Skip to main content

runner_manager_platform/
process.rs

1// owner: d1-platform-core
2
3//! Spawning, observing and terminating a child process; a process identity
4//! that survives a restart; and the restrictive handoff that gets a JIT
5//! configuration to a runner without it ever appearing in a process listing.
6//!
7//! # A PID is not an identity
8//!
9//! The agent records the runner processes it started in a durable journal and
10//! reads that journal back after a restart (`03-control-flows.md`, flow 3.2).
11//! If the record were a bare PID, then after a reboot — or after enough process
12//! churn — the PID in the journal may belong to somebody else's process. Acting
13//! on that record means either adopting a stranger as a runner or, worse,
14//! terminating it. `e3`'s restart-recovery Definition of Done ("a journal
15//! containing a live process adopts it without starting a duplicate") rests
16//! entirely on telling those cases apart.
17//!
18//! So [`ProcessIdentity`] is a PID **plus a start token**: an opaque,
19//! platform-defined string that changes when the process at that PID changes.
20//! [`ProcessIdentity::recheck`] re-resolves the token and answers
21//! [`Adoption::Live`], [`Adoption::Gone`], or [`Adoption::PidRecycled`] — three
22//! answers, because collapsing the last two into "not live" is exactly the bug
23//! this type exists to prevent.
24//!
25//! | | Start token | Resolution | Distinct across a reboot |
26//! |---|---|---|---|
27//! | Windows | `GetProcessTimes` creation `FILETIME` | 100 ns | yes, it is an absolute time |
28//! | macOS | `proc_pidinfo(PROC_PIDTBSDINFO)` start `timeval` | 1 µs | yes, it is an absolute time |
29//! | Linux | boot id + `/proc/<pid>/stat` field 22 | one clock tick, typically 10 ms | yes, the boot id changes every boot |
30//!
31//! The Linux token pairs the boot identifier with the raw tick count rather
32//! than converting ticks to a wall-clock time. Field 22 counts ticks *since
33//! boot*, so on its own it repeats after every reboot; and dividing by
34//! `sysconf(_SC_CLK_TCK)` and adding `btime` would produce an absolute time
35//! whose precision is bounded by `btime`'s whole seconds — coarser than the
36//! ticks it was derived from, and coarser than a PID-recycling discriminator
37//! wants. Prefixing the tick count with `/proc/sys/kernel/random/boot_id`
38//! keeps the full tick resolution *and* makes the token unrepeatable across a
39//! reboot. See [`Adoption`] for what that buys.
40//!
41//! # The JIT configuration never becomes an argument
42//!
43//! `07-security.md`'s threat table: *"A process listing reveals a JIT config"*,
44//! controlled by *"Do not pass JIT data as a command-line argument; use
45//! restrictive file/pipe handoff"*. [`RestrictiveHandoff`] is that file, and
46//! [`SpawnSpec::spawn_with_handoff`] refuses to spawn when the payload appears
47//! in any argument or environment value, so an obvious mistake fails the launch
48//! instead of failing a review. [`SpawnSpec::spawn_runner_with_handoff`] is the
49//! one narrow exception: GitHub Runner's supported JIT intake is the secret
50//! `ACTIONS_RUNNER_INPUT_JITCONFIG` environment input. The value is injected
51//! only while creating the child, is never retained in the public spawn spec,
52//! and Runner masks and removes it as its command parser starts.
53//!
54//! **It is a tripwire, not a proof.** A caller that passes it has not been
55//! shown to be safe. The check looks for the payload as a verbatim substring of
56//! each argument's and each environment *value's* `to_string_lossy()`, and that
57//! is all it looks for. It does not inspect the program path, the working
58//! directory, or environment variable *names*; and anything that re-encodes the
59//! payload — base64 of the base64, URL-escaping, a different Unicode
60//! normalisation — or splits it across two arguments walks straight past it.
61//! The control that actually holds is either *"pass the handoff file's path"*
62//! to a program that supports one or use the dedicated Runner intake above;
63//! `e3` must not read a passing generic check as evidence that a configuration
64//! cannot reach a process listing.
65
66use std::ffi::{OsStr, OsString};
67use std::fmt;
68use std::path::{Path, PathBuf};
69use std::process::{Child, Command, ExitStatus, Stdio};
70use std::time::{Duration, Instant};
71
72use secrecy::{ExposeSecret, SecretString};
73use serde::{Deserialize, Serialize};
74
75/// GitHub Runner's supported process-safe JIT configuration input.
76///
77/// `actions/runner` v2.336.0 reads every `ACTIONS_RUNNER_INPUT_*` variable in
78/// `CommandSettings`, registers secret inputs with its masker, and removes the
79/// variable from its environment before `Runner.ExecuteCommand` decodes it.
80const RUNNER_JIT_CONFIG_ENV: &str = "ACTIONS_RUNNER_INPUT_JITCONFIG";
81
82// ---------------------------------------------------------------------------
83// Errors
84// ---------------------------------------------------------------------------
85
86/// Something went wrong spawning, observing, or identifying a process.
87#[derive(Debug, thiserror::Error)]
88pub enum ProcessError {
89    /// The program could not be launched.
90    #[error("cannot start {}: {source}", program.display())]
91    Spawn {
92        /// The program that could not be launched.
93        program: PathBuf,
94        /// The underlying error.
95        #[source]
96        source: std::io::Error,
97    },
98
99    /// The operating system would not say when a process started.
100    #[error("cannot read the start time of process {pid}: {source}")]
101    Identity {
102        /// The process that could not be identified.
103        pid: u32,
104        /// The underlying error.
105        #[source]
106        source: std::io::Error,
107    },
108
109    /// No live process holds this PID, so it has no identity to record.
110    #[error("no live process holds PID {pid}")]
111    NoSuchProcess {
112        /// The PID that resolved to nothing.
113        pid: u32,
114    },
115
116    /// Waiting on, signalling, or killing a process failed.
117    #[error("cannot control process {pid}: {source}")]
118    Control {
119        /// The process that could not be controlled.
120        pid: u32,
121        /// The underlying error.
122        #[source]
123        source: std::io::Error,
124    },
125
126    /// The handoff payload was about to be visible in a process listing.
127    ///
128    /// Returned by [`SpawnSpec::spawn_with_handoff`] rather than logged,
129    /// because the whole point is that the launch must not happen.
130    #[error(
131        "refusing to start {}: the handoff payload appears in {location}, which would put \
132         it in this machine's process listing. Pass the handoff file's path instead \
133         (`07-security.md`, threat table).",
134        program.display()
135    )]
136    SecretInCommandLine {
137        /// The program that would have been launched.
138        program: PathBuf,
139        /// Where the payload was found — an argument index or an environment
140        /// variable name.
141        location: String,
142    },
143}
144
145// ---------------------------------------------------------------------------
146// Process identity
147// ---------------------------------------------------------------------------
148
149/// A PID paired with a token that changes when the process at that PID does.
150///
151/// Serializable because its whole reason to exist is being written to the
152/// attempt journal and read back after a restart. The token is opaque: its
153/// shape is documented at the module level for diagnostics, but nothing should
154/// parse it. Comparison — not interpretation — is the operation it supports.
155#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
156pub struct ProcessIdentity {
157    pid: u32,
158    start_token: String,
159}
160
161/// The start token of a child that was gone before its identity could be read.
162///
163/// Deliberately not a value any platform produces: every real token is
164/// `<platform>:<instant>`, so this can never compare equal to a live process
165/// and never claims a start instant that was not observed. See
166/// [`ProcessIdentity::of_child`].
167const EXITED_BEFORE_IDENTIFIED: &str = "exited-before-identified";
168
169/// What a recorded [`ProcessIdentity`] turns out to refer to now.
170///
171/// # One journal entry can answer differently on different platforms
172///
173/// For a PID now held by **another account's** process, Linux answers
174/// [`Adoption::PidRecycled`] while macOS and Windows answer
175/// [`ProcessError::Identity`]: `/proc/<pid>/stat` is world-readable, whereas
176/// `proc_pidinfo` and `OpenProcess` refuse an inspection this account is not
177/// entitled to. So the same journal entry, read back after the agent's service
178/// account has been changed, is a recycled PID on one platform and an error on
179/// the other two. `e3` branches on this, and should treat the error as the same
180/// *decision* as `PidRecycled` — do not adopt, do not terminate — rather than
181/// as a platform bug.
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub enum Adoption {
184    /// The same process is still running. Adopt it; do not start a replacement.
185    Live,
186
187    /// Nothing holds the PID. The process is gone and its attempt is terminal.
188    Gone,
189
190    /// Something holds the PID, but it is not the recorded process.
191    ///
192    /// Distinct from [`Adoption::Gone`] on purpose. The recorded process is
193    /// equally gone in both cases, but here there is a stranger at that PID,
194    /// and treating this as `Gone` is one refactor away from treating it as
195    /// `Live` — which is how an agent terminates somebody else's process.
196    PidRecycled {
197        /// Whoever holds the PID now.
198        current: ProcessIdentity,
199    },
200}
201
202impl Adoption {
203    /// Whether the recorded process is still running.
204    #[must_use]
205    pub const fn is_live(&self) -> bool {
206        matches!(self, Self::Live)
207    }
208}
209
210/// The outcome of asking a recorded process to stop.
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub enum Termination {
213    /// The process was signalled and is no longer running.
214    Terminated,
215    /// It had already exited; nothing to do.
216    AlreadyGone,
217    /// The PID belongs to a different process now, so nothing was signalled.
218    ///
219    /// The refusal is the feature. See [`Adoption::PidRecycled`].
220    RefusedPidRecycled {
221        /// Whoever holds the PID now.
222        current: ProcessIdentity,
223    },
224}
225
226impl ProcessIdentity {
227    /// Reads the identity of a live process.
228    ///
229    /// # Errors
230    ///
231    /// [`ProcessError::NoSuchProcess`] when nothing holds the PID, and
232    /// [`ProcessError::Identity`] when the operating system refuses to answer —
233    /// a process owned by another account, for instance. The two are separate
234    /// because "gone" is a normal lifecycle answer and "refused" is a
235    /// misconfiguration.
236    pub fn resolve(pid: u32) -> Result<Self, ProcessError> {
237        Self::read(pid, LivenessFilter::LiveOnly)
238    }
239
240    /// Reads the identity of a process this program has just spawned and still
241    /// holds the handle to.
242    ///
243    /// Separate from [`ProcessIdentity::resolve`] because of a race that would
244    /// otherwise make short-lived children unlaunchable: a program that exits
245    /// before its parent gets round to identifying it is *already* not live by
246    /// the time the identity is read, and `resolve` would answer
247    /// [`ProcessError::NoSuchProcess`]. Reading an exited child's start time is
248    /// safe in a way that reading an arbitrary exited PID's is not — the parent
249    /// holds the child handle, so the PID cannot have been reused underneath
250    /// it.
251    fn of_child(pid: u32) -> Result<Self, ProcessError> {
252        match Self::read(pid, LivenessFilter::IncludeExited) {
253            Ok(identity) => Ok(identity),
254            // ------------------------------------------------------------
255            // THE RACE THIS FUNCTION EXISTS FOR, ON THE ONE PLATFORM WHERE
256            // `IncludeExited` IS NOT ENOUGH TO SURVIVE IT.
257            // ------------------------------------------------------------
258            // The filter above covers a *zombie*: exited, unreaped, still
259            // holding its PID. macOS has a narrower window than that.
260            // `proc_pidinfo(PROC_PIDTBSDINFO)` answers `ESRCH` for a child
261            // that has only just gone, and the filter never gets a say --
262            // so a program that exits fast enough could not be launched at
263            // all, which is the exact failure the documentation above says
264            // this function prevents. It cost two CI runs before it was
265            // recognised as that rather than as noise.
266            //
267            // A start token is a claim about *when a process started*, used
268            // to notice a PID that has since been reused. There is no such
269            // instant to read here, and inventing one would be a lie a later
270            // comparison could believe. This sentinel is the honest value:
271            // it matches nothing, so [`ProcessIdentity::classify`] answers
272            // `Gone` for a PID nobody holds and `PidRecycled` for one
273            // somebody else has taken -- and `PidRecycled` is refused rather
274            // than signalled, which is the safe half of the pair.
275            //
276            // Only the parent reaches this: it holds the child handle, so the
277            // PID cannot already belong to somebody else at this moment.
278            Err(ProcessError::NoSuchProcess { .. }) => Ok(Self {
279                pid,
280                start_token: EXITED_BEFORE_IDENTIFIED.to_string(),
281            }),
282            Err(other) => Err(other),
283        }
284    }
285
286    fn read(pid: u32, filter: LivenessFilter) -> Result<Self, ProcessError> {
287        match sys::start_token(pid, filter) {
288            Ok(Some(start_token)) => Ok(Self { pid, start_token }),
289            Ok(None) => Err(ProcessError::NoSuchProcess { pid }),
290            Err(source) => Err(ProcessError::Identity { pid, source }),
291        }
292    }
293
294    /// The identity of the calling process.
295    ///
296    /// # Errors
297    ///
298    /// As [`ProcessIdentity::resolve`], though a process can always see itself
299    /// in practice.
300    pub fn of_current_process() -> Result<Self, ProcessError> {
301        Self::resolve(std::process::id())
302    }
303
304    /// The PID. Recorded in `RunnerAttempt::process_id` and shown in the UI;
305    /// never used on its own to decide whether a process is ours.
306    #[must_use]
307    pub const fn pid(&self) -> u32 {
308        self.pid
309    }
310
311    /// The opaque start token. Exposed for diagnostics only.
312    #[must_use]
313    pub fn start_token(&self) -> &str {
314        &self.start_token
315    }
316
317    /// Re-resolves this identity against the machine as it is now.
318    ///
319    /// # Errors
320    ///
321    /// [`ProcessError::Identity`] when the operating system refuses to answer.
322    /// A PID that resolves to nothing is [`Adoption::Gone`], not an error.
323    pub fn recheck(&self) -> Result<Adoption, ProcessError> {
324        match sys::start_token(self.pid, LivenessFilter::LiveOnly) {
325            Ok(observed) => Ok(self.classify(observed)),
326            Err(source) => Err(ProcessError::Identity {
327                pid: self.pid,
328                source,
329            }),
330        }
331    }
332
333    /// The adoption decision itself, with the operating system already
334    /// consulted.
335    ///
336    /// Split out of [`ProcessIdentity::recheck`] so that all three answers can
337    /// be exercised without spawning anything. One of them cannot be reached
338    /// through a spawn on demand: two processes carrying an *identical* start
339    /// token. On Windows (100 ns) and macOS (1 µs) that is unreachable, and on
340    /// Linux it happens only by landing inside the same 10 ms clock tick, which
341    /// is a race a test cannot ask for. Comparing tokens here rather than
342    /// inline means the discriminator can be shown to behave correctly on that
343    /// input from every leg of the CI matrix.
344    fn classify(&self, observed: Option<String>) -> Adoption {
345        match observed {
346            None => Adoption::Gone,
347            Some(token) if token == self.start_token => Adoption::Live,
348            Some(token) => Adoption::PidRecycled {
349                current: Self {
350                    pid: self.pid,
351                    start_token: token,
352                },
353            },
354        }
355    }
356
357    /// Stops the process this identity names, and nothing else.
358    ///
359    /// Re-checks the identity immediately before signalling. That check is the
360    /// difference between "terminate the runner recorded in the journal" and
361    /// "terminate whatever now holds PID 4312", and it is why the recycled case
362    /// returns [`Termination::RefusedPidRecycled`] rather than proceeding.
363    ///
364    /// `grace` applies only where the platform has a polite stop to offer: on
365    /// Unix the process is sent `SIGTERM`, given `grace` to exit, and then
366    /// `SIGKILL`ed. Windows has no console-independent equivalent, so the
367    /// process is terminated immediately and `grace` is ignored — stated here
368    /// rather than emulated, because a fake grace period that never actually
369    /// asks politely is worse than no grace period.
370    ///
371    /// # Errors
372    ///
373    /// [`ProcessError::Control`] when signalling fails for a reason other than
374    /// the process having already exited.
375    pub fn terminate(&self, grace: Duration) -> Result<Termination, ProcessError> {
376        match self.recheck()? {
377            Adoption::Gone => return Ok(Termination::AlreadyGone),
378            Adoption::PidRecycled { current } => {
379                return Ok(Termination::RefusedPidRecycled { current });
380            }
381            Adoption::Live => {}
382        }
383
384        let requested = sys::request_stop(self.pid).map_err(|source| ProcessError::Control {
385            pid: self.pid,
386            source,
387        })?;
388
389        if requested {
390            let deadline = Instant::now() + grace;
391            while Instant::now() < deadline {
392                if matches!(
393                    self.recheck()?,
394                    Adoption::Gone | Adoption::PidRecycled { .. }
395                ) {
396                    return Ok(Termination::Terminated);
397                }
398                std::thread::sleep(POLL_INTERVAL);
399            }
400        }
401
402        // Either the platform has no polite stop, or the grace period expired.
403        // Re-check once more so a process that exited during the wait is not
404        // reported as force-killed, and so a PID recycled during the wait is
405        // not force-killed at all.
406        match self.recheck()? {
407            Adoption::Gone => Ok(Termination::AlreadyGone),
408            Adoption::PidRecycled { current } => Ok(Termination::RefusedPidRecycled { current }),
409            Adoption::Live => {
410                sys::force_stop(self.pid).map_err(|source| ProcessError::Control {
411                    pid: self.pid,
412                    source,
413                })?;
414                Ok(Termination::Terminated)
415            }
416        }
417    }
418}
419
420/// When this machine booted, as Linux reports it in `/proc/stat`. `None`
421/// elsewhere, and where it cannot be read.
422#[must_use]
423pub fn boot_time() -> Option<chrono::DateTime<chrono::Utc>> {
424    #[cfg(target_os = "linux")]
425    {
426        sys::btime()
427            .ok()
428            .and_then(|seconds| seconds.parse().ok())
429            .and_then(|seconds| chrono::DateTime::from_timestamp(seconds, 0))
430    }
431    #[cfg(not(target_os = "linux"))]
432    {
433        None
434    }
435}
436
437impl fmt::Display for ProcessIdentity {
438    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
439        write!(f, "pid {} started {}", self.pid, self.start_token)
440    }
441}
442
443/// How often a wait loop re-checks. Short enough that a terminating runner is
444/// noticed promptly, long enough that a grace period is not a spin.
445const POLL_INTERVAL: Duration = Duration::from_millis(20);
446
447/// Whether a start-time lookup should answer for a process that has already
448/// exited but whose PID is not yet free.
449///
450/// Both states exist on both families: a Unix child that has exited and not
451/// been reaped is a zombie holding its PID, and a Windows process whose handle
452/// is still open leaves a process object behind with a non-zero exit time.
453#[derive(Debug, Clone, Copy, PartialEq, Eq)]
454enum LivenessFilter {
455    /// Answer only for a process that is still running. What recovery and
456    /// adoption need: an exited process is not something to adopt.
457    LiveOnly,
458    /// Answer for an exited process too. Only ever used by the parent of that
459    /// process, which holds its handle and therefore knows the PID is not
460    /// somebody else's.
461    IncludeExited,
462}
463
464// ---------------------------------------------------------------------------
465// Spawning
466// ---------------------------------------------------------------------------
467
468/// What to do with a child's standard output and standard error.
469#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
470pub enum OutputMode {
471    /// Send both to the null device. The default: a runner writes its own
472    /// diagnostics into its workspace, and inheriting its output would splice
473    /// unredacted text into this process's stream.
474    #[default]
475    Discard,
476    /// Inherit this process's handles.
477    Inherit,
478    /// Capture both, readable through [`ChildProcess::take_stdout`].
479    Capture,
480}
481
482/// A child process about to be launched.
483///
484/// A builder rather than a bare [`Command`] so that the arguments and the
485/// environment can be inspected before the launch — which is what
486/// [`SpawnSpec::spawn_with_handoff`] does.
487#[derive(Debug, Clone)]
488pub struct SpawnSpec {
489    program: PathBuf,
490    args: Vec<OsString>,
491    envs: Vec<(OsString, OsString)>,
492    removed_envs: Vec<OsString>,
493    working_dir: Option<PathBuf>,
494    output: OutputMode,
495    normal_scheduling: bool,
496}
497
498impl SpawnSpec {
499    /// Starts a specification for `program`.
500    pub fn new(program: impl Into<PathBuf>) -> Self {
501        Self {
502            program: program.into(),
503            args: Vec::new(),
504            envs: Vec::new(),
505            removed_envs: Vec::new(),
506            working_dir: None,
507            output: OutputMode::Discard,
508            normal_scheduling: false,
509        }
510    }
511
512    /// Appends one argument.
513    #[must_use]
514    pub fn arg(mut self, arg: impl AsRef<OsStr>) -> Self {
515        self.args.push(arg.as_ref().to_os_string());
516        self
517    }
518
519    /// Appends several arguments.
520    #[must_use]
521    pub fn args<I, S>(mut self, args: I) -> Self
522    where
523        I: IntoIterator<Item = S>,
524        S: AsRef<OsStr>,
525    {
526        self.args
527            .extend(args.into_iter().map(|a| a.as_ref().to_os_string()));
528        self
529    }
530
531    /// Sets one environment variable for the child, on top of the inherited
532    /// environment.
533    #[must_use]
534    pub fn env(mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
535        self.envs
536            .push((key.as_ref().to_os_string(), value.as_ref().to_os_string()));
537        self
538    }
539
540    /// Prevents an inherited variable from changing a subprocess's operation.
541    #[must_use]
542    pub fn without_env(mut self, key: impl AsRef<OsStr>) -> Self {
543        self.removed_envs.push(key.as_ref().to_owned());
544        self
545    }
546
547    /// Sets the child's working directory.
548    #[must_use]
549    pub fn working_dir(mut self, dir: impl Into<PathBuf>) -> Self {
550        self.working_dir = Some(dir.into());
551        self
552    }
553
554    /// Chooses what happens to the child's output.
555    #[must_use]
556    pub fn output(mut self, output: OutputMode) -> Self {
557        self.output = output;
558        self
559    }
560
561    /// Declares that the child must run at normal scheduling priority, and
562    /// has the launch say so when it does not.
563    ///
564    /// On macOS the parent reads the child's base priority back after the
565    /// spawn and logs a warning when it is at the background ceiling. A
566    /// throttled runner still runs its job correctly, only 4 to 20 times
567    /// slower, so this reports rather than refuses.
568    ///
569    /// Nothing here changes the child's priority. Under launchd the priority
570    /// comes from the job's `ProcessType`, which every descendant inherits and
571    /// none can leave (`crate::service::LAUNCHD_PROCESS_TYPE` has the
572    /// measurements), so the fix belongs in the plist. 0.4.31 cleared
573    /// `PRIO_DARWIN_PROCESS` between `fork` and `exec` instead; the call
574    /// succeeded and the runner stayed at 4, and this warning is what showed
575    /// it.
576    ///
577    /// No effect on other platforms.
578    #[must_use]
579    pub fn expect_normal_scheduling(mut self) -> Self {
580        self.normal_scheduling = true;
581        self
582    }
583
584    /// The arguments as configured. Exposed so a security test can assert what
585    /// a process listing would show.
586    #[must_use]
587    pub fn arguments(&self) -> &[OsString] {
588        &self.args
589    }
590
591    /// The variable the child gets for `name`, as configured on top of the
592    /// inherited environment: the last [`Self::env`] for it wins, as it does in
593    /// the child. Names compare without case on Windows, as Windows does.
594    #[must_use]
595    pub fn configured_env(&self, name: &str) -> Option<&OsStr> {
596        self.envs
597            .iter()
598            .rev()
599            .find(|(key, _)| {
600                key.to_str()
601                    .is_some_and(|key| crate::runner_env::same_name(key, name))
602            })
603            .map(|(_, value)| value.as_os_str())
604    }
605
606    /// Whether [`Self::expect_normal_scheduling`] was asked for.
607    #[doc(hidden)]
608    #[must_use]
609    pub const fn expects_normal_scheduling(&self) -> bool {
610        self.normal_scheduling
611    }
612
613    /// Launches the child.
614    ///
615    /// # Errors
616    ///
617    /// [`ProcessError::Spawn`] when the program cannot be launched, and
618    /// [`ProcessError::Identity`] when it launches but its start time cannot be
619    /// read — which would leave an unrecordable process running, so the child
620    /// is killed rather than leaked.
621    pub fn spawn(&self) -> Result<ChildProcess, ProcessError> {
622        self.spawn_with_extra_env(None)
623    }
624
625    fn spawn_with_extra_env(
626        &self,
627        extra_env: Option<(&OsStr, &OsStr)>,
628    ) -> Result<ChildProcess, ProcessError> {
629        let mut command = Command::new(&self.program);
630        #[cfg(unix)]
631        std::os::unix::process::CommandExt::process_group(&mut command, 0);
632
633        command.args(&self.args);
634        for key in &self.removed_envs {
635            command.env_remove(key);
636        }
637        for (key, value) in &self.envs {
638            command.env(key, value);
639        }
640        if let Some((key, value)) = extra_env {
641            command.env(key, value);
642        }
643        if let Some(dir) = &self.working_dir {
644            command.current_dir(dir);
645        }
646        // Never inherited: a runner that reads this process's stdin would block
647        // the agent, and nothing in this design feeds a child interactively.
648        command.stdin(Stdio::null());
649        let (stdout, stderr) = match self.output {
650            OutputMode::Discard => (Stdio::null(), Stdio::null()),
651            OutputMode::Inherit => (Stdio::inherit(), Stdio::inherit()),
652            OutputMode::Capture => (Stdio::piped(), Stdio::piped()),
653        };
654        command.stdout(stdout).stderr(stderr);
655
656        let child = command.spawn().map_err(|source| ProcessError::Spawn {
657            program: self.program.clone(),
658            source,
659        })?;
660
661        let pid = child.id();
662        #[cfg(target_os = "macos")]
663        if self.normal_scheduling && sys::runs_throttled(pid) {
664            tracing::warn!(
665                pid,
666                reason = "runner_started_at_background_priority",
667                "the runner process still runs at background priority, so its jobs will be slow"
668            );
669        }
670        match ProcessIdentity::of_child(pid) {
671            Ok(identity) => Ok(ChildProcess {
672                child,
673                identity,
674                program: self.program.clone(),
675            }),
676            Err(error) => {
677                // An unidentifiable child cannot be journalled, so it cannot be
678                // recovered after a restart. Leaving it running would create
679                // exactly the orphan the journal exists to prevent.
680                let mut child = child;
681                let _ = child.kill();
682                let _ = child.wait();
683                Err(error)
684            }
685        }
686    }
687
688    /// Launches the child after proving the handoff payload is not in the
689    /// command line or the environment.
690    ///
691    /// This is the enforcement point for `07-security.md`'s control on *"A
692    /// process listing reveals a JIT config"*. `e3` should reach for this and
693    /// not for [`SpawnSpec::spawn`], because a rule that is checked is a rule,
694    /// and a rule that is written down is a hope.
695    ///
696    /// # Errors
697    ///
698    /// [`ProcessError::SecretInCommandLine`] when the payload appears in an
699    /// argument or an environment value, plus everything
700    /// [`SpawnSpec::spawn`] returns.
701    pub fn spawn_with_handoff(
702        &self,
703        handoff: &RestrictiveHandoff,
704    ) -> Result<ChildProcess, ProcessError> {
705        self.reject_exposed_handoff(handoff)?;
706        self.spawn()
707    }
708
709    /// Launches GitHub Runner using its supported process-safe JIT input.
710    ///
711    /// The encoded configuration is deliberately absent from [`SpawnSpec`]:
712    /// callers cannot render it as an argument or accidentally retain it in a
713    /// reusable specification. It is copied from the restrictive handoff into
714    /// the child's initial environment at the final `Command::spawn` boundary.
715    /// GitHub Runner's `CommandSettings` treats `jitconfig` as a secret and
716    /// removes `ACTIONS_RUNNER_INPUT_JITCONFIG` from the process environment
717    /// before executing the `run` command.
718    ///
719    /// The caller still owns deleting `handoff` immediately after this method
720    /// returns. A failed launch leaves deletion to [`RestrictiveHandoff`]'s
721    /// fail-closed `Drop` implementation.
722    ///
723    /// # Errors
724    ///
725    /// [`ProcessError::SecretInCommandLine`] when the payload was also placed
726    /// in an argument or explicitly configured environment value, plus every
727    /// error returned by [`SpawnSpec::spawn`].
728    pub fn spawn_runner_with_handoff(
729        &self,
730        handoff: &RestrictiveHandoff,
731    ) -> Result<ChildProcess, ProcessError> {
732        self.reject_exposed_handoff(handoff)?;
733        self.spawn_with_extra_env(Some((
734            OsStr::new(RUNNER_JIT_CONFIG_ENV),
735            OsStr::new(handoff.payload.expose_secret()),
736        )))
737    }
738
739    fn reject_exposed_handoff(&self, handoff: &RestrictiveHandoff) -> Result<(), ProcessError> {
740        let payload = handoff.payload.expose_secret();
741        // An empty payload cannot meaningfully be searched for — every string
742        // contains it — and it is not a secret worth protecting either.
743        if !payload.is_empty() {
744            for (index, arg) in self.args.iter().enumerate() {
745                if os_str_contains(arg, payload) {
746                    return Err(ProcessError::SecretInCommandLine {
747                        program: self.program.clone(),
748                        location: format!("argument {index}"),
749                    });
750                }
751            }
752            for (key, value) in &self.envs {
753                if os_str_contains(value, payload) {
754                    return Err(ProcessError::SecretInCommandLine {
755                        program: self.program.clone(),
756                        location: format!("environment variable {}", key.to_string_lossy()),
757                    });
758                }
759            }
760        }
761
762        Ok(())
763    }
764}
765
766/// Whether `haystack` contains `needle`, for an [`OsStr`] that may not be valid
767/// Unicode.
768///
769/// `to_string_lossy` is enough here: the payload is a base64 JIT configuration,
770/// so it is ASCII, and lossy conversion replaces only the bytes that could not
771/// have matched it anyway.
772fn os_str_contains(haystack: &OsStr, needle: &str) -> bool {
773    haystack.to_string_lossy().contains(needle)
774}
775
776/// A child process this agent started.
777///
778/// `04-subsystem-contracts.md`: *"local process state is authoritative only for
779/// a child process owned by this agent"*. That is this type. For a process
780/// recovered from the journal after a restart there is no [`Child`] and no
781/// parent relationship, so [`ProcessIdentity`] is the authority instead.
782#[derive(Debug)]
783pub struct ChildProcess {
784    child: Child,
785    identity: ProcessIdentity,
786    program: PathBuf,
787}
788
789impl ChildProcess {
790    /// The identity to record in the journal.
791    #[must_use]
792    pub const fn identity(&self) -> &ProcessIdentity {
793        &self.identity
794    }
795
796    /// The child's PID.
797    #[must_use]
798    pub const fn pid(&self) -> u32 {
799        self.identity.pid
800    }
801
802    /// Whether the child is still running.
803    ///
804    /// # Errors
805    ///
806    /// [`ProcessError::Control`] when the wait fails.
807    pub fn is_running(&mut self) -> Result<bool, ProcessError> {
808        Ok(self.try_exit_status()?.is_none())
809    }
810
811    /// The child's exit status if it has already exited, reaping it if so.
812    ///
813    /// # Errors
814    ///
815    /// [`ProcessError::Control`] when the wait fails.
816    pub fn try_exit_status(&mut self) -> Result<Option<ExitStatus>, ProcessError> {
817        self.child
818            .try_wait()
819            .map_err(|source| ProcessError::Control {
820                pid: self.identity.pid,
821                source,
822            })
823    }
824
825    /// Blocks until the child exits.
826    ///
827    /// # Errors
828    ///
829    /// [`ProcessError::Control`] when the wait fails.
830    pub fn wait(&mut self) -> Result<ExitStatus, ProcessError> {
831        self.child.wait().map_err(|source| ProcessError::Control {
832            pid: self.identity.pid,
833            source,
834        })
835    }
836
837    /// Waits up to `timeout` for the child to exit; `None` means it is still
838    /// running when the timeout expires.
839    ///
840    /// # Errors
841    ///
842    /// [`ProcessError::Control`] when the wait fails.
843    pub fn wait_for(&mut self, timeout: Duration) -> Result<Option<ExitStatus>, ProcessError> {
844        let deadline = Instant::now() + timeout;
845        loop {
846            if let Some(status) = self.try_exit_status()? {
847                return Ok(Some(status));
848            }
849            if Instant::now() >= deadline {
850                return Ok(None);
851            }
852            std::thread::sleep(POLL_INTERVAL);
853        }
854    }
855
856    /// Asks the child to stop, forcefully if `grace` expires first.
857    ///
858    /// See [`ProcessIdentity::terminate`] for what `grace` means on each
859    /// platform. Unlike that method this one has the parent relationship, so it
860    /// reaps the child and returns its exit status.
861    ///
862    /// # Errors
863    ///
864    /// [`ProcessError::Control`] when signalling or waiting fails.
865    pub fn stop(&mut self, grace: Duration) -> Result<ExitStatus, ProcessError> {
866        if let Some(status) = self.try_exit_status()? {
867            return Ok(status);
868        }
869
870        let pid = self.identity.pid;
871        // Safe to signal by PID: this process holds the child handle, so the
872        // PID cannot have been recycled behind our back.
873        let requested =
874            sys::request_stop(pid).map_err(|source| ProcessError::Control { pid, source })?;
875
876        if requested && let Some(status) = self.wait_for(grace)? {
877            return Ok(status);
878        }
879
880        self.child
881            .kill()
882            .map_err(|source| ProcessError::Control { pid, source })?;
883        self.wait()
884    }
885
886    /// Takes the captured standard output, if [`OutputMode::Capture`] was set.
887    pub fn take_stdout(&mut self) -> Option<std::process::ChildStdout> {
888        self.child.stdout.take()
889    }
890
891    /// Takes the captured standard error, if [`OutputMode::Capture`] was set.
892    pub fn take_stderr(&mut self) -> Option<std::process::ChildStderr> {
893        self.child.stderr.take()
894    }
895
896    /// The program that was launched.
897    #[must_use]
898    pub fn program(&self) -> &Path {
899        &self.program
900    }
901}
902
903// ---------------------------------------------------------------------------
904// Restrictive handoff
905// ---------------------------------------------------------------------------
906
907/// Something went wrong creating, inspecting, or removing a handoff file.
908#[derive(Debug, thiserror::Error)]
909pub enum HandoffError {
910    /// The file could not be created with restrictive permissions.
911    #[error("cannot create a restrictive handoff file in {}: {source}", directory.display())]
912    Create {
913        /// The directory the file was to be created in.
914        directory: PathBuf,
915        /// The underlying error.
916        #[source]
917        source: std::io::Error,
918    },
919
920    /// The payload could not be written.
921    #[error("cannot write the handoff payload to {}: {source}", path.display())]
922    Write {
923        /// The file that could not be written.
924        path: PathBuf,
925        /// The underlying error.
926        #[source]
927        source: std::io::Error,
928    },
929
930    /// The file's permissions could not be read back.
931    #[error("cannot read the permissions of {}: {source}", path.display())]
932    Inspect {
933        /// The file that could not be inspected.
934        path: PathBuf,
935        /// The underlying error.
936        #[source]
937        source: std::io::Error,
938    },
939
940    /// The file could not be removed.
941    ///
942    /// Worth an error rather than a shrug: a JIT configuration left on disk is
943    /// the exact thing `05-infrastructure.md` says must be deleted immediately.
944    #[error("cannot delete the handoff file {}: {source}", path.display())]
945    Delete {
946        /// The file that could not be removed.
947        path: PathBuf,
948        /// The underlying error.
949        #[source]
950        source: std::io::Error,
951    },
952}
953
954/// What a file's permissions amount to, in terms this product cares about.
955#[derive(Debug, Clone, PartialEq, Eq)]
956pub struct PermissionsSummary {
957    /// Platform-native description — a Unix mode, or a Windows DACL in SDDL
958    /// form. For diagnostics and for test failure messages.
959    pub description: String,
960    /// Whether an ordinary local user other than the file's owner could read
961    /// it.
962    ///
963    /// A local administrator or `root` is deliberately outside this question:
964    /// `07-security.md` records that such an account is already assumed able to
965    /// read the runner's credentials and job workspaces, so pretending a file
966    /// mode could exclude it would be theatre.
967    pub readable_by_other_local_users: bool,
968}
969
970/// Reads back what a file's permissions actually grant.
971///
972/// # Errors
973///
974/// [`HandoffError::Inspect`] when the permissions cannot be read.
975/// This account's SID, in the string form an SDDL trustee takes.
976///
977/// Windows only, because a SID is. The secret store names the account that
978/// wrote a value explicitly rather than describing it as `OW`: OWNER RIGHTS is
979/// deleted by the system whenever an object's owner changes, so a DACL that
980/// leans on it loses the grant the moment anybody runs `takeown`.
981///
982/// # Errors
983/// Whatever reading this process's own token reported.
984#[cfg(windows)]
985pub(crate) fn current_user_sid() -> std::io::Result<String> {
986    sys::current_user_sid()
987}
988
989/// Whether this process runs at the background priority ceiling, by the same
990/// reading the post-spawn runner check uses.
991#[cfg(target_os = "macos")]
992#[must_use]
993pub fn runs_at_background_priority() -> bool {
994    sys::runs_throttled(std::process::id())
995}
996
997pub fn permissions_summary(path: &Path) -> Result<PermissionsSummary, HandoffError> {
998    sys::describe_permissions(path)
999        .map(
1000            |(description, readable_by_other_local_users)| PermissionsSummary {
1001                description,
1002                readable_by_other_local_users,
1003            },
1004        )
1005        .map_err(|source| HandoffError::Inspect {
1006            path: path.to_path_buf(),
1007            source,
1008        })
1009}
1010
1011/// A short-lived file holding a secret, readable only by this account, deleted
1012/// on every path out.
1013///
1014/// `05-infrastructure.md` puts the encoded JIT configuration in a *"restrictive
1015/// temporary file or process-safe handoff"* and requires *"Delete immediately
1016/// after runner start or failed start"*. Both halves of that sentence are
1017/// implemented here: [`RestrictiveHandoff::create`] makes the file
1018/// unreadable by other local users at the moment of creation, and [`Drop`]
1019/// deletes it whether the launch succeeded, failed, or panicked.
1020///
1021/// # Why creation, not creation-then-chmod
1022///
1023/// On Windows the file is created through `CreateFileW` with an explicit
1024/// `SECURITY_ATTRIBUTES`, and on Unix through `open(2)` with mode `0600`. In
1025/// both cases the restriction is applied *by the call that creates the file*.
1026/// Creating a file and then tightening it leaves a window — however short — in
1027/// which the JIT configuration exists on disk under whatever the parent
1028/// directory happened to grant, and a window is all a local attacker needs.
1029///
1030/// # What is not claimed
1031///
1032/// The file is deleted, not securely erased. No modern filesystem lets a
1033/// userspace program guarantee that the bytes are unrecoverable — a journal, a
1034/// copy-on-write snapshot, or an SSD's wear levelling can each keep a copy that
1035/// an overwrite never reaches. Claiming otherwise would be worse than saying
1036/// so plainly, so the control this design relies on is the file's short life
1037/// and its access control, not erasure.
1038#[derive(Debug)]
1039pub struct RestrictiveHandoff {
1040    path: PathBuf,
1041    payload: SecretString,
1042    deleted: bool,
1043}
1044
1045impl RestrictiveHandoff {
1046    /// What every handoff file's name begins with.
1047    ///
1048    /// Published because the name is otherwise a UUID nobody can predict, and
1049    /// `c3`'s persistent cleanup has to answer "did an encoded configuration
1050    /// survive into this slot?" *after* the process that owned it is gone
1051    /// (`04-security-recovery.md`: JIT values are "never retained in slot").
1052    /// A second `"jit-"` spelled out over there would be a second source of
1053    /// truth for the one fact that decides whether a secret is still on disk.
1054    pub const NAME_PREFIX: &'static str = "jit-";
1055
1056    /// Writes `payload` to a new uniquely named file in `directory`.
1057    ///
1058    /// The name is a UUID rather than a predictable one, so that another local
1059    /// account cannot pre-create the path and win the race for it; creation is
1060    /// exclusive, so if it did, this fails rather than writing into the
1061    /// squatter's file.
1062    ///
1063    /// # Errors
1064    ///
1065    /// [`HandoffError::Create`] and [`HandoffError::Write`].
1066    pub fn create(directory: &Path, payload: SecretString) -> Result<Self, HandoffError> {
1067        use std::io::Write as _;
1068
1069        let path = directory.join(format!("{}{}.tmp", Self::NAME_PREFIX, uuid::Uuid::new_v4()));
1070
1071        let mut file =
1072            sys::create_restrictive_file(&path).map_err(|source| HandoffError::Create {
1073                directory: directory.to_path_buf(),
1074                source,
1075            })?;
1076
1077        let handoff = Self {
1078            path,
1079            payload,
1080            deleted: false,
1081        };
1082
1083        // From here on the file exists, so every failure path must delete it.
1084        // `handoff` is already constructed, so `?` unwinds through its `Drop`.
1085        let write = file
1086            .write_all(handoff.payload.expose_secret().as_bytes())
1087            .and_then(|()| file.flush())
1088            .and_then(|()| file.sync_all());
1089        write.map_err(|source| HandoffError::Write {
1090            path: handoff.path.clone(),
1091            source,
1092        })?;
1093        drop(file);
1094
1095        Ok(handoff)
1096    }
1097
1098    /// The path to hand to the child process.
1099    ///
1100    /// This is the only thing that may reach a command line. The payload is not
1101    /// exposed at all: it is held as a [`SecretString`], which has no `Display`
1102    /// and a redacting `Debug`, so it cannot be formatted into an argument by
1103    /// accident.
1104    #[must_use]
1105    pub fn path(&self) -> &Path {
1106        &self.path
1107    }
1108
1109    /// What the file's permissions actually grant.
1110    ///
1111    /// # Errors
1112    ///
1113    /// [`HandoffError::Inspect`].
1114    pub fn permissions(&self) -> Result<PermissionsSummary, HandoffError> {
1115        permissions_summary(&self.path)
1116    }
1117
1118    /// Deletes the file now, reporting failure.
1119    ///
1120    /// The success path should call this rather than relying on [`Drop`], for
1121    /// one reason: `Drop` cannot report an error, and a JIT configuration that
1122    /// could not be deleted is something an operator must be told about.
1123    ///
1124    /// # Errors
1125    ///
1126    /// [`HandoffError::Delete`].
1127    pub fn delete(mut self) -> Result<(), HandoffError> {
1128        self.delete_in_place()
1129    }
1130
1131    fn delete_in_place(&mut self) -> Result<(), HandoffError> {
1132        if self.deleted {
1133            return Ok(());
1134        }
1135        match std::fs::remove_file(&self.path) {
1136            Ok(()) => {
1137                self.deleted = true;
1138                Ok(())
1139            }
1140            // Already gone is the desired end state, not a failure.
1141            Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
1142                self.deleted = true;
1143                Ok(())
1144            }
1145            Err(source) => Err(HandoffError::Delete {
1146                path: self.path.clone(),
1147                source,
1148            }),
1149        }
1150    }
1151}
1152
1153impl Drop for RestrictiveHandoff {
1154    fn drop(&mut self) {
1155        if let Err(error) = self.delete_in_place() {
1156            // The path is logged at `error` because an undeleted JIT
1157            // configuration is an operator-actionable condition, and the path
1158            // is the only actionable part. It is redacted by the log sink like
1159            // every other path (`crate::logging`).
1160            tracing::error!(
1161                event = "handoff_delete_failed",
1162                path = %self.path.display(),
1163                error = %error,
1164                "a JIT handoff file could not be deleted and is still on disk"
1165            );
1166        }
1167    }
1168}
1169
1170// ---------------------------------------------------------------------------
1171// The Linux `/proc/<pid>/stat` parser
1172// ---------------------------------------------------------------------------
1173
1174/// The two fields of `/proc/<pid>/stat` this crate reads.
1175#[cfg_attr(
1176    not(target_os = "linux"),
1177    allow(dead_code, reason = "parsed on Linux; unit tested on every platform")
1178)]
1179#[derive(Debug, Clone, PartialEq, Eq)]
1180struct ProcStat<'a> {
1181    /// Field 3: a single character, `R`, `S`, `D`, `Z`, `T`, and so on.
1182    state: &'a str,
1183    /// Field 22: the process's start time, in clock ticks since boot.
1184    start_ticks: &'a str,
1185}
1186
1187/// Parses the fields after the `comm` field of a `/proc/<pid>/stat` line.
1188///
1189/// Lives here, outside the `#[cfg(unix)]` module, for one reason: it is the
1190/// riskiest few lines in this crate and the only ones whose correctness is
1191/// pure string handling. Keeping it platform-independent means it is unit
1192/// tested on the Windows and macOS legs of CI as well as the Linux one, and by
1193/// a developer on any machine — rather than being exercised for the first time
1194/// on the leg where a mistake is a wrong process identity.
1195///
1196/// The hazard it exists to handle: **field 2 is the executable name, in
1197/// parentheses, and it may itself contain spaces and parentheses.** A process
1198/// really can be called `my prog (v2)`, and `procfs(5)` says so. Splitting the
1199/// whole line on whitespace therefore mis-indexes every later field, and does
1200/// so only for the one process whose name happens to be adversarial — which is
1201/// to say, only when someone is being adversarial. The *last* `)` on the line
1202/// is the reliable anchor, because every field after `comm` is numeric.
1203#[cfg_attr(
1204    not(target_os = "linux"),
1205    allow(dead_code, reason = "parsed on Linux; unit tested on every platform")
1206)]
1207fn parse_proc_stat(stat: &str) -> Option<ProcStat<'_>> {
1208    let mut fields = proc_stat_after_comm(stat)?.split_whitespace();
1209
1210    // `next()` yields field 3; the iterator then stands at field 4, so
1211    // `nth(k)` yields field `4 + k`.
1212    let state = fields.next()?;
1213    let start_ticks = fields.nth(22 - 4)?;
1214
1215    Some(ProcStat { state, start_ticks })
1216}
1217
1218/// Field 3 onwards of a `/proc/<pid>/stat` line: everything after the
1219/// **last** `)`, all of it numeric and whitespace separated (see
1220/// [`parse_proc_stat`] for why the last one is the only reliable anchor).
1221#[must_use]
1222pub(crate) fn proc_stat_after_comm(stat: &str) -> Option<&str> {
1223    Some(&stat[stat.rfind(')')? + 1..])
1224}
1225
1226/// Whether a failed start-time probe means the process is *gone*, as opposed to
1227/// unreadable for some other reason.
1228///
1229/// `no_such_process` is the platform's `ESRCH`, passed in rather than read from
1230/// `libc` here for the same reason [`parse_proc_stat`] lives outside the
1231/// `#[cfg(unix)]` module: this is a security-relevant decision that is
1232/// otherwise only compiled on two of the three CI legs and testable on neither
1233/// a Windows developer's machine nor the Windows leg. As a plain function over
1234/// an `Option<i32>` it is exercised everywhere.
1235///
1236/// **Only `ESRCH` is "gone".** Every other answer — `EPERM` for a process this
1237/// account may not inspect, or a zero `errno` from a call that failed without
1238/// setting one — is an error. This is the same rule the Windows leg applies to
1239/// `ERROR_ACCESS_DENIED`, and for the same reason: an unexplained failure is
1240/// not evidence of absence, and reporting [`Adoption::Gone`] for one is how an
1241/// agent decides to start a duplicate runner.
1242///
1243/// # Compiled where it is used, rather than allowed where it is not
1244///
1245/// This carried `#[cfg_attr(not(target_os = "macos"), allow(dead_code, …))]`,
1246/// and now carries a `cfg` that names macOS plus `test`. The difference is not
1247/// tidiness. An allowance leaves the lint's premise true and silences the
1248/// report; the condition it carries is a claim about every platform it does
1249/// *not* name, and getting that claim wrong is invisible until the one CI leg
1250/// that disagrees runs. That is precisely how N1 reached the Linux leg. A
1251/// `cfg` makes the premise false instead: on a platform that does not call
1252/// this, the item is not there to be dead, and there is nothing left to allow.
1253///
1254/// `test` is in the condition because the unit tests below are the reason this
1255/// function is a plain function over `Option<i32>` at all -- they are what
1256/// exercise it on the Windows and Linux legs, where the macOS caller does not
1257/// exist.
1258#[cfg(any(target_os = "macos", test))]
1259const fn probe_failure_means_gone(errno: Option<i32>, no_such_process: i32) -> bool {
1260    matches!(errno, Some(code) if code == no_such_process)
1261}
1262
1263// ---------------------------------------------------------------------------
1264// Platform implementations
1265// ---------------------------------------------------------------------------
1266//
1267// Each `sys` module offers the same five functions, and the shared code above
1268// is the only caller:
1269//
1270//   start_token(pid, filter)    -> Ok(None) when nothing matching holds the PID
1271//   request_stop(pid)           -> Ok(false) when the platform has no polite stop
1272//   force_stop(pid)             -> terminate immediately
1273//   create_restrictive_file(p)  -> a new file only this account can read
1274//   describe_permissions(p)     -> (native description, readable by others?)
1275
1276#[cfg(windows)]
1277mod sys {
1278    use std::fs::File;
1279    use std::io;
1280    use std::os::windows::ffi::OsStrExt;
1281    use std::os::windows::io::FromRawHandle;
1282    use std::path::Path;
1283
1284    use windows::Win32::Foundation::{
1285        CloseHandle, ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, ERROR_SUCCESS, FILETIME, HANDLE,
1286        HLOCAL, LocalFree,
1287    };
1288    use windows::Win32::Security::Authorization::{
1289        ConvertSecurityDescriptorToStringSecurityDescriptorW, ConvertSidToStringSidW,
1290        ConvertStringSecurityDescriptorToSecurityDescriptorW, GetNamedSecurityInfoW,
1291        SDDL_REVISION_1, SE_FILE_OBJECT,
1292    };
1293    use windows::Win32::Security::{
1294        DACL_SECURITY_INFORMATION, GetTokenInformation, PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES,
1295        TOKEN_QUERY, TOKEN_USER, TokenUser,
1296    };
1297    use windows::Win32::Storage::FileSystem::{
1298        CREATE_NEW, CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_GENERIC_READ, FILE_GENERIC_WRITE,
1299        FILE_SHARE_NONE,
1300    };
1301    use windows::Win32::System::Threading::{
1302        GetCurrentProcess, GetProcessTimes, OpenProcess, OpenProcessToken,
1303        PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_TERMINATE, TerminateProcess,
1304    };
1305    use windows::core::{PCWSTR, PWSTR};
1306
1307    /// `HRESULT_FROM_WIN32`, which windows-rs does not re-export as a function.
1308    const fn hresult_from_win32(code: u32) -> i32 {
1309        if code == 0 {
1310            0
1311        } else {
1312            ((code & 0x0000_ffff) | 0x8007_0000) as i32
1313        }
1314    }
1315
1316    fn to_wide(path: &Path) -> Vec<u16> {
1317        path.as_os_str()
1318            .encode_wide()
1319            .chain(std::iter::once(0))
1320            .collect()
1321    }
1322
1323    fn filetime_to_u64(time: FILETIME) -> u64 {
1324        (u64::from(time.dwHighDateTime) << 32) | u64::from(time.dwLowDateTime)
1325    }
1326
1327    fn io_error(error: &windows::core::Error) -> io::Error {
1328        io::Error::from_raw_os_error(error.code().0)
1329    }
1330
1331    pub(super) fn start_token(
1332        pid: u32,
1333        filter: super::LivenessFilter,
1334    ) -> io::Result<Option<String>> {
1335        let handle = match unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) } {
1336            Ok(handle) => handle,
1337            Err(error) => {
1338                // `OpenProcess` answers a PID nobody holds with
1339                // ERROR_INVALID_PARAMETER, not with a "no such process" code.
1340                // Access denied stays an error: a process this agent may not
1341                // query is not the same as one that is gone, and treating it as
1342                // gone is how an agent decides to start a duplicate.
1343                let code = error.code().0;
1344                if code == hresult_from_win32(ERROR_INVALID_PARAMETER.0) {
1345                    return Ok(None);
1346                }
1347                if code == hresult_from_win32(ERROR_ACCESS_DENIED.0) {
1348                    return Err(io::Error::new(
1349                        io::ErrorKind::PermissionDenied,
1350                        format!("PID {pid} belongs to a process this account may not query"),
1351                    ));
1352                }
1353                return Err(io_error(&error));
1354            }
1355        };
1356
1357        let mut creation = FILETIME::default();
1358        let mut exit = FILETIME::default();
1359        let mut kernel = FILETIME::default();
1360        let mut user = FILETIME::default();
1361        let times =
1362            unsafe { GetProcessTimes(handle, &mut creation, &mut exit, &mut kernel, &mut user) };
1363        unsafe {
1364            let _ = CloseHandle(handle);
1365        }
1366        times.map_err(|error| io_error(&error))?;
1367
1368        // A non-zero exit time means the process object outlives the process
1369        // itself because somebody still holds a handle to it — which this
1370        // process does, for every child it spawned. Without this check a killed
1371        // child would keep reporting `Live` until it was reaped.
1372        if filetime_to_u64(exit) != 0 && filter == super::LivenessFilter::LiveOnly {
1373            return Ok(None);
1374        }
1375
1376        Ok(Some(format!("windows:{}", filetime_to_u64(creation))))
1377    }
1378
1379    pub(super) fn request_stop(_pid: u32) -> io::Result<bool> {
1380        // Windows has no signal a non-console process can be asked to handle
1381        // from outside. `GenerateConsoleCtrlEvent` needs a shared console, and
1382        // a service-hosted agent has none. Reporting `false` tells the caller
1383        // to go straight to `force_stop` rather than sit through a grace period
1384        // during which nothing was ever asked.
1385        Ok(false)
1386    }
1387
1388    pub(super) fn force_stop(pid: u32) -> io::Result<()> {
1389        let _ = std::process::Command::new("taskkill")
1390            .args(["/F", "/T", "/PID", &pid.to_string()])
1391            .stdout(std::process::Stdio::null())
1392            .stderr(std::process::Stdio::null())
1393            .status();
1394
1395        match unsafe { OpenProcess(PROCESS_TERMINATE, false, pid) } {
1396            Ok(handle) => {
1397                let result = unsafe { TerminateProcess(handle, 1) };
1398                unsafe {
1399                    let _ = CloseHandle(handle);
1400                }
1401                if let Err(error) = result {
1402                    let code = error.code().0 as u32;
1403                    if code != 0x80070005 && code != 0x80070057 {
1404                        return Err(io_error(&error));
1405                    }
1406                }
1407                Ok(())
1408            }
1409            Err(error) => {
1410                let code = error.code().0 as u32;
1411                if code == 0x80070005 || code == 0x80070057 {
1412                    Ok(())
1413                } else {
1414                    Err(io_error(&error))
1415                }
1416            }
1417        }
1418    }
1419
1420    /// The current account's SID in string form, for the DACL below.
1421    ///
1422    /// `pub(crate)` because the secret store needs the same answer for the same
1423    /// reason: a DACL that names the account explicitly, rather than one that
1424    /// describes it and hopes the description still fits later.
1425    pub(crate) fn current_user_sid() -> io::Result<String> {
1426        let mut token = HANDLE(std::ptr::null_mut());
1427        unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }
1428            .map_err(|error| io_error(&error))?;
1429
1430        let mut needed = 0u32;
1431        // The first call is expected to fail with ERROR_INSUFFICIENT_BUFFER; it
1432        // is how the required size is learned.
1433        let _ = unsafe { GetTokenInformation(token, TokenUser, None, 0, &mut needed) };
1434
1435        // `TOKEN_USER` contains a pointer, so the buffer must be
1436        // pointer-aligned; a `Vec<u8>` is aligned to 1 and casting it would be
1437        // undefined behaviour.
1438        let words = (needed as usize).div_ceil(size_of::<usize>()).max(1);
1439        let mut buffer = vec![0usize; words];
1440
1441        let information = unsafe {
1442            GetTokenInformation(
1443                token,
1444                TokenUser,
1445                Some(buffer.as_mut_ptr().cast()),
1446                needed,
1447                &mut needed,
1448            )
1449        };
1450        if let Err(error) = information {
1451            unsafe {
1452                let _ = CloseHandle(token);
1453            }
1454            return Err(io_error(&error));
1455        }
1456
1457        let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
1458        let mut sid_string = PWSTR::null();
1459        let converted = unsafe { ConvertSidToStringSidW(token_user.User.Sid, &mut sid_string) };
1460        unsafe {
1461            let _ = CloseHandle(token);
1462        }
1463        converted.map_err(|error| io_error(&error))?;
1464
1465        let text = unsafe { sid_string.to_string() }
1466            .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error));
1467        unsafe {
1468            let _ = LocalFree(Some(HLOCAL(sid_string.0.cast())));
1469        }
1470        text
1471    }
1472
1473    pub(super) fn create_restrictive_file(path: &Path) -> io::Result<File> {
1474        // A protected DACL — the `P` — so that nothing is inherited from the
1475        // parent directory, granting full access to exactly two trustees: this
1476        // account, and the local Administrators group.
1477        //
1478        // `BA` is not a weakening: `07-security.md` places a local
1479        // administrator outside this threat model, because such an account can
1480        // already read the runner's workspace and its credentials. It is kept
1481        // so that an operator can clean up a handoff file left behind by an
1482        // agent running under a service account they are not logged in as.
1483        //
1484        // There is deliberately no `(A;;FA;;;SY)` ACE. An earlier version
1485        // carried one "so that a service running as LocalSystem still works",
1486        // and that justification was simply wrong: if this agent *is*
1487        // LocalSystem then `current_user_sid()` returns S-1-5-18 and the third
1488        // ACE already covers it. So the ACE added nothing in the one case it
1489        // was written for, and in every other case it widened access to a file
1490        // whose entire purpose is to be narrow.
1491        let sddl = format!("D:P(A;;FA;;;BA)(A;;FA;;;{})", current_user_sid()?);
1492        let sddl_wide: Vec<u16> = sddl.encode_utf16().chain(std::iter::once(0)).collect();
1493
1494        let mut descriptor = PSECURITY_DESCRIPTOR(std::ptr::null_mut());
1495        unsafe {
1496            ConvertStringSecurityDescriptorToSecurityDescriptorW(
1497                PCWSTR(sddl_wide.as_ptr()),
1498                SDDL_REVISION_1,
1499                &mut descriptor,
1500                None,
1501            )
1502        }
1503        .map_err(|error| io_error(&error))?;
1504
1505        let attributes = SECURITY_ATTRIBUTES {
1506            nLength: u32::try_from(size_of::<SECURITY_ATTRIBUTES>()).unwrap_or(u32::MAX),
1507            lpSecurityDescriptor: descriptor.0,
1508            // False: a handle to the JIT configuration must not be inherited by
1509            // the runner or by anything else this agent spawns.
1510            bInheritHandle: windows::core::BOOL(0),
1511        };
1512
1513        let wide = to_wide(path);
1514        let handle = unsafe {
1515            CreateFileW(
1516                PCWSTR(wide.as_ptr()),
1517                FILE_GENERIC_READ.0 | FILE_GENERIC_WRITE.0,
1518                FILE_SHARE_NONE,
1519                Some(&raw const attributes),
1520                CREATE_NEW,
1521                FILE_ATTRIBUTE_NORMAL,
1522                None,
1523            )
1524        };
1525
1526        unsafe {
1527            let _ = LocalFree(Some(HLOCAL(descriptor.0)));
1528        }
1529
1530        let handle = handle.map_err(|error| io_error(&error))?;
1531        Ok(unsafe { File::from_raw_handle(handle.0) })
1532    }
1533
1534    pub(super) fn describe_permissions(path: &Path) -> io::Result<(String, bool)> {
1535        let wide = to_wide(path);
1536        let mut descriptor = PSECURITY_DESCRIPTOR(std::ptr::null_mut());
1537        let status = unsafe {
1538            GetNamedSecurityInfoW(
1539                PCWSTR(wide.as_ptr()),
1540                SE_FILE_OBJECT,
1541                DACL_SECURITY_INFORMATION,
1542                None,
1543                None,
1544                None,
1545                None,
1546                &mut descriptor,
1547            )
1548        };
1549        if status != ERROR_SUCCESS {
1550            return Err(io::Error::from_raw_os_error(
1551                i32::try_from(status.0).unwrap_or(i32::MAX),
1552            ));
1553        }
1554
1555        let mut sddl = PWSTR::null();
1556        let converted = unsafe {
1557            ConvertSecurityDescriptorToStringSecurityDescriptorW(
1558                descriptor,
1559                SDDL_REVISION_1,
1560                DACL_SECURITY_INFORMATION,
1561                &mut sddl,
1562                None,
1563            )
1564        };
1565        let text = match converted {
1566            Ok(()) => {
1567                let text = unsafe { sddl.to_string() }
1568                    .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error));
1569                unsafe {
1570                    let _ = LocalFree(Some(HLOCAL(sddl.0.cast())));
1571                }
1572                text
1573            }
1574            Err(error) => Err(io_error(&error)),
1575        };
1576        unsafe {
1577            let _ = LocalFree(Some(HLOCAL(descriptor.0)));
1578        }
1579        let text = text?;
1580
1581        let readable = dacl_grants_broad_access(&text);
1582        Ok((text, readable))
1583    }
1584
1585    /// Whether a DACL in SDDL form lets an ordinary local user other than the
1586    /// owner read the object.
1587    ///
1588    /// Two conditions, and both matter. An unprotected DACL inherits whatever
1589    /// the parent directory grants, which is not this program's to vouch for.
1590    /// And an allow ACE naming one of the broad well-known trustees grants
1591    /// access to every interactive account on the machine.
1592    fn dacl_grants_broad_access(sddl: &str) -> bool {
1593        /// The trustees that mean "more or less anybody logged in here", in
1594        /// both the two-letter SDDL alias form and the raw SID form the
1595        /// converter may emit instead.
1596        const BROAD: &[&str] = &[
1597            "WD",           // Everyone
1598            "S-1-1-0",      // Everyone
1599            "AU",           // Authenticated Users
1600            "S-1-5-11",     // Authenticated Users
1601            "BU",           // Builtin Users
1602            "S-1-5-32-545", // Builtin Users
1603            "IU",           // Interactive
1604            "S-1-5-4",      // Interactive
1605            "AN",           // Anonymous
1606            "S-1-5-7",      // Anonymous
1607            "WR",           // Write Restricted
1608            "LU",           // Performance Log Users
1609        ];
1610
1611        let Some(dacl) = sddl.split("D:").nth(1) else {
1612            // No DACL at all means "everyone, full control" in Windows'
1613            // security model. Never the answer this function should be
1614            // optimistic about.
1615            return true;
1616        };
1617
1618        let flags: String = dacl.chars().take_while(|c| *c != '(').collect();
1619        if !flags.contains('P') {
1620            return true;
1621        }
1622
1623        for ace in dacl.split('(').skip(1) {
1624            let ace = ace.split(')').next().unwrap_or_default();
1625            let fields: Vec<&str> = ace.split(';').collect();
1626            // (type;flags;rights;object_guid;inherit_object_guid;trustee)
1627            let (Some(kind), Some(trustee)) = (fields.first(), fields.get(5)) else {
1628                continue;
1629            };
1630            // Only allow ACEs grant anything; a deny ACE naming Everyone is a
1631            // tightening, not a leak.
1632            if !kind.starts_with('A') {
1633                continue;
1634            }
1635            if BROAD
1636                .iter()
1637                .any(|broad| trustee.eq_ignore_ascii_case(broad))
1638            {
1639                return true;
1640            }
1641        }
1642
1643        false
1644    }
1645
1646    #[cfg(test)]
1647    mod tests {
1648        use super::dacl_grants_broad_access;
1649
1650        #[test]
1651        fn a_protected_owner_only_dacl_is_not_broadly_readable() {
1652            // The shape `create_restrictive_file` actually writes: protected,
1653            // Administrators, and this account. No LocalSystem ACE — see the
1654            // comment there for why one would add nothing.
1655            assert!(!dacl_grants_broad_access(
1656                "D:P(A;;FA;;;BA)(A;;FA;;;S-1-5-21-1-2-3-1001)"
1657            ));
1658            // A LocalSystem agent's own SID is S-1-5-18, which is what makes
1659            // the separate `SY` ACE redundant rather than load-bearing.
1660            assert!(!dacl_grants_broad_access(
1661                "D:P(A;;FA;;;BA)(A;;FA;;;S-1-5-18)"
1662            ));
1663            // Still tolerated when it arrives from somewhere else: this
1664            // heuristic inspects files, and not every file it sees was written
1665            // by this module.
1666            assert!(!dacl_grants_broad_access(
1667                "D:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;S-1-5-21-1-2-3-1001)"
1668            ));
1669        }
1670
1671        #[test]
1672        fn an_everyone_ace_is_broadly_readable_in_either_notation() {
1673            assert!(dacl_grants_broad_access("D:P(A;;FA;;;SY)(A;;FR;;;WD)"));
1674            assert!(dacl_grants_broad_access("D:P(A;;FA;;;SY)(A;;FR;;;S-1-1-0)"));
1675            assert!(dacl_grants_broad_access("D:P(A;;FR;;;BU)"));
1676        }
1677
1678        #[test]
1679        fn an_unprotected_dacl_is_broadly_readable_because_it_inherits() {
1680            assert!(dacl_grants_broad_access("D:AI(A;ID;FA;;;SY)"));
1681        }
1682
1683        #[test]
1684        fn a_deny_ace_for_everyone_is_not_a_grant() {
1685            assert!(!dacl_grants_broad_access("D:P(D;;FA;;;WD)(A;;FA;;;SY)"));
1686        }
1687
1688        #[test]
1689        fn a_missing_dacl_is_treated_as_wide_open() {
1690            assert!(dacl_grants_broad_access("O:BAG:BA"));
1691        }
1692    }
1693}
1694
1695#[cfg(unix)]
1696mod sys {
1697    use std::fs::File;
1698    use std::io;
1699    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
1700    use std::path::Path;
1701
1702    pub(super) fn request_stop(pid: u32) -> io::Result<bool> {
1703        // SAFETY: `kill` takes a PID and a signal number and touches no memory
1704        // this program owns. The PID is checked for liveness by the caller
1705        // immediately beforehand, and every caller either holds the child
1706        // handle or has just re-verified the process identity.
1707        // We use `-pid` to send the signal to the entire process group.
1708        let result = unsafe { libc::kill(-(pid as libc::pid_t), libc::SIGTERM) };
1709        if result == 0 {
1710            return Ok(true);
1711        }
1712        let error = io::Error::last_os_error();
1713        // The process exited between the liveness check and the signal. That is
1714        // a normal race, not a failure to stop it.
1715        if error.raw_os_error() == Some(libc::ESRCH) {
1716            return Ok(true);
1717        }
1718        Err(error)
1719    }
1720
1721    pub(super) fn force_stop(pid: u32) -> io::Result<()> {
1722        // SAFETY: as `request_stop`.
1723        let result = unsafe { libc::kill(-(pid as libc::pid_t), libc::SIGKILL) };
1724        if result == 0 {
1725            return Ok(());
1726        }
1727        let error = io::Error::last_os_error();
1728        if error.raw_os_error() == Some(libc::ESRCH) {
1729            return Ok(());
1730        }
1731        Err(error)
1732    }
1733
1734    pub(super) fn create_restrictive_file(path: &Path) -> io::Result<File> {
1735        // `mode` is applied by `open(2)` itself, so the file never exists with
1736        // any other permissions. `create_new` makes it exclusive, so a
1737        // pre-created path belonging to somebody else is an error rather than a
1738        // file this process writes a JIT configuration into.
1739        std::fs::OpenOptions::new()
1740            .read(true)
1741            .write(true)
1742            .create_new(true)
1743            .mode(0o600)
1744            .open(path)
1745    }
1746
1747    pub(super) fn describe_permissions(path: &Path) -> io::Result<(String, bool)> {
1748        let mode = std::fs::metadata(path)?.permissions().mode() & 0o777;
1749        // Any group or other bit at all, not just the read bit: an executable
1750        // or writable bit for another account is not something this file should
1751        // ever carry either.
1752        Ok((format!("mode {mode:04o}"), mode & 0o077 != 0))
1753    }
1754
1755    #[cfg(target_os = "linux")]
1756    pub(super) fn start_token(
1757        pid: u32,
1758        filter: super::LivenessFilter,
1759    ) -> io::Result<Option<String>> {
1760        let stat = match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
1761            Ok(stat) => stat,
1762            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
1763            Err(error) => return Err(error),
1764        };
1765
1766        let parsed = super::parse_proc_stat(&stat).ok_or_else(|| {
1767            io::Error::new(
1768                io::ErrorKind::InvalidData,
1769                format!("/proc/{pid}/stat is not in the documented format"),
1770            )
1771        })?;
1772
1773        // `Z` is a process that has exited and is waiting to be reaped. Its PID
1774        // is still taken, but it is not a process that can be adopted or asked
1775        // to do anything.
1776        if parsed.state == "Z" && filter == super::LivenessFilter::LiveOnly {
1777            return Ok(None);
1778        }
1779
1780        Ok(Some(format!("linux:{}:{}", boot_id()?, parsed.start_ticks)))
1781    }
1782
1783    /// An identifier that changes on every boot.
1784    ///
1785    /// Needed because `starttime` counts from boot, so without it a process
1786    /// started 500 ticks after this boot and one started 500 ticks after the
1787    /// previous boot are indistinguishable — which is exactly the confusion a
1788    /// journal read back after a reboot invites.
1789    #[cfg(target_os = "linux")]
1790    fn boot_id() -> io::Result<String> {
1791        // Every kernel since 2.6.19 offers this, and it is world-readable.
1792        if let Ok(id) = std::fs::read_to_string("/proc/sys/kernel/random/boot_id") {
1793            let id = id.trim();
1794            if !id.is_empty() {
1795                return Ok(id.to_string());
1796            }
1797        }
1798
1799        // A container or a hardened kernel may hide it. `btime` in
1800        // `/proc/stat` is the boot wall-clock time in whole seconds and serves
1801        // the same purpose here: it distinguishes boots. It is only ever the
1802        // fallback because two boots within the same second would collide, and
1803        // `boot_id` cannot.
1804        btime()
1805            .map(|value| format!("btime-{value}"))
1806            .map_err(|error| {
1807                if error.kind() == io::ErrorKind::NotFound {
1808                    io::Error::new(
1809                        io::ErrorKind::NotFound,
1810                        "neither /proc/sys/kernel/random/boot_id nor /proc/stat btime is \
1811                         readable, so a process identity that survives a reboot cannot be formed",
1812                    )
1813                } else {
1814                    error
1815                }
1816            })
1817    }
1818
1819    /// `btime` from `/proc/stat`: the boot wall-clock time, in whole seconds.
1820    #[cfg(target_os = "linux")]
1821    pub(super) fn btime() -> io::Result<String> {
1822        let stat = std::fs::read_to_string("/proc/stat")?;
1823        stat.lines()
1824            .find_map(|line| line.strip_prefix("btime "))
1825            .map(|value| value.trim().to_string())
1826            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "/proc/stat has no btime"))
1827    }
1828
1829    #[cfg(target_os = "macos")]
1830    pub(super) fn start_token(
1831        pid: u32,
1832        filter: super::LivenessFilter,
1833    ) -> io::Result<Option<String>> {
1834        // `proc_pidinfo(PROC_PIDTBSDINFO)` rather than `sysctl(KERN_PROC)`:
1835        // libc 0.2 defines `proc_bsdinfo` for Apple targets but not
1836        // `kinfo_proc`, so the sysctl route would mean declaring the struct
1837        // layout by hand — a layout this crate would then own and have to keep
1838        // correct across macOS releases. `proc_pidinfo` gives the same
1839        // microsecond start time through a declared type.
1840        //
1841        // SAFETY: `info` is a correctly sized, zero-initialised `proc_bsdinfo`
1842        // and the size passed matches it, so the kernel writes only within it.
1843        // `proc_pidinfo` reads no memory this program owns other than that
1844        // buffer.
1845        let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
1846        let size = i32::try_from(size_of::<libc::proc_bsdinfo>()).unwrap_or(i32::MAX);
1847        let written = unsafe {
1848            libc::proc_pidinfo(
1849                pid as libc::c_int,
1850                libc::PROC_PIDTBSDINFO,
1851                0,
1852                std::ptr::from_mut(&mut info).cast(),
1853                size,
1854            )
1855        };
1856
1857        if written <= 0 {
1858            let error = io::Error::last_os_error();
1859            // See `super::probe_failure_means_gone`, which is where this rule
1860            // lives so that it can be tested on a leg that has no `libc`.
1861            // Only ESRCH is "gone"; EPERM and a zero `errno` are errors.
1862            return if super::probe_failure_means_gone(error.raw_os_error(), libc::ESRCH) {
1863                Ok(None)
1864            } else {
1865                Err(error)
1866            };
1867        }
1868        if written != size {
1869            return Err(io::Error::new(
1870                io::ErrorKind::InvalidData,
1871                format!("proc_pidinfo returned {written} bytes for PID {pid}, expected {size}"),
1872            ));
1873        }
1874
1875        // A zombie still holds its PID but is not a process anything can adopt.
1876        if info.pbi_status == libc::SZOMB && filter == super::LivenessFilter::LiveOnly {
1877            return Ok(None);
1878        }
1879
1880        Ok(Some(format!(
1881            "macos:{}.{:06}",
1882            info.pbi_start_tvsec, info.pbi_start_tvusec
1883        )))
1884    }
1885
1886    /// The highest base priority XNU gives a "darwinbg" task
1887    /// (`MAXPRI_THROTTLE`). A normal user task runs at 31.
1888    #[cfg(target_os = "macos")]
1889    pub(super) const BACKGROUND_PRIORITY_CEILING: i32 = 4;
1890
1891    /// The highest base scheduling priority among the process's threads: the
1892    /// number `ps -o pri` shows.
1893    ///
1894    /// The threads, because that is where the throttle is. Under a launchd
1895    /// `Background` job a child's task priority (`proc_taskinfo::pti_priority`)
1896    /// reads 31 while every one of its threads has base priority 4, and `ps`
1897    /// shows 4 (measured on macOS 27); only the job itself reads 4 there. A
1898    /// check of the task priority therefore passes for exactly the runners it
1899    /// exists to catch. `getpriority(PRIO_DARWIN_PROCESS, pid)` is no better:
1900    /// for a `Background` agent at priority 4 it answers 0, "not background".
1901    #[cfg(target_os = "macos")]
1902    pub(super) fn base_priority(pid: u32) -> io::Result<i32> {
1903        /// `PROC_PIDLISTTHREADS` from `<sys/proc_info.h>`, which `libc` lacks.
1904        const PROC_PIDLISTTHREADS: libc::c_int = 6;
1905        /// Far more than a freshly started runner has; a process with more
1906        /// threads is still judged by the first this many.
1907        const MAX_THREADS: usize = 256;
1908
1909        let mut threads = [0u64; MAX_THREADS];
1910        let capacity = i32::try_from(size_of_val(&threads)).unwrap_or(i32::MAX);
1911        // SAFETY: the buffer and its exact size in bytes, so the kernel writes
1912        // only within it.
1913        let written = unsafe {
1914            libc::proc_pidinfo(
1915                pid as libc::c_int,
1916                PROC_PIDLISTTHREADS,
1917                0,
1918                threads.as_mut_ptr().cast(),
1919                capacity,
1920            )
1921        };
1922        if written <= 0 {
1923            return Err(io::Error::last_os_error());
1924        }
1925        let count = usize::try_from(written).unwrap_or(0) / size_of::<u64>();
1926        let size = i32::try_from(size_of::<libc::proc_threadinfo>()).unwrap_or(i32::MAX);
1927        let mut highest = None;
1928        for &thread in &threads[..count.min(MAX_THREADS)] {
1929            // SAFETY: as above, a zeroed, correctly sized `proc_threadinfo`.
1930            let mut info: libc::proc_threadinfo = unsafe { std::mem::zeroed() };
1931            let written = unsafe {
1932                libc::proc_pidinfo(
1933                    pid as libc::c_int,
1934                    libc::PROC_PIDTHREADINFO,
1935                    thread,
1936                    std::ptr::from_mut(&mut info).cast(),
1937                    size,
1938                )
1939            };
1940            // A thread can exit between the listing and this read.
1941            if written == size {
1942                highest = highest.max(Some(info.pth_priority));
1943            }
1944        }
1945        highest.ok_or_else(io::Error::last_os_error)
1946    }
1947
1948    /// Whether `pid` runs at or under the background priority ceiling. A
1949    /// process that can no longer be read is not reported as throttled.
1950    #[cfg(target_os = "macos")]
1951    pub(super) fn runs_throttled(pid: u32) -> bool {
1952        base_priority(pid).is_ok_and(|priority| priority <= BACKGROUND_PRIORITY_CEILING)
1953    }
1954}
1955
1956// ---------------------------------------------------------------------------
1957// Tests
1958// ---------------------------------------------------------------------------
1959
1960#[cfg(test)]
1961pub(crate) mod tests {
1962    use super::*;
1963
1964    /// Every Rust program, this test binary and the daemon alike, starts with
1965    /// SIGPIPE ignored, and an ignored disposition survives `exec`. A runner
1966    /// started with it would hand it to every job step, where `yes | head`
1967    /// exits 1 instead of 141 and `tr … </dev/urandom | head` never ends. The
1968    /// child must start with the default disposition, so a shell that sends
1969    /// itself SIGPIPE dies of it.
1970    ///
1971    /// Job steps on a runner this product starts do still see SIGPIPE ignored,
1972    /// on every host and under a classic runner too: .NET ignores it in
1973    /// `Runner.Listener` and `Runner.Worker`, and its process start keeps an
1974    /// ignored disposition for the shells it runs (actions/runner#2684). That is
1975    /// below this spawn, and this test pins only what this spawn hands over.
1976    #[cfg(unix)]
1977    #[test]
1978    fn a_spawned_child_starts_with_the_default_sigpipe_disposition() {
1979        use std::os::unix::process::ExitStatusExt as _;
1980        let mut child = SpawnSpec::new("/bin/sh")
1981            .args(["-c", "kill -s PIPE $$; exit 3"])
1982            .output(OutputMode::Discard)
1983            .spawn()
1984            .expect("/bin/sh starts");
1985        let status = child.wait().expect("the shell is reaped");
1986        assert_eq!(
1987            status.signal(),
1988            Some(libc::SIGPIPE),
1989            "the child inherited an ignored SIGPIPE: {status:?}"
1990        );
1991    }
1992
1993    /// A child can be gone before its parent reads its identity, and launching
1994    /// it must still succeed. macOS is where this bites: `proc_pidinfo` answers
1995    /// `ESRCH` for a child that has only just exited, so `IncludeExited` -- which
1996    /// covers a zombie -- never gets a say, and the spawn failed outright.
1997    #[test]
1998    fn a_child_that_is_already_gone_is_still_given_an_identity() {
1999        let identity = ProcessIdentity::of_child(u32::MAX).expect(
2000            "a parent holding the handle must always end up with an identity, even for a              child that has already exited",
2001        );
2002
2003        // The sentinel is a claim about nothing, deliberately: there is no
2004        // start instant to read, and inventing one would be a lie that a later
2005        // comparison could believe.
2006        assert_eq!(
2007            identity.classify(None),
2008            Adoption::Gone,
2009            "a PID nobody holds is gone"
2010        );
2011        assert!(
2012            matches!(
2013                identity.classify(Some("macos:1.000000".to_string())),
2014                Adoption::PidRecycled { .. }
2015            ),
2016            "and a PID somebody else has taken is recycled -- which is refused, never signalled"
2017        );
2018    }
2019
2020    /// A program that exits immediately, on every supported platform.
2021    pub(crate) fn quick_exit() -> SpawnSpec {
2022        if cfg!(windows) {
2023            SpawnSpec::new("cmd").args(["/C", "exit", "0"])
2024        } else {
2025            SpawnSpec::new("true")
2026        }
2027    }
2028
2029    /// A program that stays alive until it is stopped.
2030    ///
2031    /// Deliberately a single process on both families rather than a shell
2032    /// wrapping one. Terminating a shell does not terminate the child it
2033    /// spawned, so a `cmd /C …` or `sh -c …` wrapper would leave a stray
2034    /// process behind after every test that stops it — and would make
2035    /// `Adoption::Gone` assertions read as passes for the wrong reason.
2036    ///
2037    /// `ping -n 600 127.0.0.1` sends one loopback echo a second for ten
2038    /// minutes: it needs no console, writes to the null device this spec
2039    /// already sets, and burns no CPU while waiting.
2040    pub(crate) fn long_running() -> SpawnSpec {
2041        if cfg!(windows) {
2042            SpawnSpec::new("ping").args(["-n", "600", "127.0.0.1"])
2043        } else {
2044            SpawnSpec::new("sleep").args(["600"])
2045        }
2046    }
2047
2048    /// The coarsest start-token resolution any supported platform has.
2049    ///
2050    /// Linux's token carries `/proc/<pid>/stat` field 22, which counts
2051    /// `USER_HZ` ticks. `USER_HZ` is 100 on every supported distribution, so
2052    /// one tick is **10 ms** and two processes started inside the same tick get
2053    /// byte-identical start tokens. Windows resolves creation time to 100 ns
2054    /// and macOS to 1 µs, so neither can collide this way.
2055    const COARSEST_START_TOKEN_TICK: Duration = Duration::from_millis(10);
2056
2057    /// Puts a start-token boundary between two spawns.
2058    ///
2059    /// Every "recycled PID" fixture below works by pairing one process's PID
2060    /// with another process's start token. That only *is* a recycled record if
2061    /// the two tokens differ. Spawning back to back is well inside one Linux
2062    /// tick — a `posix_spawn` and two small `/proc` reads — so without this the
2063    /// synthesised record would be the survivor's genuine identity, `recheck`
2064    /// would correctly answer `Live`, and the fixture would fail while the
2065    /// primitive it is testing was working exactly as designed.
2066    ///
2067    /// This is a defect in the fixture and not in the discriminator: recycling
2068    /// a PID for real takes far longer than 10 ms, and `boot_id` changes across
2069    /// a reboot, so no production record can collide this way.
2070    fn separate_start_tokens() {
2071        // Comfortably more than one tick, so a slow or virtualised Linux CI
2072        // runner does not land on the boundary itself.
2073        std::thread::sleep(COARSEST_START_TOKEN_TICK * 5 / 2);
2074    }
2075
2076    /// Fails with a message naming its own cause if two spawns still collided.
2077    ///
2078    /// Without this the collision surfaces as `assert!(!…is_live())` or as a
2079    /// dead survivor, both of which read as "the start token has stopped
2080    /// discriminating" — the opposite of what actually happened.
2081    fn assert_distinguishable(first: &ProcessIdentity, second: &ProcessIdentity) {
2082        assert_ne!(
2083            first.start_token(),
2084            second.start_token(),
2085            "the two children share a start token, so the record synthesised below would be \
2086             the second child's real identity rather than a recycled one. This is the fixture \
2087             colliding inside one start-token tick, not the discriminator failing; lengthen \
2088             `separate_start_tokens` for this platform."
2089        );
2090    }
2091
2092    // -----------------------------------------------------------------------
2093    // The Linux `/proc/<pid>/stat` parser
2094    //
2095    // Deliberately not `#[cfg(target_os = "linux")]`. This is the only Linux
2096    // path in the crate whose correctness is decidable without a Linux kernel,
2097    // and running it on all three legs means a Windows or macOS developer
2098    // breaking it finds out immediately rather than in the Linux leg of CI.
2099    // -----------------------------------------------------------------------
2100
2101    /// A real `/proc/<pid>/stat` line, from a 6.x kernel, truncated after the
2102    /// fields this crate reads. Fields 1 and 2 are the PID and `comm`; field 3
2103    /// is the state; field 22 is `starttime`.
2104    const REAL_STAT: &str = "1234 (bash) S 1200 1234 1234 34816 1234 4194304 3300 5100 0 0 6 5 8 4 20 0 1 0 987654 12345678 900 18446744073709551615";
2105
2106    #[test]
2107    fn the_proc_stat_parser_reads_the_state_and_the_start_time() {
2108        let parsed = parse_proc_stat(REAL_STAT).expect("a well-formed line parses");
2109        assert_eq!(parsed.state, "S");
2110        assert_eq!(
2111            parsed.start_ticks, "987654",
2112            "field 22 is `starttime`; an off-by-one here silently produces a process identity \
2113             that is stable but wrong, which is worse than one that fails"
2114        );
2115    }
2116
2117    #[test]
2118    fn the_proc_stat_parser_survives_a_command_name_containing_spaces_and_parentheses() {
2119        // `procfs(5)` permits this, and a whitespace split of the whole line
2120        // mis-indexes every field after it. The failure would show up only for
2121        // a process whose name was chosen to cause it — that is, only when
2122        // somebody meant to.
2123        let hostile = REAL_STAT.replace("(bash)", "(my prog (v2) :) )");
2124        let parsed = parse_proc_stat(&hostile).expect("a hostile comm still parses");
2125        assert_eq!(parsed.state, "S");
2126        assert_eq!(parsed.start_ticks, "987654");
2127    }
2128
2129    #[test]
2130    fn a_naive_whitespace_split_gets_the_hostile_case_wrong() {
2131        // Shows the previous test is not decorative: the obvious
2132        // implementation, run on the same input, returns a different field.
2133        let hostile = REAL_STAT.replace("(bash)", "(my prog (v2) :) )");
2134        let naive: Vec<&str> = hostile.split_whitespace().collect();
2135        // Field 22 counting from the start of the line, as the naive reading
2136        // would.
2137        let naive_start = naive.get(21).copied();
2138
2139        assert_ne!(
2140            naive_start,
2141            Some(parse_proc_stat(&hostile).expect("parses").start_ticks),
2142            "if these agree, the hostile fixture no longer exercises the hazard and the test \
2143             above proves nothing"
2144        );
2145    }
2146
2147    #[test]
2148    fn the_proc_stat_parser_reports_a_zombie() {
2149        let zombie = REAL_STAT.replacen(") S ", ") Z ", 1);
2150        let parsed = parse_proc_stat(&zombie).expect("parses");
2151        assert_eq!(
2152            parsed.state, "Z",
2153            "a zombie holds its PID but is not adoptable; the caller depends on seeing this"
2154        );
2155    }
2156
2157    #[test]
2158    fn the_proc_stat_parser_rejects_a_truncated_line() {
2159        assert_eq!(parse_proc_stat(""), None);
2160        assert_eq!(parse_proc_stat("1234 (bash)"), None);
2161        assert_eq!(parse_proc_stat("1234 (bash) S 1200"), None);
2162        assert_eq!(
2163            parse_proc_stat("no parenthesis here at all"),
2164            None,
2165            "a line with no comm field must be rejected, not indexed into"
2166        );
2167    }
2168
2169    #[test]
2170    fn the_current_process_has_a_stable_identity() {
2171        let first = ProcessIdentity::of_current_process().expect("this process can see itself");
2172        let second = ProcessIdentity::of_current_process().expect("twice");
2173
2174        assert_eq!(
2175            first, second,
2176            "an identity that changes between two reads of the same live process would make \
2177             every journal record unmatchable"
2178        );
2179        assert_eq!(first.pid(), std::process::id());
2180        assert!(
2181            !first.start_token().is_empty(),
2182            "an empty start token would make the identity a bare PID again"
2183        );
2184        assert_eq!(first.recheck().expect("resolvable"), Adoption::Live);
2185    }
2186
2187    #[test]
2188    fn the_start_token_varies_between_processes() {
2189        // Guards against the degenerate implementation that satisfies every
2190        // other test in this module: a token that is the same constant for
2191        // everything. Such a token would make a recycled PID indistinguishable
2192        // from the original process.
2193        //
2194        // Under cargo-nextest, this test process and its child start so close
2195        // together that they can share the same clock tick on Linux (10ms resolution).
2196        // Sleep for a tick to ensure they have distinct start times.
2197        std::thread::sleep(Duration::from_millis(20));
2198
2199        let mut child = long_running().spawn().expect("the child starts");
2200        let mine = ProcessIdentity::of_current_process().expect("this process can see itself");
2201
2202        assert_ne!(
2203            child.identity().start_token(),
2204            mine.start_token(),
2205            "two different processes must not share a start token"
2206        );
2207
2208        child.stop(Duration::from_secs(5)).expect("the child stops");
2209    }
2210
2211    #[test]
2212    fn a_spawned_child_is_observable_and_terminable() {
2213        let mut child = long_running().spawn().expect("the child starts");
2214
2215        assert!(child.is_running().expect("observable"), "just spawned");
2216        assert_eq!(child.identity().pid(), child.pid());
2217        assert_eq!(
2218            child.identity().recheck().expect("resolvable"),
2219            Adoption::Live,
2220            "a running child must re-resolve to itself"
2221        );
2222        assert_eq!(
2223            child.wait_for(Duration::from_millis(50)).expect("waitable"),
2224            None,
2225            "a long-running child must not be reported as exited"
2226        );
2227
2228        child
2229            .stop(Duration::from_secs(10))
2230            .expect("the child stops");
2231
2232        assert!(!child.is_running().expect("observable"), "after stop");
2233        assert_eq!(
2234            child.identity().recheck().expect("resolvable"),
2235            Adoption::Gone,
2236            "a stopped child's identity must not still resolve as live"
2237        );
2238    }
2239
2240    #[test]
2241    fn a_child_that_exits_on_its_own_is_reported_as_exited() {
2242        let mut child = quick_exit().spawn().expect("the child starts");
2243
2244        let status = child
2245            .wait_for(Duration::from_secs(30))
2246            .expect("waitable")
2247            .expect("a program that exits immediately must be seen to exit");
2248        assert!(status.success(), "{status:?}");
2249        assert!(!child.is_running().expect("observable"));
2250    }
2251
2252    /// The Definition of Done clause this whole module exists for: *"its
2253    /// recorded identity is re-resolvable after a simulated restart and does
2254    /// **not** match a recycled PID belonging to a different process"*.
2255    ///
2256    /// The restart is simulated by round-tripping the identity through JSON —
2257    /// the journal's representation — and dropping every in-memory handle, so
2258    /// the re-resolution has nothing but the serialised record to work from.
2259    /// The recycling is simulated by pairing a *live* process's PID with a
2260    /// *different* process's start token, which is precisely the state a real
2261    /// PID reuse produces.
2262    #[test]
2263    fn a_recorded_identity_survives_a_restart_and_rejects_a_recycled_pid() {
2264        let mut victim = long_running().spawn().expect("the first child starts");
2265        let recorded = victim.identity().clone();
2266
2267        // The journal round trip.
2268        let journalled = serde_json::to_string(&recorded).expect("serialisable");
2269        let recovered: ProcessIdentity = serde_json::from_str(&journalled).expect("readable back");
2270        assert_eq!(recovered, recorded);
2271        assert_eq!(
2272            recovered.recheck().expect("resolvable"),
2273            Adoption::Live,
2274            "a journalled identity whose process is still running must be adoptable; e3's \
2275             restart recovery depends on exactly this"
2276        );
2277
2278        // A second, unrelated process. Its PID is the one the record will be
2279        // made to point at.
2280        separate_start_tokens();
2281        let mut survivor = long_running().spawn().expect("the second child starts");
2282        let survivor_identity = survivor.identity().clone();
2283        assert_ne!(survivor_identity.pid(), recorded.pid());
2284        assert_distinguishable(&recorded, &survivor_identity);
2285
2286        victim
2287            .stop(Duration::from_secs(10))
2288            .expect("the first child stops");
2289
2290        // The record as it would look after PID reuse: the journalled start
2291        // token, now pointing at a PID a different process holds.
2292        let recycled = ProcessIdentity {
2293            pid: survivor_identity.pid(),
2294            start_token: recorded.start_token().to_string(),
2295        };
2296
2297        match recycled.recheck().expect("resolvable") {
2298            Adoption::PidRecycled { current } => {
2299                assert_eq!(
2300                    current, survivor_identity,
2301                    "the recycled answer must name whoever actually holds the PID"
2302                );
2303            }
2304            other => panic!(
2305                "a recycled PID must not be adopted, and must be distinguishable from a PID \
2306                 nobody holds; got {other:?}"
2307            ),
2308        }
2309
2310        survivor
2311            .stop(Duration::from_secs(10))
2312            .expect("the second child stops");
2313    }
2314
2315    /// Only `ESRCH` means the process is gone.
2316    ///
2317    /// Runs on every leg, including the one with no `libc`, because the rule is
2318    /// a function over an `Option<i32>` rather than a `match` buried in a
2319    /// `#[cfg(target_os = "macos")]` body. `ESRCH` is 3 on both Unix platforms,
2320    /// but it is passed in rather than assumed, so this stays true of a
2321    /// platform where it is not.
2322    #[test]
2323    fn only_no_such_process_means_gone() {
2324        const ESRCH: i32 = 3;
2325        const EPERM: i32 = 1;
2326
2327        assert!(probe_failure_means_gone(Some(ESRCH), ESRCH));
2328
2329        // The finding this guards. A zero `errno` was once folded in with
2330        // `ESRCH`, which contradicted the Windows policy on
2331        // `ERROR_ACCESS_DENIED`: an unexplained failure is not evidence that
2332        // the process is gone, and answering `Gone` for one is how an agent
2333        // decides to start a second runner for an attempt that already has one.
2334        assert!(
2335            !probe_failure_means_gone(Some(0), ESRCH),
2336            "a zero errno is an unexplained failure, not an absent process"
2337        );
2338
2339        // A process owned by another account. Reporting this as `Gone` would
2340        // silently un-adopt every runner after a service-account change.
2341        assert!(!probe_failure_means_gone(Some(EPERM), ESRCH));
2342
2343        // No errno at all is not evidence of anything either.
2344        assert!(!probe_failure_means_gone(None, ESRCH));
2345    }
2346
2347    /// The attribute this scan walks, and the lint it looks for inside it.
2348    ///
2349    /// Assembled rather than written out, so that this file contains no
2350    /// literal spelling of the attribute outside the attributes themselves.
2351    /// A text scan reads its own machinery as readily as it reads code: the
2352    /// first version of this test found the attribute quoted in its own
2353    /// documentation and failed, which was a fair demonstration that it
2354    /// detects the shape and a reminder that the needle must not be in the
2355    /// haystack.
2356    const CFG_ATTR: &str = concat!("#[cfg_", "attr(");
2357
2358    /// The lint's own name, which has to appear verbatim in any allowance.
2359    const DEAD_CODE: &str = concat!("dead_", "code");
2360
2361    /// The condition of every `dead_code` allowance in `source` that names a
2362    /// platform rather than the complement of one.
2363    ///
2364    /// Split out from the test below so that it can also be run against a
2365    /// fixture. A scan that finds nothing in this file is worth exactly what
2366    /// its ability to find something is worth, and the only way to establish
2367    /// that is to hand it something it must object to -- the same device
2368    /// `logging.rs` uses to prove its secret-injection scan is capable of
2369    /// failing.
2370    fn positive_dead_code_conditions(source: &str) -> Vec<String> {
2371        // Comment lines go first, for the reason `CFG_ATTR` documents.
2372        let code: String = source
2373            .lines()
2374            .filter(|line| !line.trim_start().starts_with("//"))
2375            .collect::<Vec<_>>()
2376            .join("\n");
2377
2378        let mut offenders = Vec::new();
2379
2380        for block in code.split(CFG_ATTR).skip(1) {
2381            // Walk to the `)` closing the attribute, and remember the first
2382            // comma at depth zero: that is what separates the condition from
2383            // the attributes it applies.
2384            let mut depth = 0usize;
2385            let mut body_end = None;
2386            let mut split_at = None;
2387            for (index, character) in block.char_indices() {
2388                match character {
2389                    '(' => depth += 1,
2390                    ')' => {
2391                        if depth == 0 {
2392                            body_end = Some(index);
2393                            break;
2394                        }
2395                        depth -= 1;
2396                    }
2397                    ',' if depth == 0 && split_at.is_none() => split_at = Some(index),
2398                    _ => {}
2399                }
2400            }
2401
2402            let (Some(body_end), Some(split_at)) = (body_end, split_at) else {
2403                continue;
2404            };
2405            if !block[split_at..body_end].contains(DEAD_CODE) {
2406                continue;
2407            }
2408
2409            let condition = block[..split_at].trim();
2410            if !condition.starts_with("not(") {
2411                offenders.push(condition.to_string());
2412            }
2413        }
2414
2415        offenders
2416    }
2417
2418    /// Every `dead_code` allowance in this file must name a *complement*.
2419    ///
2420    /// A lint on the lint, and it exists because the alternative did not work.
2421    /// `probe_failure_means_gone` carried a `dead_code` allowance conditioned
2422    /// on `windows` while its only non-test caller was macOS-only. On Linux the
2423    /// allowance was inactive *and* the caller absent, so the lint fired on
2424    /// the lib target and `cargo clippy --all-targets -- -D warnings` failed --
2425    /// on the one CI leg no Windows developer runs, and invisibly to every
2426    /// local gate.
2427    ///
2428    /// A `dead_code` allowance is a claim about everywhere the caller is *not*,
2429    /// so its condition is naturally a complement: `not(target_os = "…")`. A
2430    /// bare positive names a single platform and says nothing whatsoever about
2431    /// the rest, which is precisely how the wrong one went unnoticed. Requiring
2432    /// the complement form does not prove the condition names the *right*
2433    /// platform, but it does refuse the shape that hid the bug.
2434    ///
2435    /// # No lower bound, and why the vacuity check is a fixture instead
2436    ///
2437    /// This used to end with `checked >= 3`: a lower bound on the number of
2438    /// allowances found. A lower bound cannot tell "the parser stopped
2439    /// matching" from "somebody removed an allowance properly", so it punished
2440    /// the correct remediation. Replacing one of these with a plain
2441    /// `cfg(any(…, test))` -- no allowance at all, which is strictly stronger,
2442    /// because it makes the lint's premise false rather than silencing the
2443    /// report -- left `cargo clippy --all-targets -- -D warnings` clean and
2444    /// this test failing. A tripwire that fires on the better fix teaches
2445    /// people to make the worse one.
2446    ///
2447    /// Counting parsed attributes against occurrences in the text does not fix
2448    /// it either, and that was tried: both counts come from the same needle, so
2449    /// breaking the needle takes both to zero and the check passes. Measured,
2450    /// not reasoned -- the probe that was meant to fail did not.
2451    ///
2452    /// So the vacuity check is
2453    /// [`the_allowance_scan_catches_a_positive_condition`], which runs the same
2454    /// walk over a fixture that must be objected to. It cannot go vacuous,
2455    /// because its input does not depend on what this file happens to contain,
2456    /// and it costs nothing when an allowance is properly removed.
2457    ///
2458    /// # Scope
2459    ///
2460    /// Scoped to this file because it is the only one in the crate that carries
2461    /// a `dead_code` allowance. It is *not* the only one with
2462    /// target-conditional compilation, which is what this used to claim and
2463    /// which was untrue: `paths.rs` and `logging.rs` both carry `#[cfg(unix)]`,
2464    /// and `lock.rs` carries a Windows arm and a Unix arm. Those are a
2465    /// different shape and not the one guarded here -- a `cfg` that selects an
2466    /// implementation is load-bearing, while an `allow(dead_code)` conditioned
2467    /// on one platform is an unexamined claim about all the others. If an
2468    /// allowance ever appears in another file this scan will not see it, and
2469    /// widening it is then the fix.
2470    #[test]
2471    fn every_dead_code_allowance_names_a_complement() {
2472        let offenders = positive_dead_code_conditions(include_str!("process.rs"));
2473        assert!(
2474            offenders.is_empty(),
2475            "a dead_code allowance must name the complement of its caller's \
2476             cfg rather than one platform, or it says nothing about the legs \
2477             it does not name: {offenders:?}"
2478        );
2479    }
2480
2481    /// Proves the scan above is capable of finding something.
2482    ///
2483    /// Without this, "no offending allowance in the file" and "the walk no
2484    /// longer recognises an attribute" are the same passing test. The fixtures
2485    /// are assembled with `concat!` for the reason [`CFG_ATTR`] documents: a
2486    /// literal here would be found by the file scan itself and reported as a
2487    /// defect in the file.
2488    #[test]
2489    fn the_allowance_scan_catches_a_positive_condition() {
2490        let offending = concat!(
2491            "#[cfg_",
2492            "attr(windows, allow(dead_",
2493            "code, reason = \"a reason\"))]\nfn f() {}"
2494        );
2495        assert_eq!(
2496            positive_dead_code_conditions(offending),
2497            vec!["windows".to_string()],
2498            "the walk no longer recognises an allowance, so the scan over this \
2499             file is checking nothing"
2500        );
2501
2502        // The shape this file actually uses is not objected to.
2503        let complement = concat!(
2504            "#[cfg_",
2505            "attr(not(target_os = \"linux\"), allow(dead_",
2506            "code, reason = \"a reason\"))]\nfn f() {}"
2507        );
2508        assert!(positive_dead_code_conditions(complement).is_empty());
2509
2510        // Nor is a `cfg_attr` that allows something other than this lint, at
2511        // any condition: the rule is about claims made on this lint's behalf.
2512        let unrelated = concat!(
2513            "#[cfg_",
2514            "attr(windows, allow(clippy::needless_return))]\nfn f() {}"
2515        );
2516        assert!(positive_dead_code_conditions(unrelated).is_empty());
2517
2518        // A comment is not code. The rule this enforces is quoted in prose
2519        // above, and reading prose as code is how the first version of this
2520        // scan failed.
2521        let commented = concat!(
2522            "// #[cfg_",
2523            "attr(windows, allow(dead_",
2524            "code))]\nfn f() {}"
2525        );
2526        assert!(positive_dead_code_conditions(commented).is_empty());
2527    }
2528
2529    /// The three-way answer, on synthesised tokens, on every platform.
2530    ///
2531    /// The spawn-based tests above cannot cover the case where two identities
2532    /// carry the *same* start token: on Windows and macOS the resolution makes
2533    /// it unreachable, and on Linux it is a 10 ms race rather than something a
2534    /// test can request. That case is exactly what the tick boundary in
2535    /// `separate_start_tokens` is there to avoid, so it is worth pinning down
2536    /// what the discriminator does with it — and pinning it down on the Windows
2537    /// leg, which cannot exhibit the collision any other way.
2538    #[test]
2539    fn identical_start_tokens_are_the_same_process_and_differing_ones_are_not() {
2540        let recorded = ProcessIdentity {
2541            pid: 4312,
2542            start_token: "platform:token-a".to_string(),
2543        };
2544
2545        // The collision. This is the answer that makes a colliding fixture fail
2546        // for the wrong reason: `Live` is *correct* here, because on the
2547        // evidence available the two are the same process.
2548        assert_eq!(
2549            recorded.classify(Some("platform:token-a".to_string())),
2550            Adoption::Live,
2551            "an identical token is the same process; a fixture that spawns twice inside one \
2552             tick is therefore asserting against a correct answer"
2553        );
2554
2555        // A different token at the same PID is the recycled case, and it must
2556        // carry whoever holds the PID now.
2557        assert_eq!(
2558            recorded.classify(Some("platform:token-b".to_string())),
2559            Adoption::PidRecycled {
2560                current: ProcessIdentity {
2561                    pid: 4312,
2562                    start_token: "platform:token-b".to_string(),
2563                },
2564            }
2565        );
2566
2567        assert_eq!(recorded.classify(None), Adoption::Gone);
2568        assert!(
2569            recorded
2570                .classify(Some("platform:token-a".to_string()))
2571                .is_live()
2572        );
2573        assert!(
2574            !recorded
2575                .classify(Some("platform:token-b".to_string()))
2576                .is_live()
2577        );
2578        assert!(!recorded.classify(None).is_live());
2579    }
2580
2581    /// The Linux token shape is the one that can collide, so assert the
2582    /// collision is a tick-granularity property rather than a boot-id one.
2583    ///
2584    /// Runs everywhere: these are strings, not `/proc` reads.
2585    #[test]
2586    fn two_linux_tokens_collide_only_within_one_tick_of_one_boot() {
2587        let boot = "f81d4fae-7dec-11d0-a765-00a0c91e6bf6";
2588        let at = |ticks: u64| ProcessIdentity {
2589            pid: 4312,
2590            start_token: format!("linux:{boot}:{ticks}"),
2591        };
2592
2593        // Same boot, same tick: indistinguishable. This is the H1 collision,
2594        // reproduced without a Linux kernel.
2595        assert_eq!(
2596            at(884_213).classify(Some(at(884_213).start_token)),
2597            Adoption::Live
2598        );
2599
2600        // One tick apart — 10 ms — is enough to tell them apart, which is why
2601        // `separate_start_tokens` sleeps for longer than one tick.
2602        assert!(
2603            !at(884_213)
2604                .classify(Some(at(884_214).start_token))
2605                .is_live()
2606        );
2607
2608        // The same tick count across a reboot is a different token, which is
2609        // the whole reason the boot id is in there.
2610        let other_boot = ProcessIdentity {
2611            pid: 4312,
2612            start_token: "linux:6ba7b810-9dad-11d1-80b4-00c04fd430c8:884213".to_string(),
2613        };
2614        assert!(!at(884_213).classify(Some(other_boot.start_token)).is_live());
2615    }
2616
2617    /// Shows the previous test is not vacuous.
2618    ///
2619    /// A bare-PID identity — the implementation the task specification calls
2620    /// insufficient — is built here and run through the same comparison. It
2621    /// reports the recycled record as a match, which is the failure the start
2622    /// token exists to prevent. If this test ever stops finding a difference,
2623    /// the start token has stopped discriminating and the test above has become
2624    /// decoration.
2625    #[test]
2626    fn a_bare_pid_would_have_accepted_the_recycled_record() {
2627        let mut victim = long_running().spawn().expect("the first child starts");
2628        let recorded = victim.identity().clone();
2629        separate_start_tokens();
2630        let mut survivor = long_running().spawn().expect("the second child starts");
2631        let survivor_identity = survivor.identity().clone();
2632        assert_distinguishable(&recorded, &survivor_identity);
2633
2634        victim
2635            .stop(Duration::from_secs(10))
2636            .expect("the first child stops");
2637
2638        let recycled = ProcessIdentity {
2639            pid: survivor_identity.pid(),
2640            start_token: recorded.start_token().to_string(),
2641        };
2642
2643        // What a PID-only comparison would conclude.
2644        let bare_pid_says_live = recycled.pid() == survivor_identity.pid();
2645        assert!(
2646            bare_pid_says_live,
2647            "the recycled record must genuinely point at a live process, or the test above is \
2648             not testing recycling at all"
2649        );
2650
2651        // What this module concludes.
2652        assert!(
2653            !recycled.recheck().expect("resolvable").is_live(),
2654            "the start token must reject what a bare PID accepts"
2655        );
2656
2657        survivor
2658            .stop(Duration::from_secs(10))
2659            .expect("the second child stops");
2660    }
2661
2662    #[test]
2663    fn terminating_a_recycled_record_refuses_rather_than_killing_a_stranger() {
2664        let mut victim = long_running().spawn().expect("the first child starts");
2665        let recorded = victim.identity().clone();
2666        separate_start_tokens();
2667        let mut survivor = long_running().spawn().expect("the second child starts");
2668        let survivor_identity = survivor.identity().clone();
2669        // Without this guard a token collision would make the test SIGTERM and
2670        // then SIGKILL the very process it exists to prove is protected, and
2671        // then fail on `survivor.is_running()` — a failure that names the wrong
2672        // cause and has already destroyed its own evidence.
2673        assert_distinguishable(&recorded, &survivor_identity);
2674
2675        victim
2676            .stop(Duration::from_secs(10))
2677            .expect("the first child stops");
2678
2679        let recycled = ProcessIdentity {
2680            pid: survivor_identity.pid(),
2681            start_token: recorded.start_token().to_string(),
2682        };
2683
2684        let outcome = recycled
2685            .terminate(Duration::from_secs(1))
2686            .expect("terminable");
2687        assert_eq!(
2688            outcome,
2689            Termination::RefusedPidRecycled {
2690                current: survivor_identity,
2691            },
2692            "terminating a recycled record must refuse; killing a stranger's process is the \
2693             worst outcome this primitive can produce"
2694        );
2695
2696        assert!(
2697            survivor.is_running().expect("observable"),
2698            "the innocent process must still be running"
2699        );
2700        survivor.stop(Duration::from_secs(10)).expect("cleanup");
2701    }
2702
2703    #[test]
2704    fn terminating_by_identity_stops_a_process_this_agent_did_not_spawn_as_a_child() {
2705        // The post-restart shape: an identity from the journal, no `Child`.
2706        let mut child = long_running().spawn().expect("the child starts");
2707        let identity = child.identity().clone();
2708
2709        assert_eq!(
2710            identity
2711                .terminate(Duration::from_secs(10))
2712                .expect("terminable"),
2713            Termination::Terminated
2714        );
2715
2716        // Reap it so the PID is released; on Unix an unreaped child stays a
2717        // zombie and keeps its PID.
2718        let status = child.wait_for(Duration::from_secs(30)).expect("waitable");
2719        assert!(status.is_some(), "the process must actually have stopped");
2720
2721        assert_eq!(
2722            identity
2723                .terminate(Duration::from_secs(1))
2724                .expect("terminable"),
2725            Termination::AlreadyGone,
2726            "terminating an already-dead identity must be a no-op, not an error: recovery runs \
2727             this on every journal entry"
2728        );
2729    }
2730
2731    #[test]
2732    fn resolving_a_pid_nobody_holds_is_a_distinct_error() {
2733        // PID 0 is the idle/kernel process on Windows and the scheduler on
2734        // Linux; neither is a process this account can adopt, and on macOS
2735        // `proc_pidinfo` reports nothing for it. Using a child's PID after it
2736        // has been reaped is the honest test, so do that instead.
2737        let mut child = quick_exit().spawn().expect("the child starts");
2738        let pid = child.pid();
2739        child.wait().expect("the child exits");
2740
2741        match ProcessIdentity::resolve(pid) {
2742            Err(ProcessError::NoSuchProcess { pid: reported }) => assert_eq!(reported, pid),
2743            // A PID freed moments ago can legitimately be reused by an
2744            // unrelated process on a busy machine. That is not this test's
2745            // failure; it is the very thing the module handles.
2746            Ok(other) => assert_eq!(other.pid(), pid),
2747            Err(other) => panic!("unexpected error: {other}"),
2748        }
2749    }
2750
2751    // -----------------------------------------------------------------------
2752    // Restrictive handoff
2753    // -----------------------------------------------------------------------
2754
2755    /// Stands in for an encoded JIT configuration: long, base64-shaped, and
2756    /// the thing `07-security.md` says must never reach a process listing.
2757    fn jit_payload() -> SecretString {
2758        SecretString::from(
2759            "eyJhZ2VudCI6ICJydW5uZXItbWFuYWdlciIsICJqaXQiOiAidGhpcy1pcy1ub3QtYS1yZWFsLWNvbmZp\
2760             Zy1idXQtaXQtaXMtdGhlLXJpZ2h0LXNoYXBlLWFuZC1sZW5ndGgifQ=="
2761                .to_string(),
2762        )
2763    }
2764
2765    #[test]
2766    fn a_handoff_file_is_unreadable_by_other_local_users() {
2767        let directory = tempfile::tempdir().expect("a temporary directory");
2768        let payload = jit_payload();
2769        let handoff =
2770            RestrictiveHandoff::create(directory.path(), payload).expect("the file is created");
2771
2772        let contents = std::fs::read_to_string(handoff.path()).expect("this account can read it");
2773        assert_eq!(contents, jit_payload().expose_secret());
2774
2775        let permissions = handoff.permissions().expect("inspectable");
2776        assert!(
2777            !permissions.readable_by_other_local_users,
2778            "the JIT handoff is readable by other local accounts: {}",
2779            permissions.description
2780        );
2781    }
2782
2783    #[test]
2784    fn the_permissions_check_catches_a_world_readable_file() {
2785        // Without this, `a_handoff_file_is_unreadable_by_other_local_users`
2786        // could be passing because the check always says "no". Create a file
2787        // the ordinary way — inheriting whatever the directory grants, with no
2788        // restriction applied — and require the check to tell it apart from the
2789        // restrictive one.
2790        let directory = tempfile::tempdir().expect("a temporary directory");
2791
2792        let restrictive = directory.path().join("restrictive");
2793        drop(super::sys::create_restrictive_file(&restrictive).expect("created"));
2794        let restrictive_summary = permissions_summary(&restrictive).expect("inspectable");
2795        assert!(!restrictive_summary.readable_by_other_local_users);
2796
2797        let open = directory.path().join("open");
2798        std::fs::write(&open, b"not a secret").expect("created");
2799        make_world_readable(&open);
2800        let open_summary = permissions_summary(&open).expect("inspectable");
2801
2802        assert!(
2803            open_summary.readable_by_other_local_users,
2804            "a deliberately permissive file was reported as restricted, so the assertion in \
2805             `a_handoff_file_is_unreadable_by_other_local_users` proves nothing. \
2806             restrictive={} permissive={}",
2807            restrictive_summary.description, open_summary.description
2808        );
2809    }
2810
2811    #[cfg(unix)]
2812    fn make_world_readable(path: &Path) {
2813        use std::os::unix::fs::PermissionsExt;
2814        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o644))
2815            .expect("the mode can be widened");
2816    }
2817
2818    #[cfg(windows)]
2819    fn make_world_readable(path: &Path) {
2820        // `icacls` is part of Windows itself and is the shortest way to put a
2821        // real Everyone ACE on a real file. Test-only: nothing in the product
2822        // ever widens a DACL.
2823        //
2824        // Best effort, and the caller's assertion does not depend on it. A file
2825        // created the ordinary way already inherits the parent directory's
2826        // DACL, which is unprotected — and an unprotected DACL is by itself
2827        // something `dacl_grants_broad_access` must report, because this
2828        // program cannot vouch for what a directory it did not create grants.
2829        let _ = std::process::Command::new("icacls")
2830            .arg(path)
2831            .args(["/grant", "*S-1-1-0:(R)"])
2832            .stdout(std::process::Stdio::null())
2833            .stderr(std::process::Stdio::null())
2834            .status();
2835    }
2836
2837    #[test]
2838    fn a_handoff_file_is_deleted_on_the_success_path() {
2839        let directory = tempfile::tempdir().expect("a temporary directory");
2840        let handoff = RestrictiveHandoff::create(directory.path(), jit_payload()).expect("created");
2841        let path = handoff.path().to_path_buf();
2842        assert!(path.exists());
2843
2844        handoff.delete().expect("deletable");
2845        assert!(!path.exists(), "the handoff outlived its explicit deletion");
2846    }
2847
2848    #[test]
2849    fn a_handoff_file_is_deleted_on_the_failure_path() {
2850        let directory = tempfile::tempdir().expect("a temporary directory");
2851
2852        // A launch that fails after the handoff exists. `?` unwinds through
2853        // `RestrictiveHandoff`'s `Drop`, which is the only thing standing
2854        // between a failed start and a JIT configuration left on disk.
2855        fn launch_and_fail(directory: &Path) -> Result<PathBuf, ProcessError> {
2856            let handoff = RestrictiveHandoff::create(directory, jit_payload())
2857                .expect("the handoff is created");
2858            let path = handoff.path().to_path_buf();
2859            SpawnSpec::new("a-program-that-does-not-exist-anywhere")
2860                .arg(handoff.path())
2861                .spawn_with_handoff(&handoff)?;
2862            Ok(path)
2863        }
2864
2865        let before = std::fs::read_dir(directory.path())
2866            .expect("readable")
2867            .count();
2868        assert_eq!(before, 0, "the temporary directory should start empty");
2869
2870        let error = launch_and_fail(directory.path()).expect_err("the program does not exist");
2871        assert!(matches!(error, ProcessError::Spawn { .. }), "{error}");
2872
2873        let remaining: Vec<PathBuf> = std::fs::read_dir(directory.path())
2874            .expect("readable")
2875            .filter_map(Result::ok)
2876            .map(|entry| entry.path())
2877            .collect();
2878        assert!(
2879            remaining.is_empty(),
2880            "a failed start left the JIT handoff on disk: {remaining:?}"
2881        );
2882    }
2883
2884    #[test]
2885    fn a_handoff_file_is_deleted_when_a_panic_unwinds_past_it() {
2886        let directory = tempfile::tempdir().expect("a temporary directory");
2887        let root = directory.path().to_path_buf();
2888
2889        let panicked = std::panic::catch_unwind(move || {
2890            let _handoff = RestrictiveHandoff::create(&root, jit_payload()).expect("created");
2891            panic!("something went wrong after the handoff was written");
2892        });
2893        assert!(panicked.is_err());
2894
2895        let remaining: Vec<PathBuf> = std::fs::read_dir(directory.path())
2896            .expect("readable")
2897            .filter_map(Result::ok)
2898            .map(|entry| entry.path())
2899            .collect();
2900        assert!(
2901            remaining.is_empty(),
2902            "a panic left the JIT handoff on disk: {remaining:?}"
2903        );
2904    }
2905
2906    #[test]
2907    fn spawning_refuses_to_put_the_payload_in_an_argument() {
2908        let directory = tempfile::tempdir().expect("a temporary directory");
2909        let handoff = RestrictiveHandoff::create(directory.path(), jit_payload()).expect("created");
2910
2911        // The mistake `07-security.md`'s threat table names: the configuration
2912        // itself on the command line, where every local account's process
2913        // listing shows it.
2914        let spec = long_running()
2915            .arg("--jit-config")
2916            .arg(jit_payload().expose_secret());
2917
2918        let error = spec
2919            .spawn_with_handoff(&handoff)
2920            .expect_err("the payload must never reach a command line");
2921        match error {
2922            ProcessError::SecretInCommandLine { location, .. } => {
2923                assert!(location.starts_with("argument"), "{location}");
2924            }
2925            other => panic!("expected a refusal, got {other}"),
2926        }
2927    }
2928
2929    #[test]
2930    fn spawning_refuses_to_put_the_payload_in_the_environment() {
2931        let directory = tempfile::tempdir().expect("a temporary directory");
2932        let handoff = RestrictiveHandoff::create(directory.path(), jit_payload()).expect("created");
2933
2934        let spec = long_running().env("ACTIONS_RUNNER_JITCONFIG", jit_payload().expose_secret());
2935
2936        let error = spec
2937            .spawn_with_handoff(&handoff)
2938            .expect_err("the payload must not be inherited through the environment either");
2939        match error {
2940            ProcessError::SecretInCommandLine { location, .. } => {
2941                assert!(location.contains("ACTIONS_RUNNER_JITCONFIG"), "{location}");
2942            }
2943            other => panic!("expected a refusal, got {other}"),
2944        }
2945    }
2946
2947    #[test]
2948    fn runner_handoff_injects_the_supported_secret_input_without_an_argument() {
2949        let directory = tempfile::tempdir().expect("a temporary directory");
2950        let payload = jit_payload();
2951        let payload_length = payload.expose_secret().len();
2952        let handoff = RestrictiveHandoff::create(directory.path(), payload).expect("created");
2953        let spec = runner_jit_input_probe(payload_length);
2954
2955        let rendered: Vec<String> = spec
2956            .arguments()
2957            .iter()
2958            .map(|argument| argument.to_string_lossy().into_owned())
2959            .collect();
2960        assert!(
2961            rendered
2962                .iter()
2963                .all(|argument| !argument.contains(jit_payload().expose_secret())),
2964            "the JIT payload reached the command line: {rendered:?}"
2965        );
2966        assert!(
2967            rendered
2968                .iter()
2969                .all(|argument| argument != "--jit-config-file"),
2970            "the obsolete listener option returned: {rendered:?}"
2971        );
2972
2973        let mut child = spec
2974            .spawn_runner_with_handoff(&handoff)
2975            .expect("the probe starts");
2976        handoff
2977            .delete()
2978            .expect("the handoff is deleted immediately");
2979        let status = child.wait().expect("the probe exits");
2980        assert!(
2981            status.success(),
2982            "the child did not receive the complete {RUNNER_JIT_CONFIG_ENV} input: {status}"
2983        );
2984    }
2985
2986    #[cfg(windows)]
2987    fn runner_jit_input_probe(expected_length: usize) -> SpawnSpec {
2988        SpawnSpec::new("powershell.exe").args([
2989            "-NoProfile".into(),
2990            "-NonInteractive".into(),
2991            "-Command".into(),
2992            format!(
2993                "$value = [Environment]::GetEnvironmentVariable('{RUNNER_JIT_CONFIG_ENV}'); \
2994                 if ($null -eq $value -or $value.Length -ne {expected_length}) {{ exit 41 }}"
2995            ),
2996        ])
2997    }
2998
2999    #[cfg(unix)]
3000    fn runner_jit_input_probe(expected_length: usize) -> SpawnSpec {
3001        SpawnSpec::new("/bin/sh").args([
3002            "-c".to_owned(),
3003            format!("test \"${{#{RUNNER_JIT_CONFIG_ENV}}}\" -eq \"{expected_length}\""),
3004        ])
3005    }
3006
3007    #[test]
3008    fn spawning_allows_the_handoff_path_itself() {
3009        let directory = tempfile::tempdir().expect("a temporary directory");
3010        let handoff = RestrictiveHandoff::create(directory.path(), jit_payload()).expect("created");
3011
3012        let spec = long_running().arg("--jit-config-file").arg(handoff.path());
3013        let mut child = spec
3014            .spawn_with_handoff(&handoff)
3015            .expect("passing the path is the supported handoff");
3016
3017        // What a process listing would show: the path, and nothing that
3018        // contains the payload.
3019        let rendered: Vec<String> = spec
3020            .arguments()
3021            .iter()
3022            .map(|arg| arg.to_string_lossy().into_owned())
3023            .collect();
3024        let payload = jit_payload();
3025        assert!(
3026            rendered
3027                .iter()
3028                .all(|arg| !arg.contains(payload.expose_secret())),
3029            "the payload reached the argument vector: {rendered:?}"
3030        );
3031        assert!(
3032            rendered
3033                .iter()
3034                .any(|arg| arg == &handoff.path().display().to_string()),
3035            "the handoff path should be there: {rendered:?}"
3036        );
3037
3038        child.stop(Duration::from_secs(10)).expect("cleanup");
3039    }
3040
3041    /// What the launchd job below writes into its working directory.
3042    #[cfg(target_os = "macos")]
3043    const PRIORITY_REPORT: &str = "priority-report.txt";
3044
3045    /// The job of a launch agent rendered by `launchd_plist`, run by launchd
3046    /// and by nothing else.
3047    ///
3048    /// It starts one child exactly as the lifecycle starts a runner and writes
3049    /// its own base priority and the child's into its working directory, which
3050    /// the plist points at a directory the test owns.
3051    #[cfg(target_os = "macos")]
3052    #[test]
3053    #[ignore = "run only by launchd, as the job of the launch-agent priority test"]
3054    fn launch_agent_priority_helper() {
3055        let label = std::env::var("XPC_SERVICE_NAME").unwrap_or_default();
3056        assert!(
3057            label.contains("-selftest-"),
3058            "run only by launchd for the launch-agent priority test, not under {label:?}"
3059        );
3060        let mut child = long_running()
3061            .expect_normal_scheduling()
3062            .spawn()
3063            .expect("child starts");
3064        let report = format!(
3065            "agent_priority={}\nchild_priority={}\n",
3066            sys::base_priority(std::process::id()).expect("own priority"),
3067            sys::base_priority(child.pid()).expect("child priority"),
3068        );
3069        child.stop(Duration::from_secs(5)).expect("child stops");
3070        // Renamed into place so the test never reads half a report.
3071        let staged = format!("{PRIORITY_REPORT}.partial");
3072        std::fs::write(&staged, report).expect("report staged");
3073        std::fs::rename(&staged, PRIORITY_REPORT).expect("report written");
3074    }
3075
3076    /// Loads the plist `launchd_plist` renders for a test plan, passed through
3077    /// `edit`, as a launch agent; waits for its job's report and boots it out
3078    /// again. Returns `(agent priority, child priority)`.
3079    #[cfg(target_os = "macos")]
3080    fn priorities_under_launch_agent(tag: &str, edit: impl FnOnce(String) -> String) -> (i32, i32) {
3081        use crate::service::launchd_fixture::LoadedAgent;
3082        use crate::service::{InstallPlan, ServiceDirectories, ServiceIdentity, launchd_plist};
3083        use runner_manager_domain::model::StartMode;
3084
3085        let root = tempfile::tempdir().expect("a temporary directory");
3086        let directories = ServiceDirectories {
3087            config: root.path().join("config"),
3088            state: root.path().join("state"),
3089            runtime: root.path().join("runtime"),
3090            logs: root.path().join("logs"),
3091        };
3092        for directory in directories.all() {
3093            std::fs::create_dir_all(directory).expect("directory created");
3094        }
3095        let binary = crate::service::launchd_fixture::job_binary(root.path());
3096        let identity = ServiceIdentity::fixture(&format!("priority-{tag}-{}", std::process::id()));
3097        let label = identity.launchd_label();
3098        let plan = InstallPlan::unchecked(identity, StartMode::Login, binary, directories.clone())
3099            .with_arguments([
3100                "--ignored",
3101                "--exact",
3102                "process::tests::launch_agent_priority_helper",
3103                "--test-threads=1",
3104            ]);
3105        let plist = root.path().join(format!("{label}.plist"));
3106        std::fs::write(&plist, edit(launchd_plist(&plan))).expect("plist written");
3107        let _loaded = LoadedAgent::bootstrap(&label, &plist);
3108
3109        let report = directories.state.join(PRIORITY_REPORT);
3110        let deadline = std::time::Instant::now() + Duration::from_secs(90);
3111        let text = loop {
3112            if let Ok(text) = std::fs::read_to_string(&report) {
3113                break text;
3114            }
3115            assert!(
3116                std::time::Instant::now() < deadline,
3117                "the {tag} agent's job wrote no report; its stderr: {}",
3118                std::fs::read_to_string(directories.logs.join("runner-manager.launchd.err.log"))
3119                    .unwrap_or_default()
3120            );
3121            std::thread::sleep(Duration::from_millis(250));
3122        };
3123        let value = |name: &str| -> i32 {
3124            text.lines()
3125                .find_map(|line| line.strip_prefix(&format!("{name}=")))
3126                .and_then(|value| value.trim().parse().ok())
3127                .unwrap_or_else(|| panic!("no {name} in the {tag} report: {text}"))
3128        };
3129        (value("agent_priority"), value("child_priority"))
3130    }
3131
3132    /// A runner started by a daemon under the launch agent `launchd_plist`
3133    /// renders runs at normal priority.
3134    ///
3135    /// This is the test 0.4.31 lacked. Its test moved the parent into the
3136    /// background with `setpriority`, which a child can undo, so it passed while
3137    /// every real runner stayed at 4: launchd's `ProcessType` is a different
3138    /// mechanism and a child cannot undo it. Here launchd itself starts the job,
3139    /// from the plist the product installs.
3140    ///
3141    /// The control agent is the same plist with `ProcessType` set back to
3142    /// `Background`. Its child has to be throttled, which proves that launchd
3143    /// applies the job's class to descendants on this host. Without that, the
3144    /// rendered agent's normal-priority child would prove nothing.
3145    #[cfg(target_os = "macos")]
3146    #[test]
3147    fn a_runner_under_the_rendered_launch_agent_runs_at_normal_priority() {
3148        if !crate::service::launchd_fixture::enabled() {
3149            return;
3150        }
3151        let ceiling = sys::BACKGROUND_PRIORITY_CEILING;
3152        let process_type =
3153            |value: &str| format!("<key>ProcessType</key>\n  <string>{value}</string>");
3154        let rendered = process_type(crate::service::LAUNCHD_PROCESS_TYPE);
3155
3156        let (control_agent, control_child) = priorities_under_launch_agent("control", |plist| {
3157            assert!(plist.contains(&rendered), "{plist}");
3158            plist.replace(&rendered, &process_type("Background"))
3159        });
3160        assert!(
3161            control_agent <= ceiling && control_child <= ceiling,
3162            "a Background agent's job ({control_agent}) and its child ({control_child}) were not \
3163             throttled, so this host does not show the condition under test"
3164        );
3165
3166        let (agent, child) = priorities_under_launch_agent("rendered", |plist| plist);
3167        eprintln!(
3168            "Background control: job {control_agent}, runner {control_child}; \
3169             rendered plist: job {agent}, runner {child}"
3170        );
3171        assert!(
3172            child > ceiling,
3173            "a runner started under the rendered launch agent runs at background priority \
3174             {child} (agent {agent}); launchd applies the plist's ProcessType to every runner"
3175        );
3176    }
3177
3178    #[test]
3179    fn the_handoff_path_is_unique_per_handoff() {
3180        let directory = tempfile::tempdir().expect("a temporary directory");
3181        let first = RestrictiveHandoff::create(directory.path(), jit_payload()).expect("created");
3182        let second = RestrictiveHandoff::create(directory.path(), jit_payload()).expect("created");
3183
3184        assert_ne!(
3185            first.path(),
3186            second.path(),
3187            "two concurrent attempts must not share a handoff file"
3188        );
3189    }
3190
3191    #[test]
3192    fn the_payload_has_no_debug_or_display_that_reveals_it() {
3193        // The structural half of the control: even a careless
3194        // `format!("{:?}", …)` in a later task cannot print the configuration.
3195        let payload = jit_payload();
3196        let rendered = format!("{payload:?}");
3197        assert!(
3198            !rendered.contains(payload.expose_secret()),
3199            "SecretString's Debug leaked the payload: {rendered}"
3200        );
3201
3202        let directory = tempfile::tempdir().expect("a temporary directory");
3203        let handoff = RestrictiveHandoff::create(directory.path(), jit_payload()).expect("created");
3204        let rendered = format!("{handoff:?}");
3205        assert!(
3206            !rendered.contains(jit_payload().expose_secret()),
3207            "RestrictiveHandoff's Debug leaked the payload: {rendered}"
3208        );
3209    }
3210}