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