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