Skip to main content

runner_manager_platform/
service.rs

1// owner: d3-service-installers
2
3//! Registering `daemon run` with the operating system, so that a home host
4//! resumes work by itself after a reboot with nobody logged in.
5//!
6//! Journey 5 is the whole of the requirement: *"the machine reboots with nobody
7//! logged in; the boot-start service starts the agent, which reads the user
8//! access token from the machine-scoped secret store"*. Everything in this file
9//! exists to make that sentence true on Windows, macOS, and Linux, and to make
10//! `service status` say so — or say precisely why not.
11//!
12//! # The shape: render, then apply
13//!
14//! A service manager is the one dependency a `cargo test` run cannot have. So
15//! this module is split in two, and the split is the reason most of it is
16//! testable on a developer's laptop whatever OS that laptop runs:
17//!
18//! * **Rendering** is pure. [`ServiceDefinition`] turns an [`InstallPlan`] into
19//!   the exact text the platform consumes — a systemd unit, a launchd property
20//!   list, a Task Scheduler XML document, or a canonical descriptor of the
21//!   Windows service parameters. No `cfg`, no privileges, no I/O. A Windows
22//!   developer renders and asserts the systemd unit; a Linux CI leg renders and
23//!   asserts the launchd plist.
24//! * **Applying** is behind [`ServiceControl`], one trait with a backend per
25//!   platform and a public in-memory double ([`RecordingControls`]) that `f3`
26//!   and this crate's own tests drive without touching the host.
27//!
28//! [`ServiceOperations`] is the layer above both, and it is where the logic
29//! that is *not* platform-specific lives: refusing an install while the
30//! single-instance lock is held, recording the resolved absolute binary path,
31//! detecting a stale one, switching start mode without reinstalling, and
32//! uninstalling without deleting a byte of configuration, secrets, or cache.
33//!
34//! # Two domains, not two products
35//!
36//! `--start-at boot` and `--start-at login` are the same daemon registered in
37//! two different *domains*:
38//!
39//! | | boot | login |
40//! |---|---|---|
41//! | Windows | a service in the Service Control Manager, `LocalSystem` | a Task Scheduler task with a logon trigger, running at `LeastPrivilege` |
42//! | macOS | a LaunchDaemon in `/Library/LaunchDaemons` | a LaunchAgent in `~/Library/LaunchAgents` |
43//! | Linux | a system unit in `/etc/systemd/system` | a user unit in `~/.config/systemd/user` |
44//!
45//! The Windows row is the one that is not symmetric, and it is worth saying why
46//! rather than leaving a reader to wonder. **Windows services cannot start at
47//! logon.** Service trigger-start covers domain join, an IP address becoming
48//! available, a device arriving, a firewall port event and a group-policy
49//! change; there is no logon trigger, and there is no user-session service
50//! type this product could use instead. Task Scheduler is the mechanism Windows
51//! actually provides for "run this when the operator signs in", so that is what
52//! `--start-at login` uses there. It is registered, inspected, and removed
53//! through the same [`ServiceControl`] trait as everything else, so nothing
54//! above this module has to know.
55//!
56//! # What the account can reach, and why it is not more
57//!
58//! `05-infrastructure.md` requires *"a least-privilege account that can read the
59//! machine-scoped secret store and write its configured cache and runtime
60//! directories"*. Those two clauses pull in opposite directions on every
61//! platform, and the resolution is recorded per platform in
62//! `docs/service-account.md` and checked by [`review_least_privilege`], which
63//! reads the rendered definition back and reports anything it grants beyond the
64//! requirement.
65//!
66//! The Windows resolution is the one that surprises people. `d2` protects the
67//! machine-scoped store with `D:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;OW)` and
68//! documents that the DACL *is* the access control, because a machine-scope
69//! DPAPI blob is decryptable by any process on the host. `NT AUTHORITY\
70//! LocalService` and `NT AUTHORITY\NetworkService` — the two accounts a
71//! "least privilege service" reflex reaches for — are named by none of those
72//! three ACEs and therefore **cannot read the token at all**. Widening the DACL
73//! to reach them would grant every service on the host read access to the one
74//! credential this product holds, which is strictly worse than running as
75//! `LocalSystem`. `secrets.rs` is not this task's file and is not widened; the
76//! service runs as `LocalSystem`, and [`review_least_privilege`] records the
77//! account together with the reason it is the minimum that satisfies the
78//! requirement rather than pretending it is small.
79//!
80//! # What is deliberately not claimed
81//!
82//! **A reboot is not something a test suite can have.** Every assertion here is
83//! about configuration a boot-time start depends on — the start type, the
84//! account, the recorded absolute path, the restart policy the service manager
85//! reports back — and about a store that is readable outside any login session,
86//! which `d2` proves separately. That the machine actually comes back up and
87//! the agent actually resumes is human gate 3 in `06-migration-rollout.md`, and
88//! nothing in this file is evidence for it.
89
90use std::collections::{BTreeMap, BTreeSet};
91use std::ffi::OsString;
92use std::fmt;
93use std::path::{Path, PathBuf};
94use std::time::Duration;
95
96use chrono::{DateTime, Utc};
97use runner_manager_domain::model::StartMode;
98use runner_manager_domain::path::LocalAbsolutePath;
99use serde::{Deserialize, Serialize};
100
101use crate::lock::{HostLock, LockError, LockKind};
102use crate::paths::AppPaths;
103#[cfg(windows)]
104use crate::runner_root_access::RootAdmission;
105use crate::runner_root_access::{
106    Reversal, RootAccessChange, RootAccessError, RootAccessReport, RootAccessSummary,
107};
108
109// ---------------------------------------------------------------------------
110// Names
111// ---------------------------------------------------------------------------
112
113/// The product's own service name, on every platform that wants a short one.
114pub const SERVICE_NAME: &str = "runner-manager";
115/// Hidden CLI marker carried only by Windows boot-service registrations.
116///
117/// Application-data arguments are shared by every service manager and cannot
118/// distinguish SCM from Task Scheduler. This marker is the durable routing
119/// contract between the Windows installer and the shipping binary.
120pub const WINDOWS_SCM_HOST_ARGUMENT: &str = "--windows-service-host";
121
122/// What an operator sees in `services.msc`, `launchctl list`, or
123/// `systemctl status`.
124pub const DISPLAY_NAME: &str = "GitHub Actions Runner Manager";
125
126/// One line of explanation, for the same three places.
127pub const DESCRIPTION: &str =
128    "Starts ephemeral GitHub Actions self-hosted runners on this machine on demand.";
129
130/// The arguments the installed command line carries.
131///
132/// `05-infrastructure.md`: *"`service install` registers `daemon run` for the
133/// current host"*. Spelled once, here, so the installer and `f3`'s command
134/// surface cannot disagree about what was registered.
135pub const DAEMON_ARGUMENTS: [&str; 2] = ["daemon", "run"];
136
137/// The file `install` writes and `status` reads, inside `config/`.
138///
139/// `config/` and not `state/`: `05-infrastructure.md` gives `config/` to
140/// *"non-secret TOML"*, and this is exactly that — a record of what was
141/// registered, holding no credential. It is also the one file `uninstall`
142/// removes, which is what keeps "uninstall deletes no configuration" honest:
143/// the record is not configuration, it is the registration's own footprint.
144pub const RECORD_FILE: &str = "service.toml";
145
146/// The file the daemon touches after every successful GitHub call, inside
147/// `state/`.
148///
149/// See [`record_github_contact`] for the contract; `service status` reads it
150/// and Journey 5 step 4 requires it.
151pub const CONTACT_FILE: &str = "github-contact.toml";
152
153/// Present while GitHub is rejecting the daemon's credential, inside `state/`.
154///
155/// See [`record_github_credential_rejected`] for the contract.
156pub const CREDENTIAL_REJECTION_FILE: &str = "github-credential-rejected.toml";
157
158/// Present while the daemon cannot read its own stored credential, inside
159/// `state/`.
160///
161/// See [`record_credential_unreadable`] for the contract.
162pub const CREDENTIAL_UNREADABLE_FILE: &str = "credential-unreadable.toml";
163
164/// The `service status` subject a [`CREDENTIAL_UNREADABLE_FILE`] is reported
165/// under, which the TUI matches to offer the same command.
166pub const CREDENTIAL_UNREADABLE_SUBJECT: &str = "stored credential";
167
168/// The `service status` subject blocked runner launches are reported under.
169pub const LAUNCHES_BLOCKED_SUBJECT: &str = "runner launches";
170
171/// The [`StatusProblem::subject`] of a daemon that has stopped making progress.
172pub const DAEMON_STALLED_SUBJECT: &str = "daemon progress";
173
174/// The [`StatusProblem::subject`] of a daemon that refuses to start runners
175/// because a required host check fails from its own session.
176pub const HOST_UNFIT_SUBJECT: &str = "host fitness";
177
178/// The agent's last runner-root refusal, for `service status` to report.
179///
180/// See [`record_runner_root_refusal`] for the contract.
181pub const ROOT_REFUSAL_FILE: &str = "runner-root-refusal.toml";
182
183/// The rotating diagnostic log the **daemon** writes, inside `logs/`.
184///
185/// The appender adds its own date suffix, so this is the stem rather than a
186/// file that exists. `service status` reports the directory and the stem,
187/// because that is what an operator needs in order to find today's file and
188/// yesterday's.
189///
190/// It is [`crate::logging::SERVICE_LOG_STEM`] and not the operator's, because a
191/// boot-mode daemon runs under a different account and writes a different file.
192/// [`crate::logging::LogRole`] says why the two were separated.
193pub const LOG_FILE_STEM: &str = crate::logging::SERVICE_LOG_STEM;
194
195// ---------------------------------------------------------------------------
196// Identity
197// ---------------------------------------------------------------------------
198
199/// What the operating system calls this registration.
200///
201/// A type rather than three constants, for one reason that is not tidiness: the
202/// privileged installer tests register a **real** service on a **real** machine,
203/// and they must not be able to collide with — or remove — an operator's
204/// installation. [`ServiceIdentity::fixture`] produces a name that is
205/// unmistakably a test artefact, and every backend takes its name from here, so
206/// there is no path by which a test reaches the product's own registration.
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct ServiceIdentity {
209    name: String,
210    display_name: String,
211    description: String,
212}
213
214impl ServiceIdentity {
215    /// The registration an operator installs.
216    #[must_use]
217    pub fn product() -> Self {
218        Self {
219            name: SERVICE_NAME.to_string(),
220            display_name: DISPLAY_NAME.to_string(),
221            description: DESCRIPTION.to_string(),
222        }
223    }
224
225    /// A disposable registration for a privileged test, named so that it cannot
226    /// be mistaken for — or collide with — [`ServiceIdentity::product`].
227    ///
228    /// `tag` distinguishes concurrent runs; it is reduced to ASCII alphanumerics
229    /// and `-` so that it is a legal service name, launchd label, systemd unit
230    /// name, and Task Scheduler task name at once.
231    #[must_use]
232    pub fn fixture(tag: &str) -> Self {
233        let tag: String = tag
234            .chars()
235            .map(|c| {
236                if c.is_ascii_alphanumeric() {
237                    c.to_ascii_lowercase()
238                } else {
239                    '-'
240                }
241            })
242            .collect();
243        let name = format!("{SERVICE_NAME}-selftest-{tag}");
244        Self {
245            display_name: format!("{DISPLAY_NAME} (self-test fixture {tag})"),
246            description: "Disposable fixture created by runner-manager's own installer tests. \
247                          Safe to remove."
248                .to_string(),
249            name,
250        }
251    }
252
253    /// Whether this identity is a disposable test fixture.
254    ///
255    /// The privileged tests assert this before they delete anything, which is
256    /// the mechanical half of *"never touch a service you did not create"*.
257    #[must_use]
258    pub fn is_fixture(&self) -> bool {
259        self.name.starts_with(&format!("{SERVICE_NAME}-selftest-"))
260    }
261
262    /// The Windows service name, the systemd unit stem, and the Task Scheduler
263    /// task name.
264    #[must_use]
265    pub fn name(&self) -> &str {
266        &self.name
267    }
268
269    /// The human-readable name.
270    #[must_use]
271    pub fn display_name(&self) -> &str {
272        &self.display_name
273    }
274
275    /// One line of explanation.
276    #[must_use]
277    pub fn description(&self) -> &str {
278        &self.description
279    }
280
281    /// The launchd label — reverse-domain, as launchd expects.
282    ///
283    /// Built from the same three product segments [`crate::paths`] resolves the
284    /// application-data directories with, so a label and a directory cannot
285    /// drift apart.
286    #[must_use]
287    pub fn launchd_label(&self) -> String {
288        format!(
289            "{}.{}.{}",
290            crate::paths::QUALIFIER,
291            crate::paths::ORGANIZATION,
292            self.name
293        )
294    }
295
296    /// The systemd unit file name.
297    #[must_use]
298    pub fn systemd_unit(&self) -> String {
299        format!("{}.service", self.name)
300    }
301
302    /// The service manager's own command that restarts this registration.
303    ///
304    /// The product has no `service stop` or `service start`, and
305    /// `service install` is no restart either: it refuses while the running
306    /// daemon holds the agent lock. So a remedy that restarts the daemon names
307    /// the service manager's command. A forced restart does not wait for jobs;
308    /// it is for a daemon that has stopped making progress anyway.
309    #[must_use]
310    pub fn restart_command(&self, mode: StartMode) -> String {
311        let name = &self.name;
312        if cfg!(windows) {
313            match mode {
314                StartMode::Boot => {
315                    format!("Restart-Service -Name {name}")
316                }
317                StartMode::Login => format!("schtasks /End /TN {name}; schtasks /Run /TN {name}"),
318            }
319        } else if cfg!(target_os = "macos") {
320            let label = self.launchd_label();
321            match mode {
322                StartMode::Boot => format!("sudo launchctl kickstart -k system/{label}"),
323                StartMode::Login => format!("launchctl kickstart -k gui/$(id -u)/{label}"),
324            }
325        } else {
326            let unit = self.systemd_unit();
327            match mode {
328                StartMode::Boot => format!("sudo systemctl restart {unit}"),
329                StartMode::Login => format!("systemctl --user restart {unit}"),
330            }
331        }
332    }
333}
334
335impl fmt::Display for ServiceIdentity {
336    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
337        f.write_str(&self.name)
338    }
339}
340
341// ---------------------------------------------------------------------------
342// Restart policy
343// ---------------------------------------------------------------------------
344
345/// The restart-on-failure policy, and the bound on how fast it may retry.
346///
347/// `05-infrastructure.md` item 3: *"set a restart-on-failure policy with bounded
348/// delay"*. Both halves matter and they are different requirements. A service
349/// that does not come back after a crash defeats Journey 5; a service that comes
350/// back instantly, forever, turns one bad configuration into a fork bomb against
351/// GitHub's rate limit. So the delay has a floor as well as a ceiling, and the
352/// floor is what `does not restart-loop faster than that bound` is measured
353/// against.
354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355pub struct RestartPolicy {
356    delay: Duration,
357    reset_after: Duration,
358}
359
360impl RestartPolicy {
361    /// The shortest delay this will accept.
362    ///
363    /// One second is not a tuning preference. Below it, launchd's own throttle
364    /// and systemd's start-limit logic both take over and the configured number
365    /// stops being the number in force, so a policy under this floor would be a
366    /// value `service status` reported and the platform ignored.
367    pub const MIN_DELAY: Duration = Duration::from_secs(1);
368
369    /// The longest delay this will accept. Past five minutes an operator
370    /// watching a restart would reasonably conclude the service is simply gone.
371    pub const MAX_DELAY: Duration = Duration::from_secs(300);
372
373    /// What `service install` uses when nothing is said.
374    pub const DEFAULT_DELAY: Duration = Duration::from_secs(15);
375
376    /// How long the service must stay up before its failure count is forgotten.
377    pub const DEFAULT_RESET_AFTER: Duration = Duration::from_secs(600);
378
379    /// # Errors
380    ///
381    /// [`ServiceError::RestartDelay`] when `delay` is outside
382    /// [`RestartPolicy::MIN_DELAY`]..=[`RestartPolicy::MAX_DELAY`], or when
383    /// `reset_after` is not longer than `delay` — a reset window shorter than
384    /// the delay can never elapse between two restarts, so the failure count
385    /// would reset on every attempt and no start-limit could ever trip.
386    pub fn new(delay: Duration, reset_after: Duration) -> Result<Self, ServiceError> {
387        if delay < Self::MIN_DELAY || delay > Self::MAX_DELAY {
388            return Err(ServiceError::RestartDelay {
389                requested_secs: delay.as_secs(),
390                min_secs: Self::MIN_DELAY.as_secs(),
391                max_secs: Self::MAX_DELAY.as_secs(),
392            });
393        }
394        if reset_after <= delay {
395            return Err(ServiceError::RestartResetWindow {
396                reset_secs: reset_after.as_secs(),
397                delay_secs: delay.as_secs(),
398            });
399        }
400        Ok(Self { delay, reset_after })
401    }
402
403    /// How long the service manager waits before restarting a failed service.
404    #[must_use]
405    pub const fn delay(&self) -> Duration {
406        self.delay
407    }
408
409    /// How long the service must run before its failure count resets.
410    #[must_use]
411    pub const fn reset_after(&self) -> Duration {
412        self.reset_after
413    }
414
415    /// The delay a given manager can actually express.
416    ///
417    /// Three of the four take seconds and enforce exactly what they are given.
418    /// **Windows Task Scheduler does not**: `RestartOnFailure/Interval` is
419    /// expressed in whole minutes with a one-minute floor, and it *rejects* the
420    /// registration outright rather than rounding — `PT15S` comes back as
421    /// "The task XML contains a value which is incorrectly formatted or out of
422    /// range", which is how this was found.
423    ///
424    /// So the interval is rounded **up**, never down, and never below one
425    /// minute. The direction is the point: the requirement is that the service
426    /// *"does not restart-loop faster than that bound"*, and a delay longer
427    /// than the configured one still satisfies it, while a shorter one would
428    /// not. `service status` reports the difference as a note so that an
429    /// operator reading `15s` in the record and `60s` from the manager is told
430    /// why rather than left to wonder.
431    #[must_use]
432    pub const fn effective_delay(&self, kind: DefinitionKind) -> Duration {
433        match kind {
434            DefinitionKind::WindowsScheduledTask => {
435                let seconds = self.delay.as_secs();
436                let minutes = seconds.div_ceil(60);
437                Duration::from_secs(if minutes == 0 { 60 } else { minutes * 60 })
438            }
439            _ => self.delay,
440        }
441    }
442}
443
444impl Default for RestartPolicy {
445    fn default() -> Self {
446        Self {
447            delay: Self::DEFAULT_DELAY,
448            reset_after: Self::DEFAULT_RESET_AFTER,
449        }
450    }
451}
452
453impl fmt::Display for RestartPolicy {
454    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
455        write!(
456            f,
457            "restart after {}s, failure count resets after {}s",
458            self.delay.as_secs(),
459            self.reset_after.as_secs()
460        )
461    }
462}
463
464// ---------------------------------------------------------------------------
465// The account
466// ---------------------------------------------------------------------------
467
468/// The account a registration runs under.
469///
470/// Not an operator choice. It is a function of the start mode and the platform,
471/// because the start mode decides which secret store the daemon must read and
472/// the store's own access control decides which accounts can read it. See
473/// `docs/service-account.md`, and [`review_least_privilege`] for the check.
474#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
475#[serde(rename_all = "snake_case")]
476pub enum ServiceAccount {
477    /// `NT AUTHORITY\SYSTEM`. The only stock Windows account named by the
478    /// machine-scoped store's DACL.
479    LocalSystem,
480    /// `root`. What a LaunchDaemon and a systemd system unit run as, and what
481    /// the macOS System Keychain's root-only master key requires.
482    Root,
483    /// The operator's own account, for a login-mode registration.
484    InvokingUser,
485}
486
487impl ServiceAccount {
488    /// The account a given kind of definition obliges.
489    ///
490    /// Keyed on the definition rather than on `cfg!(windows)`, and the
491    /// difference is not academic: a Windows developer rendering the launchd
492    /// property list would otherwise write `UserName = NT AUTHORITY\SYSTEM`
493    /// into it, which is not an account macOS has. A definition's account is a
494    /// property of the definition, and the whole point of rendering being pure
495    /// is that any host can render any platform's and get the right answer.
496    #[must_use]
497    pub const fn for_definition(kind: DefinitionKind, mode: StartMode) -> Self {
498        match (kind, mode) {
499            (DefinitionKind::WindowsService, _) => Self::LocalSystem,
500            (DefinitionKind::WindowsScheduledTask, _) | (_, StartMode::Login) => Self::InvokingUser,
501            (_, StartMode::Boot) => Self::Root,
502        }
503    }
504
505    /// The account the given start mode obliges on the platform this binary was
506    /// built for.
507    #[must_use]
508    pub const fn for_start_mode(mode: StartMode) -> Self {
509        Self::for_definition(host_definition_kind(mode), mode)
510    }
511
512    /// How the platform spells it.
513    #[must_use]
514    pub const fn as_str(&self) -> &'static str {
515        match self {
516            Self::LocalSystem => "NT AUTHORITY\\SYSTEM",
517            Self::Root => "root",
518            Self::InvokingUser => "the invoking user",
519        }
520    }
521
522    /// Why this is the *minimum* account that can do the job, not merely the
523    /// convenient one.
524    ///
525    /// Printed by `service status` and by [`review_least_privilege`], because a
526    /// privileged account with no stated reason is indistinguishable from a
527    /// privileged account nobody thought about.
528    #[must_use]
529    pub const fn justification(&self) -> &'static str {
530        match self {
531            Self::LocalSystem => {
532                "the machine-scoped store's DACL names SY, BA and OW only; LocalService and \
533                 NetworkService cannot read it, and widening the DACL to reach them would grant \
534                 every service on this host read access to the one credential this product holds"
535            }
536            Self::Root => {
537                "a boot-time registration runs outside every login session: on macOS the System \
538                 Keychain is unlocked by /var/db/SystemKey, which is root-only, and on Linux the \
539                 machine-scoped store is a 0600 file under /var/lib that only root can open \
540                 before a session exists"
541            }
542            Self::InvokingUser => {
543                "a login-mode registration reads the user-scoped store, which is deliberately \
544                 readable by exactly one account and needs no elevation at all"
545            }
546        }
547    }
548
549    /// Whether registering under this account needs administrative rights.
550    #[must_use]
551    pub const fn needs_elevation(&self) -> bool {
552        matches!(self, Self::LocalSystem | Self::Root)
553    }
554}
555
556impl fmt::Display for ServiceAccount {
557    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
558        f.write_str(self.as_str())
559    }
560}
561
562// ---------------------------------------------------------------------------
563// Errors
564// ---------------------------------------------------------------------------
565
566/// Something went wrong installing, inspecting, or removing the registration.
567///
568/// Every variant is formatted straight into an operator-facing message, so each
569/// one says what to do next rather than only what failed. No variant carries a
570/// secret: this module never reads the token, only the *location* `d2` publishes
571/// for exactly this purpose.
572#[derive(Debug, thiserror::Error)]
573pub enum ServiceError {
574    /// The single-instance lock is held, so a second agent must not be
575    /// registered.
576    ///
577    /// `05-infrastructure.md` item 1. The message is `d1`'s, verbatim, because
578    /// `d1` already worked out what to tell an operator who lost that race.
579    #[error("cannot install the service while an agent is already running on this host: {source}")]
580    LockHeld {
581        /// What `d1` said, including who holds it.
582        #[source]
583        source: Box<LockError>,
584    },
585
586    /// The lock could not be inspected at all, which is not the same as it being
587    /// free.
588    #[error(
589        "cannot tell whether an agent is already running on this host, so the service was not \
590         installed: {source}. Fix the reported problem with the state directory and try again."
591    )]
592    LockUnreadable {
593        /// The underlying failure.
594        #[source]
595        source: Box<LockError>,
596    },
597
598    /// The runner root this registration would run jobs under could not be
599    /// created, inspected, or given the access the registration's account needs.
600    ///
601    /// Reported *before* anything is registered. A registration whose workspaces
602    /// would land in a directory ordinary local users can write is a
603    /// registration that should not exist, and `04-security-recovery.md` asks
604    /// for exactly that refusal rather than a warning.
605    #[error(
606        "this registration would run jobs under the default runner root, and that root could \
607         not be prepared, so nothing was registered: {source}"
608    )]
609    RunnerRoot {
610        /// What [`crate::runner_root_access`] reported, including the remedy.
611        #[source]
612        source: Box<RootAccessError>,
613    },
614
615    /// The path of the running binary could not be resolved.
616    #[error(
617        "cannot resolve the absolute path of this executable, so there is nothing to register: \
618         {detail}. Run the installer from the installed binary rather than through a shell \
619         function or a wrapper that replaces argv[0]."
620    )]
621    BinaryPath {
622        /// What the platform reported.
623        detail: String,
624    },
625
626    /// The binary named for the registration is not there.
627    #[error(
628        "{} is not a file, so registering it would create a service that cannot start. Install \
629         the product first, then run `service install` from the installed binary.",
630        path.display()
631    )]
632    BinaryMissing {
633        /// The path that was to be registered.
634        path: PathBuf,
635    },
636
637    /// The requested restart delay is outside the bound.
638    #[error(
639        "a restart delay of {requested_secs}s is outside the supported range \
640         {min_secs}s-{max_secs}s. Below the floor the platform's own throttle overrides the \
641         value, so `service status` would report a delay that is not the one in force."
642    )]
643    RestartDelay {
644        /// What was asked for.
645        requested_secs: u64,
646        /// The floor.
647        min_secs: u64,
648        /// The ceiling.
649        max_secs: u64,
650    },
651
652    /// The failure-count reset window is not longer than the restart delay.
653    #[error(
654        "a failure-count reset window of {reset_secs}s is not longer than the {delay_secs}s \
655         restart delay, so the count would reset between every pair of restarts and no \
656         start limit could ever apply."
657    )]
658    RestartResetWindow {
659        /// What was asked for.
660        reset_secs: u64,
661        /// The delay it has to exceed.
662        delay_secs: u64,
663    },
664
665    /// A registration is already there, in the **other** start mode.
666    ///
667    /// Only the other one. A registration in the mode being asked for is
668    /// replaced rather than refused — see [`Operations::install`], and the
669    /// outage that taught us why.
670    #[error(
671        "{name} is already registered to start at {existing}, and this asks for {requested}. \
672         Changing the start mode moves the registration between two different service \
673         managers, so it is not something an install does by itself: run `service uninstall` \
674         first, or change the start mode in the terminal UI (`runner-manager tui`), which \
675         switches it in place and keeps the registration running throughout."
676    )]
677    AlreadyInstalled {
678        /// Which registration.
679        name: String,
680        /// The mode it currently carries.
681        existing: StartMode,
682        /// The mode this install asked for.
683        requested: StartMode,
684    },
685
686    /// No registration is there.
687    #[error("{name} is not registered on this host, so there is nothing to {operation}.")]
688    NotInstalled {
689        /// Which registration.
690        name: String,
691        /// What was being attempted.
692        operation: &'static str,
693    },
694
695    /// The record `install` wrote could not be read or written.
696    #[error("cannot {operation} the service record {}: {detail}", path.display())]
697    Record {
698        /// Read, write, or remove.
699        operation: &'static str,
700        /// The record file.
701        path: PathBuf,
702        /// The underlying failure.
703        detail: String,
704    },
705
706    /// The record is there but is not one this version wrote.
707    #[error(
708        "the service record {} was not written by this product, or was written by a version \
709         this one cannot read: {detail}. Run `service uninstall` and `service install` again; \
710         neither touches configuration, secrets, or the cache.",
711        path.display()
712    )]
713    RecordUnreadable {
714        /// The record file.
715        path: PathBuf,
716        /// What was wrong with it.
717        detail: String,
718    },
719
720    /// The record is there and **this account** may not read it.
721    ///
722    /// Its own variant rather than a [`ServiceError::Record`] with a
723    /// `Permission denied` in the detail, because it is the one record failure
724    /// that is not a fault in the record: `service status` reports it and
725    /// carries on, where every other read failure ends the command. See
726    /// [`Operations::status`].
727    ///
728    /// A version before this one wrote the record at `0600` through a temporary
729    /// file, so a boot-mode `sudo service install` left it owned by `root` and
730    /// unreadable by the operator whose profile it is in. `service status` then
731    /// failed on their own host, with a remedy naming the command that had just
732    /// failed.
733    #[error(
734        "the service record {} is there and this account may not read it: {detail}. It was \
735         written by whichever account installed the service -- on a boot-mode host, `sudo \
736         service install`. Running `service install` again rewrites it readable; until then \
737         only what the service manager itself reports is available.",
738        path.display()
739    )]
740    RecordNotPermitted {
741        /// The record file.
742        path: PathBuf,
743        /// What the operating system said.
744        detail: String,
745    },
746
747    /// The application-data directories could not be resolved or created.
748    #[error("cannot prepare this host's application-data directories: {source}")]
749    Paths {
750        /// The underlying failure.
751        #[source]
752        source: Box<crate::paths::PathsError>,
753    },
754
755    /// The platform's service manager refused, or could not be reached.
756    #[error("cannot {operation} {name} through {manager}: {detail}")]
757    Control {
758        /// What was being attempted.
759        operation: &'static str,
760        /// Which registration.
761        name: String,
762        /// Which service manager.
763        manager: &'static str,
764        /// What it said.
765        detail: String,
766    },
767
768    /// An operation failed and its compensating action failed too.
769    #[error(
770        "cannot {operation} {name}: {cause}. The attempted rollback also failed: {rollback}. \
771         Inspect `service status` before retrying."
772    )]
773    Rollback {
774        /// The transaction that could not be completed.
775        operation: &'static str,
776        /// Which registration was involved.
777        name: String,
778        /// The original failure.
779        cause: String,
780        /// The failure while restoring the previous state.
781        rollback: String,
782    },
783
784    /// The machine's registration is another installation's (see
785    /// [`ForeignService`]), and changing it from this account would act on it
786    /// with this account's directories.
787    #[error(
788        "this machine's {name} service was installed from {installed_by}, not from this \
789         account's data root, so this account does not {operation} it: doing so would \
790         re-register or remove that installation's service with this account's directories. \
791         Do it {act_as}. If that account no longer exists, remove the registration with the \
792         service manager itself first."
793    )]
794    ForeignInstallation {
795        /// Which registration.
796        name: String,
797        /// What was refused.
798        operation: &'static str,
799        /// [`ForeignService::installed_by`].
800        installed_by: String,
801        /// [`ForeignService::act_as`].
802        act_as: String,
803    },
804
805    /// `host forget-local` found this account's configuration in use, and
806    /// moved nothing.
807    #[error("this account's local configuration is in use, so nothing was archived: {detail}.")]
808    LocalConfigurationInUse {
809        /// What uses it.
810        detail: String,
811    },
812
813    /// The operation needs administrative rights it does not have.
814    #[error("{operation} {name} needs administrative rights: {detail}. {remedy}")]
815    NeedsElevation {
816        /// What was being attempted.
817        operation: &'static str,
818        /// Which registration.
819        name: String,
820        /// What the platform said.
821        detail: String,
822        /// How to get them, in this platform's own terms.
823        remedy: &'static str,
824    },
825}
826
827// ---------------------------------------------------------------------------
828// Directories
829// ---------------------------------------------------------------------------
830
831/// The four directories the registration was installed against.
832///
833/// Recorded rather than re-derived, because `05-infrastructure.md` item 2 speaks
834/// of the *configured* cache and runtime directories: the account the service
835/// runs under and the account that ran `service install` do not always resolve
836/// the same platform-standard locations, and a claim about what the service can
837/// write is only checkable against a specific set of paths.
838#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
839pub struct ServiceDirectories {
840    /// Non-secret TOML and the SQLite database.
841    pub config: PathBuf,
842    /// The agent lock, the attempt journal, and the runner package cache.
843    pub state: PathBuf,
844    /// Per-attempt disposable runner workspaces.
845    pub runtime: PathBuf,
846    /// Rotating redacted diagnostics.
847    pub logs: PathBuf,
848}
849
850impl ServiceDirectories {
851    /// Snapshots the four directories a resolved [`AppPaths`] names.
852    #[must_use]
853    pub fn of(paths: &AppPaths) -> Self {
854        Self {
855            config: paths.config_dir().to_path_buf(),
856            state: paths.state_dir().to_path_buf(),
857            runtime: paths.runtime_dir().to_path_buf(),
858            logs: paths.logs_dir().to_path_buf(),
859        }
860    }
861
862    /// The four, in the order `05-infrastructure.md` lists them.
863    #[must_use]
864    pub fn all(&self) -> [&Path; 4] {
865        [
866            self.config.as_path(),
867            self.state.as_path(),
868            self.runtime.as_path(),
869            self.logs.as_path(),
870        ]
871    }
872
873    /// The rotating diagnostic log's stem, which is what `service status`
874    /// reports and what `05-infrastructure.md` item 4 requires be preserved.
875    #[must_use]
876    pub fn log_file(&self) -> PathBuf {
877        self.logs.join(LOG_FILE_STEM)
878    }
879
880    /// The four as the hidden `daemon run` options a registration passes them
881    /// in, [`SERVICE_DIRECTORY_OPTIONS`] order.
882    #[must_use]
883    pub fn arguments(&self) -> [OsString; 8] {
884        let [config, state, runtime, logs] = SERVICE_DIRECTORY_OPTIONS.map(OsString::from);
885        [
886            config,
887            self.config.clone().into_os_string(),
888            state,
889            self.state.clone().into_os_string(),
890            runtime,
891            self.runtime.clone().into_os_string(),
892            logs,
893            self.logs.clone().into_os_string(),
894        ]
895    }
896
897    /// Reads the four back out of a registration's command line.
898    ///
899    /// `None` unless all four options are there: a registration made before
900    /// they existed starts the daemon against whatever its account's
901    /// platform-standard directories are, and which those are cannot be told
902    /// from here.
903    #[must_use]
904    pub fn from_command_line(command_line: &str) -> Option<Self> {
905        let arguments = split_command_line(command_line);
906        let value = |option: &str| {
907            let at = arguments.iter().position(|argument| argument == option)?;
908            arguments.get(at + 1).map(PathBuf::from)
909        };
910        let [config, state, runtime, logs] = SERVICE_DIRECTORY_OPTIONS;
911        Some(Self {
912            config: value(config)?,
913            state: value(state)?,
914            runtime: value(runtime)?,
915            logs: value(logs)?,
916        })
917    }
918
919    /// The deepest directory all four sit under: what an operator would call
920    /// the installation's data root. The configuration directory when they
921    /// share nothing, and [`crate::paths::SYSTEM_DATA_ROOT`] for the system
922    /// tree, whose logs are under `/var/log` rather than beside the rest.
923    #[must_use]
924    pub fn root(&self) -> PathBuf {
925        if *self == Self::of(&AppPaths::system()) {
926            return PathBuf::from(crate::paths::SYSTEM_DATA_ROOT);
927        }
928        let mut shared: Vec<std::path::Component<'_>> = self.config.components().collect();
929        for other in [&self.state, &self.runtime, &self.logs] {
930            let common = shared
931                .iter()
932                .zip(other.components())
933                .take_while(|(left, right)| **left == *right)
934                .count();
935            shared.truncate(common);
936        }
937        if shared.is_empty() {
938            self.config.clone()
939        } else {
940            shared.iter().collect()
941        }
942    }
943
944    /// The four as an [`AppPaths`], to read the records a daemon running
945    /// against them writes.
946    #[must_use]
947    pub fn paths(&self) -> AppPaths {
948        AppPaths::from_directories(&self.config, &self.state, &self.runtime, &self.logs)
949    }
950}
951
952/// The hidden `daemon run` options that carry the installing account's four
953/// application-data directories across to the service's account, in
954/// [`ServiceDirectories`] order. Written by `service install`, and read back
955/// by [`ServiceDirectories::from_command_line`] to tell whose data root a
956/// registration runs against.
957pub const SERVICE_DIRECTORY_OPTIONS: [&str; 4] = [
958    "--service-config-dir",
959    "--service-state-dir",
960    "--service-runtime-dir",
961    "--service-logs-dir",
962];
963
964// ---------------------------------------------------------------------------
965// The install plan
966// ---------------------------------------------------------------------------
967
968/// What `service install` was asked for, before anything has been resolved.
969///
970/// Separate from [`InstallPlan`] because a request is the operator's words and
971/// a plan is what the host actually supports: the plan carries a *resolved
972/// absolute* binary path, the account the start mode obliges, and the four
973/// directories the registration is installed against, none of which the caller
974/// supplies.
975#[derive(Debug, Clone, PartialEq, Eq)]
976pub struct InstallRequest {
977    start_mode: StartMode,
978    binary: Option<PathBuf>,
979    source_binary: Option<PathBuf>,
980    arguments: Vec<OsString>,
981    restart: RestartPolicy,
982    on_demand: bool,
983}
984
985impl InstallRequest {
986    /// Register the binary that is running this call.
987    #[must_use]
988    pub fn new(start_mode: StartMode) -> Self {
989        Self {
990            start_mode,
991            binary: None,
992            source_binary: None,
993            arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
994            restart: RestartPolicy::default(),
995            on_demand: false,
996        }
997    }
998
999    /// Registers the service but does not ask the manager to start it by
1000    /// itself.
1001    ///
1002    /// Production never uses this: a boot-mode registration that does not start
1003    /// at boot is the failure `service status` reports. **The privileged
1004    /// installer tests use it for every fixture they create**, so that a
1005    /// registration which somehow escaped its cleanup guard cannot start with
1006    /// the owner's machine on the next reboot. A leaked service is bad; a
1007    /// leaked service that runs is worse, and the difference costs one flag.
1008    #[must_use]
1009    pub const fn started_on_demand(mut self) -> Self {
1010        self.on_demand = true;
1011        self
1012    }
1013
1014    /// Register a named binary instead of the running one.
1015    ///
1016    /// The privileged installer tests use this to register a fixture service
1017    /// host. Production does not: `05-infrastructure.md` item 6 is specifically
1018    /// about the path of *the running binary*, and letting an operator name a
1019    /// different one would defeat the stale-path detection it asks for.
1020    #[must_use]
1021    pub fn for_binary(mut self, binary: impl Into<PathBuf>) -> Self {
1022        self.binary = Some(binary.into());
1023        self
1024    }
1025
1026    /// Where the registered binary was copied from.
1027    ///
1028    /// # Why a registration has a source at all
1029    ///
1030    /// A service registered directly against a package manager's own file can
1031    /// never be upgraded on Windows, because the running service holds that
1032    /// file open and `npm i -g` cannot replace it. What it does instead is
1033    /// worse than failing: it rewrites the package metadata, reports success,
1034    /// and leaves the old executable in place — so the operator is told the new
1035    /// version is installed while the old one keeps running. That was observed,
1036    /// twice in a row, before this existed.
1037    ///
1038    /// So the service runs a copy the product owns, and this records where that
1039    /// copy came from. The source is what a package manager updates, what
1040    /// [`inspect_binary`] watches for item 6's stale-path case, and what the
1041    /// daemon compares its own version against to know an upgrade is waiting.
1042    #[must_use]
1043    pub fn copied_from(mut self, source: impl Into<PathBuf>) -> Self {
1044        self.source_binary = Some(source.into());
1045        self
1046    }
1047
1048    /// Replace the registered arguments. Defaults to [`DAEMON_ARGUMENTS`].
1049    #[must_use]
1050    pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1051    where
1052        I: IntoIterator<Item = S>,
1053        S: Into<OsString>,
1054    {
1055        self.arguments = arguments.into_iter().map(Into::into).collect();
1056        self
1057    }
1058
1059    /// Replace the restart-on-failure policy. Defaults to
1060    /// [`RestartPolicy::default`].
1061    #[must_use]
1062    pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1063        self.restart = restart;
1064        self
1065    }
1066
1067    /// The start mode asked for.
1068    #[must_use]
1069    pub const fn start_mode(&self) -> StartMode {
1070        self.start_mode
1071    }
1072}
1073
1074/// Everything a backend needs, resolved and validated.
1075///
1076/// Constructing one is where `05-infrastructure.md` item 6 is actually
1077/// satisfied: the binary path is made absolute and is confirmed to be a file
1078/// *before* anything is registered, so a registration that would fail at boot
1079/// is refused at install time instead.
1080#[derive(Debug, Clone, PartialEq, Eq)]
1081pub struct InstallPlan {
1082    identity: ServiceIdentity,
1083    start_mode: StartMode,
1084    binary: PathBuf,
1085    source_binary: Option<PathBuf>,
1086    arguments: Vec<OsString>,
1087    account: ServiceAccount,
1088    restart: RestartPolicy,
1089    directories: ServiceDirectories,
1090    secret_guard: Option<PathBuf>,
1091    on_demand: bool,
1092}
1093
1094impl InstallPlan {
1095    /// Resolves a request against this host.
1096    ///
1097    /// # Errors
1098    ///
1099    /// [`ServiceError::BinaryPath`] when the running executable cannot be
1100    /// located, and [`ServiceError::BinaryMissing`] when the path that would be
1101    /// registered is not a file.
1102    pub fn resolve(
1103        identity: ServiceIdentity,
1104        request: &InstallRequest,
1105        directories: ServiceDirectories,
1106    ) -> Result<Self, ServiceError> {
1107        let binary = match &request.binary {
1108            Some(named) => absolute(named)?,
1109            None => running_executable()?,
1110        };
1111        if !binary.is_file() {
1112            return Err(ServiceError::BinaryMissing { path: binary });
1113        }
1114        // `d2` publishes the guard file the store lives in so the systemd
1115        // sandbox can admit only its parent for atomic token rotation. A store
1116        // that cannot be resolved is not fatal here; it merely cannot receive
1117        // that narrow writable-path exception.
1118        let secret_guard = crate::secrets::PlatformSecretStore::for_start_mode(request.start_mode)
1119            .ok()
1120            .map(|store| store.guard());
1121        Ok(Self {
1122            identity,
1123            start_mode: request.start_mode,
1124            binary,
1125            source_binary: request.source_binary.clone(),
1126            arguments: request.arguments.clone(),
1127            account: ServiceAccount::for_start_mode(request.start_mode),
1128            restart: request.restart,
1129            directories,
1130            secret_guard,
1131            on_demand: request.on_demand,
1132        })
1133    }
1134
1135    /// Builds a plan from values a caller already holds, without touching the
1136    /// filesystem.
1137    ///
1138    /// This is what makes the renderers testable on a host that has none of the
1139    /// paths involved: a Windows developer can render the systemd unit for
1140    /// `/opt/runner-manager/bin/runner-manager` and assert every line of it.
1141    #[must_use]
1142    pub fn unchecked(
1143        identity: ServiceIdentity,
1144        start_mode: StartMode,
1145        binary: impl Into<PathBuf>,
1146        directories: ServiceDirectories,
1147    ) -> Self {
1148        Self {
1149            identity,
1150            start_mode,
1151            binary: binary.into(),
1152            source_binary: None,
1153            arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
1154            account: ServiceAccount::for_start_mode(start_mode),
1155            restart: RestartPolicy::default(),
1156            directories,
1157            secret_guard: None,
1158            on_demand: false,
1159        }
1160    }
1161
1162    /// Registers without arming the manager's automatic start. See
1163    /// [`InstallRequest::started_on_demand`].
1164    #[must_use]
1165    pub const fn started_on_demand(mut self) -> Self {
1166        self.on_demand = true;
1167        self
1168    }
1169
1170    /// Whether the manager was asked not to start this by itself.
1171    #[must_use]
1172    pub const fn is_on_demand(&self) -> bool {
1173        self.on_demand
1174    }
1175
1176    /// Names the file the secret store lives in, so systemd can permit atomic
1177    /// replacement of it under `ProtectSystem=strict`.
1178    ///
1179    /// [`InstallPlan::resolve`] fills this from `d2`; a test names it directly
1180    /// so that a Windows or macOS CI leg can assert the Linux unit's writable
1181    /// path for a store that platform does not have.
1182    #[must_use]
1183    pub fn with_secret_guard(mut self, guard: impl Into<PathBuf>) -> Self {
1184        self.secret_guard = Some(guard.into());
1185        self
1186    }
1187
1188    /// The file the machine-scoped secret store lives in, when one resolved.
1189    #[must_use]
1190    pub fn secret_guard(&self) -> Option<&Path> {
1191        self.secret_guard.as_deref()
1192    }
1193
1194    /// Overrides the restart policy on an already-built plan.
1195    #[must_use]
1196    pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1197        self.restart = restart;
1198        self
1199    }
1200
1201    /// Overrides the registered arguments on an already-built plan.
1202    #[must_use]
1203    pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1204    where
1205        I: IntoIterator<Item = S>,
1206        S: Into<OsString>,
1207    {
1208        self.arguments = arguments.into_iter().map(Into::into).collect();
1209        self
1210    }
1211
1212    /// What the operating system calls this registration.
1213    #[must_use]
1214    pub const fn identity(&self) -> &ServiceIdentity {
1215        &self.identity
1216    }
1217
1218    /// Boot or login.
1219    #[must_use]
1220    pub const fn start_mode(&self) -> StartMode {
1221        self.start_mode
1222    }
1223
1224    /// The resolved absolute path of the binary to register.
1225    #[must_use]
1226    pub fn binary(&self) -> &Path {
1227        &self.binary
1228    }
1229
1230    /// Where [`Self::binary`] was copied from, when it is a copy.
1231    ///
1232    /// `None` is the legacy layout: the registration names the file a package
1233    /// manager owns, and cannot be upgraded while it runs. See
1234    /// [`InstallRequest::copied_from`].
1235    #[must_use]
1236    pub fn source_binary(&self) -> Option<&Path> {
1237        self.source_binary.as_deref()
1238    }
1239
1240    /// The arguments it is registered with.
1241    #[must_use]
1242    pub fn arguments(&self) -> &[OsString] {
1243        &self.arguments
1244    }
1245
1246    /// The account it runs under.
1247    #[must_use]
1248    pub const fn account(&self) -> &ServiceAccount {
1249        &self.account
1250    }
1251
1252    /// The restart-on-failure policy.
1253    #[must_use]
1254    pub const fn restart(&self) -> RestartPolicy {
1255        self.restart
1256    }
1257
1258    /// The four directories it was installed against.
1259    #[must_use]
1260    pub const fn directories(&self) -> &ServiceDirectories {
1261        &self.directories
1262    }
1263
1264    /// The command line as one string, quoted the way each platform expects.
1265    #[must_use]
1266    pub fn command_line(&self) -> String {
1267        let mut out = quote_argument(&self.binary.to_string_lossy());
1268        for argument in &self.arguments {
1269            out.push(' ');
1270            out.push_str(&quote_argument(&argument.to_string_lossy()));
1271        }
1272        out
1273    }
1274}
1275
1276/// Undoes a runner-root change, and says what could not be undone.
1277///
1278/// `None` when the rollback was complete — either because there was nothing to
1279/// undo, or because the directory this operation created was removed again and
1280/// the descriptor it replaced was written back. `Some` is the "report any
1281/// non-reversible existing directory state explicitly" half of the requirement,
1282/// and the caller folds it into the failure it was already reporting.
1283fn retained_runner_root(change: &RootAccessChange) -> Option<String> {
1284    let reversal = change.revert();
1285    matches!(reversal, Reversal::Retained { .. }).then(|| reversal.to_string())
1286}
1287
1288/// The error a failed step returns once both of its rollbacks have been
1289/// attempted.
1290///
1291/// `retained` is [`retained_runner_root`]'s answer and `rollback` is what
1292/// undoing the *registration* reported — whatever it reports on success, which
1293/// a failure has no use for. Both arrive already evaluated, because each may be
1294/// attempted exactly once and the order is the caller's to choose.
1295///
1296/// A complete rollback leaves `cause` exactly as it was: the operator's problem
1297/// is what failed, not the tidying up afterwards. An incomplete one is promoted
1298/// to [`ServiceError::Rollback`], which is the variant that exists to say "this
1299/// failed *and* something is left behind" — and it names everything that did.
1300fn rolled_back<T>(
1301    retained: Option<String>,
1302    rollback: Result<T, ServiceError>,
1303    operation: &'static str,
1304    identity: &ServiceIdentity,
1305    cause: ServiceError,
1306) -> ServiceError {
1307    let left_behind = match (rollback.err(), retained) {
1308        (Some(rollback), Some(retained)) => Some(format!("{rollback}; {retained}")),
1309        (Some(rollback), None) => Some(rollback.to_string()),
1310        (None, retained) => retained,
1311    };
1312    match left_behind {
1313        Some(rollback) => ServiceError::Rollback {
1314            operation,
1315            name: identity.name().to_string(),
1316            cause: cause.to_string(),
1317            rollback,
1318        },
1319        None => cause,
1320    }
1321}
1322
1323/// [`rolled_back`] for a failure with nothing registered yet, where the runner
1324/// root is the only thing there is to undo.
1325fn undo_runner_root(
1326    change: &RootAccessChange,
1327    operation: &'static str,
1328    identity: &ServiceIdentity,
1329    cause: ServiceError,
1330) -> ServiceError {
1331    rolled_back(
1332        retained_runner_root(change),
1333        Ok(()),
1334        operation,
1335        identity,
1336        cause,
1337    )
1338}
1339
1340/// Makes a path absolute without resolving symlinks.
1341///
1342/// Symbolic links are deliberately left alone. A Homebrew installation puts a
1343/// stable link at `/opt/homebrew/bin/runner-manager` and moves the file it
1344/// points at on every upgrade; recording the link's target would make an
1345/// ordinary `brew upgrade` look exactly like the npm failure this module exists
1346/// to detect, and recording the link records the thing the operator installed.
1347///
1348/// Linux is the one platform where this is not fully in the caller's hands:
1349/// `std::env::current_exe` reads `/proc/self/exe`, which the kernel has already
1350/// resolved. There the recorded path is the real file rather than the shim —
1351/// which is still correct for the npm case, because a real file under a Node
1352/// prefix disappears with the prefix.
1353fn absolute(path: &Path) -> Result<PathBuf, ServiceError> {
1354    std::path::absolute(path).map_err(|error| ServiceError::BinaryPath {
1355        detail: format!("{} could not be made absolute: {error}", path.display()),
1356    })
1357}
1358
1359/// The absolute path of the executable running this call.
1360fn running_executable() -> Result<PathBuf, ServiceError> {
1361    let raw = std::env::current_exe().map_err(|error| ServiceError::BinaryPath {
1362        detail: error.to_string(),
1363    })?;
1364    absolute(&raw)
1365}
1366
1367/// Quotes one command-line argument when it needs it.
1368///
1369/// The rule is Windows', because Windows is the platform where a command line is
1370/// a single string the callee re-splits, and because `windows-service` applies
1371/// exactly this rule when it builds `lpBinaryPathName`. The Unix backends embed
1372/// arguments in a plist array and in a systemd `ExecStart=`, both of which
1373/// accept the same quoting, so one rule serves all three rather than three
1374/// nearly-identical ones.
1375pub(crate) fn quote_argument(argument: &str) -> String {
1376    if !argument.is_empty() && !argument.contains([' ', '"', '\t', '\n']) {
1377        return argument.to_string();
1378    }
1379    let mut out = String::with_capacity(argument.len() + 2);
1380    out.push('"');
1381    let mut backslashes = 0usize;
1382    for c in argument.chars() {
1383        match c {
1384            '\\' => {
1385                backslashes += 1;
1386                out.push('\\');
1387            }
1388            '"' => {
1389                // Every backslash immediately before a quote must be doubled,
1390                // and the quote itself escaped.
1391                for _ in 0..=backslashes {
1392                    out.push('\\');
1393                }
1394                out.push('"');
1395                backslashes = 0;
1396            }
1397            other => {
1398                backslashes = 0;
1399                out.push(other);
1400            }
1401        }
1402    }
1403    // Trailing backslashes would otherwise escape the closing quote.
1404    for _ in 0..backslashes {
1405        out.push('\\');
1406    }
1407    out.push('"');
1408    out
1409}
1410
1411/// Reads the executable back out of a command line quoted by
1412/// [`quote_argument`].
1413///
1414/// `service status` needs this because the Windows Service Control Manager
1415/// stores the binary and its arguments as one string and hands the whole thing
1416/// back from `QueryServiceConfigW`. Comparing a record against a registration
1417/// means splitting that string the same way Windows itself does.
1418///
1419/// Returns `None` for an empty or whitespace-only command line, which is the
1420/// only input with no first argument to find.
1421#[must_use]
1422pub fn executable_from_command_line(command_line: &str) -> Option<PathBuf> {
1423    split_command_line(command_line)
1424        .into_iter()
1425        .next()
1426        .filter(|executable| !executable.is_empty())
1427        .map(PathBuf::from)
1428}
1429
1430/// Splits a command line quoted by [`quote_argument`] back into its
1431/// arguments, by the rule Windows itself applies: whitespace separates
1432/// arguments outside quotes, `2n` backslashes before a quote are `n`
1433/// backslashes and a quote that opens or closes, `2n+1` are `n` backslashes
1434/// and a literal quote, and every other backslash is literal.
1435#[must_use]
1436pub fn split_command_line(command_line: &str) -> Vec<String> {
1437    let mut arguments = Vec::new();
1438    let mut current = String::new();
1439    let mut started = false;
1440    let mut quoted = false;
1441    let mut backslashes = 0usize;
1442    for c in command_line.chars() {
1443        match c {
1444            '\\' => {
1445                backslashes += 1;
1446                started = true;
1447            }
1448            '"' => {
1449                current.extend(std::iter::repeat_n('\\', backslashes / 2));
1450                if backslashes.is_multiple_of(2) {
1451                    quoted = !quoted;
1452                } else {
1453                    current.push('"');
1454                }
1455                backslashes = 0;
1456                started = true;
1457            }
1458            ' ' | '\t' if !quoted => {
1459                current.extend(std::iter::repeat_n('\\', backslashes));
1460                backslashes = 0;
1461                if started {
1462                    arguments.push(std::mem::take(&mut current));
1463                    started = false;
1464                }
1465            }
1466            other => {
1467                current.extend(std::iter::repeat_n('\\', backslashes));
1468                backslashes = 0;
1469                current.push(other);
1470                started = true;
1471            }
1472        }
1473    }
1474    current.extend(std::iter::repeat_n('\\', backslashes));
1475    if started {
1476        arguments.push(current);
1477    }
1478    arguments
1479}
1480
1481// ---------------------------------------------------------------------------
1482// Rendered definitions
1483// ---------------------------------------------------------------------------
1484
1485/// Which of the four things a platform reads.
1486#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1487pub enum DefinitionKind {
1488    /// The parameters `CreateServiceW` is called with, rendered as a canonical
1489    /// descriptor so that they can be reviewed and asserted like the other
1490    /// three. The Service Control Manager has no file.
1491    WindowsService,
1492    /// A Task Scheduler XML document, for `--start-at login` on Windows.
1493    WindowsScheduledTask,
1494    /// A launchd property list — a LaunchDaemon at boot, a LaunchAgent at login.
1495    LaunchdPlist,
1496    /// A systemd unit — a system unit at boot, a user unit at login.
1497    SystemdUnit,
1498}
1499
1500impl DefinitionKind {
1501    /// What to call the thing that reads it, in an operator-facing message.
1502    #[must_use]
1503    pub const fn manager(self) -> &'static str {
1504        match self {
1505            Self::WindowsService => "the Windows Service Control Manager",
1506            Self::WindowsScheduledTask => "Windows Task Scheduler",
1507            Self::LaunchdPlist => "launchd",
1508            Self::SystemdUnit => "systemd",
1509        }
1510    }
1511}
1512
1513impl fmt::Display for DefinitionKind {
1514    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1515        f.write_str(self.manager())
1516    }
1517}
1518
1519/// One platform's definition of the registration, as text.
1520///
1521/// Producing this is pure: no `cfg`, no privileges, no filesystem. That is what
1522/// lets every leg of the CI matrix assert every platform's definition, instead
1523/// of each leg asserting only its own and the other two being reviewed by
1524/// reading.
1525#[derive(Debug, Clone, PartialEq, Eq)]
1526pub struct ServiceDefinition {
1527    kind: DefinitionKind,
1528    text: String,
1529    install_path: Option<PathBuf>,
1530}
1531
1532impl ServiceDefinition {
1533    /// Which platform this is for.
1534    #[must_use]
1535    pub const fn kind(&self) -> DefinitionKind {
1536        self.kind
1537    }
1538
1539    /// The definition itself.
1540    #[must_use]
1541    pub fn text(&self) -> &str {
1542        &self.text
1543    }
1544
1545    /// Where the platform expects the file, when the platform reads a file.
1546    ///
1547    /// `None` for [`DefinitionKind::WindowsService`], whose "definition" is a
1548    /// set of arguments to `CreateServiceW`, and for a Task Scheduler document,
1549    /// which is handed to `schtasks` from a temporary file and is not stored
1550    /// where it was written.
1551    #[must_use]
1552    pub fn install_path(&self) -> Option<&Path> {
1553        self.install_path.as_deref()
1554    }
1555
1556    /// The definition this host's own service manager would be given.
1557    ///
1558    /// The value-level twin of [`host_definition_kind`]. Rendering needs no
1559    /// privileges and no service manager, so anything that wants to *see* what
1560    /// an install would register — `f3`'s `service install --dry-run`, a
1561    /// support bundle, [`RecordingControls`] — can have it without registering
1562    /// anything.
1563    ///
1564    /// # Errors
1565    ///
1566    /// [`ServiceError::Control`] when a Windows logon-triggered registration is
1567    /// asked for and the session reports no account to register it for.
1568    pub fn for_host(plan: &InstallPlan) -> Result<Self, ServiceError> {
1569        Ok(match host_definition_kind(plan.start_mode()) {
1570            DefinitionKind::WindowsService => Self::windows_service(plan),
1571            DefinitionKind::WindowsScheduledTask => {
1572                Self::windows_scheduled_task(plan, &TaskPrincipal::current()?)
1573            }
1574            DefinitionKind::LaunchdPlist => Self::launchd(plan, host_home().as_deref()),
1575            DefinitionKind::SystemdUnit => Self::systemd(plan, host_home().as_deref()),
1576        })
1577    }
1578
1579    /// The Windows service parameters, as a canonical descriptor.
1580    #[must_use]
1581    pub fn windows_service(plan: &InstallPlan) -> Self {
1582        Self {
1583            kind: DefinitionKind::WindowsService,
1584            text: windows_service_descriptor(plan),
1585            install_path: None,
1586        }
1587    }
1588
1589    /// A Task Scheduler document for `--start-at login`.
1590    #[must_use]
1591    pub fn windows_scheduled_task(plan: &InstallPlan, principal: &TaskPrincipal) -> Self {
1592        Self {
1593            kind: DefinitionKind::WindowsScheduledTask,
1594            text: windows_scheduled_task_xml(plan, principal),
1595            install_path: None,
1596        }
1597    }
1598
1599    /// A launchd property list.
1600    ///
1601    /// `home` is only consulted for `--start-at login`, where the file belongs
1602    /// in the operator's own `~/Library/LaunchAgents`.
1603    #[must_use]
1604    pub fn launchd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1605        let file = format!("{}.plist", plan.identity().launchd_label());
1606        let install_path = match plan.start_mode() {
1607            StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
1608            StartMode::Login => home.map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file)),
1609        };
1610        Self {
1611            kind: DefinitionKind::LaunchdPlist,
1612            text: launchd_plist(plan),
1613            install_path,
1614        }
1615    }
1616
1617    /// Wraps text this module did not render.
1618    ///
1619    /// Two callers need it and both matter. A test builds a deliberately
1620    /// widened definition and watches [`review_least_privilege`] reject it —
1621    /// without which the review would be a function nobody had ever seen fail.
1622    /// And `service status` can review the definition **actually on disk**,
1623    /// which on Linux and macOS an operator is free to edit after installation.
1624    #[must_use]
1625    pub fn from_text(kind: DefinitionKind, text: impl Into<String>) -> Self {
1626        Self {
1627            kind,
1628            text: text.into(),
1629            install_path: None,
1630        }
1631    }
1632
1633    /// A systemd unit.
1634    ///
1635    /// `home` is only consulted for `--start-at login`, where the unit belongs
1636    /// in the operator's own `~/.config/systemd/user`.
1637    #[must_use]
1638    pub fn systemd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1639        let file = plan.identity().systemd_unit();
1640        let install_path = match plan.start_mode() {
1641            StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
1642            StartMode::Login => home.map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file)),
1643        };
1644        Self {
1645            kind: DefinitionKind::SystemdUnit,
1646            text: systemd_unit(plan),
1647            install_path,
1648        }
1649    }
1650}
1651
1652/// Where a LaunchDaemon lives.
1653pub const LAUNCH_DAEMONS_DIR: &str = "/Library/LaunchDaemons";
1654/// Where a LaunchAgent lives, under the operator's home directory.
1655pub const LAUNCH_AGENTS_SUBDIR: &str = "Library/LaunchAgents";
1656/// Where a systemd system unit lives.
1657pub const SYSTEMD_SYSTEM_DIR: &str = "/etc/systemd/system";
1658/// Where a systemd user unit lives, under the operator's home directory.
1659pub const SYSTEMD_USER_SUBDIR: &str = ".config/systemd/user";
1660
1661/// The product's own page, cited by every definition so an operator who finds
1662/// one on a host can find out what put it there.
1663const DOCUMENTATION: &str = "https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI";
1664
1665/// How many consecutive failures the platform tolerates before it stops
1666/// retrying.
1667///
1668/// The companion to [`RestartPolicy::reset_after`]: the delay bounds how *fast*
1669/// a restart may come, and this bounds how *many* come before the host gives
1670/// up and leaves the failure visible instead of hiding it behind an endless
1671/// retry.
1672pub const START_LIMIT_BURST: u32 = 5;
1673
1674// -- systemd -----------------------------------------------------------------
1675
1676/// Renders the systemd unit for this plan.
1677///
1678/// # Why the sandbox is this tight, and what it costs
1679///
1680/// `05-infrastructure.md` item 2 ends *"and write its configured cache and
1681/// runtime directories — and no more"*. `ProtectSystem=strict` plus an explicit
1682/// `ReadWritePaths` is what "and no more" means on Linux: everything outside the
1683/// recorded application-data directories and the credential store directory
1684/// is read-only to this unit. The latter must be writable because refresh-token
1685/// rotation atomically replaces the credential file.
1686///
1687/// **The runner inherits it.** The agent spawns the GitHub Actions runner as a
1688/// child, so a workflow running on this host also runs inside this sandbox: it
1689/// cannot write outside those directories and its private `/tmp`, and
1690/// `NoNewPrivileges=yes` means it cannot `sudo`. `07-security.md` assumes a
1691/// hostile workflow may run here, so that is the intended direction — but it is
1692/// a real behavioural limit and `docs/service-account.md` states it where an
1693/// operator will find it.
1694#[must_use]
1695pub fn systemd_unit(plan: &InstallPlan) -> String {
1696    let identity = plan.identity();
1697    let restart = plan.restart();
1698    let directories = plan.directories();
1699
1700    let mut out = String::new();
1701    out.push_str("[Unit]\n");
1702    out.push_str(&format!("Description={}\n", identity.display_name()));
1703    out.push_str(&format!("Documentation={DOCUMENTATION}\n"));
1704    out.push_str("After=network-online.target\n");
1705    out.push_str("Wants=network-online.target\n");
1706    // In `[Unit]` rather than `[Service]`: systemd moved these in v229 and only
1707    // accepts them here without a deprecation warning.
1708    out.push_str(&format!(
1709        "StartLimitIntervalSec={}\n",
1710        restart.reset_after().as_secs()
1711    ));
1712    out.push_str(&format!("StartLimitBurst={START_LIMIT_BURST}\n"));
1713
1714    out.push_str("\n[Service]\n");
1715    out.push_str("Type=simple\n");
1716    // A runner is a child of the daemon. In particular, systemd must not
1717    // signal it merely because a cooperative daemon handover replaces the
1718    // manager process; startup recovery supervises any survivor instead.
1719    out.push_str("KillMode=process\n");
1720    out.push_str(&format!("ExecStart={}\n", plan.command_line()));
1721    out.push_str(&format!(
1722        "WorkingDirectory={}\n",
1723        directories.state.display()
1724    ));
1725    out.push_str(&format!("SyslogIdentifier={identity}\n"));
1726    out.push_str("Restart=on-failure\n");
1727    out.push_str(&format!("RestartSec={}\n", restart.delay().as_secs()));
1728
1729    out.push_str("\n# Least privilege. See docs/service-account.md.\n");
1730    for directive in systemd_hardening(plan.start_mode()) {
1731        out.push_str(directive);
1732        out.push('\n');
1733    }
1734    let quoted = |paths: &[String]| {
1735        paths
1736            .iter()
1737            .map(|path| quote_argument(path))
1738            .collect::<Vec<_>>()
1739            .join(" ")
1740    };
1741    out.push_str(&format!(
1742        "ReadWritePaths={}\n",
1743        quoted(&permitted_paths(DefinitionKind::SystemdUnit, plan))
1744    ));
1745
1746    out.push_str("\n[Install]\n");
1747    out.push_str(match plan.start_mode() {
1748        StartMode::Boot => "WantedBy=multi-user.target\n",
1749        StartMode::Login => "WantedBy=default.target\n",
1750    });
1751    out
1752}
1753
1754/// The hardening directives every unit carries, in the order they are rendered.
1755///
1756/// One list, used by [`systemd_hardening`], so that [`review_least_privilege`]
1757/// is written against the same directives the renderer emits: a directive
1758/// dropped from the unit is one the review then reports as missing, rather
1759/// than one that silently stops being checked.
1760///
1761/// The capability bounding set listed here is a login unit's: empty. A boot
1762/// unit's is [`BOOT_CAPABILITIES`]; see [`systemd_hardening`].
1763///
1764/// # Why not `RestrictSUIDSGID=yes`
1765///
1766/// Its seccomp filter cannot inspect `openat2`'s flags, so it answers every
1767/// `openat2` with `ENOSYS`, expecting a fallback to `openat`. GNU tar 1.35
1768/// extracts through `openat2(..., RESOLVE_BENEATH)` and has none, so every
1769/// extraction in a job failed with "Cannot open: Function not implemented" --
1770/// and the runner unpacks every action it downloads with `tar`. Measured on
1771/// Ubuntu 26.04 with systemd 259. What the directive guarded against is
1772/// covered without it: `NoNewPrivileges=yes` makes a set-user-ID bit inert for
1773/// everything this unit starts, and the only places it can write are `root`'s
1774/// own `0700` directories and its private `/tmp`, which no other account can
1775/// reach to execute a file left there.
1776const SYSTEMD_HARDENING: [&str; 12] = [
1777    "NoNewPrivileges=yes",
1778    "CapabilityBoundingSet=",
1779    "AmbientCapabilities=",
1780    "PrivateTmp=yes",
1781    "PrivateDevices=yes",
1782    "ProtectSystem=strict",
1783    "ProtectKernelTunables=yes",
1784    "ProtectKernelModules=yes",
1785    "ProtectControlGroups=yes",
1786    "RestrictNamespaces=yes",
1787    "RestrictRealtime=yes",
1788    "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1789];
1790
1791/// The capabilities a boot unit's daemon keeps, all for one purpose: running
1792/// its runners as [`crate::runner_account::RUNNER_ACCOUNT`] rather than as
1793/// itself.
1794///
1795/// - `CAP_SETUID`, `CAP_SETGID`: to start a runner as that account.
1796/// - `CAP_KILL`: to signal the runner's processes, which are no longer its own.
1797/// - `CAP_CHOWN`: to hand the runner its attempt directory and caches.
1798/// - `CAP_DAC_OVERRIDE`, `CAP_FOWNER`: to clean up what the runner left, and
1799///   to prune caches, though the runner's account owns those files.
1800///
1801/// None reaches a job. Moving from `root` to another uid clears the permitted
1802/// and effective sets, the ambient set is empty, and `NoNewPrivileges=yes`
1803/// stops a set-user-ID or file-capability binary from granting any back.
1804pub const BOOT_CAPABILITIES: &str = boot_capabilities!();
1805
1806/// The one spelling of [`BOOT_CAPABILITIES`], so that the directive the unit
1807/// carries and the list the review checks it against cannot drift apart.
1808macro_rules! boot_capabilities {
1809    () => {
1810        "CAP_CHOWN CAP_DAC_OVERRIDE CAP_FOWNER CAP_KILL CAP_SETGID CAP_SETUID"
1811    };
1812}
1813use boot_capabilities;
1814
1815/// The hardening directives a unit started in `mode` carries.
1816///
1817/// A boot unit keeps [`BOOT_CAPABILITIES`] for its daemon, and hides every
1818/// home directory behind an empty `tmpfs`: its
1819/// data lives outside them, and a workflow running as `root` has no business
1820/// reading the files there that DAC leaves world-readable. `tmpfs` rather than
1821/// `yes`, because tools such as `git` treat an *unreadable* `$HOME/.gitconfig`
1822/// as fatal and an absent one as nothing.
1823fn systemd_hardening(mode: StartMode) -> Vec<&'static str> {
1824    let mut directives = SYSTEMD_HARDENING.to_vec();
1825    if mode == StartMode::Boot {
1826        if let Some(directive) = directives
1827            .iter_mut()
1828            .find(|directive| **directive == "CapabilityBoundingSet=")
1829        {
1830            *directive = concat!("CapabilityBoundingSet=", boot_capabilities!());
1831        }
1832        directives.push("ProtectHome=tmpfs");
1833    }
1834    directives
1835}
1836
1837/// The trees `ProtectHome=` hides, which a boot unit's directories must
1838/// therefore lie outside.
1839#[cfg(any(test, all(unix, not(target_os = "macos"))))]
1840const SYSTEMD_HOME_ROOTS: [&str; 3] = ["/home", "/root", "/run/user"];
1841
1842/// Renames a non-empty directory at `path` to `<name>.before-move-<stamp>`
1843/// beside it, leaving nothing at `path`.
1844#[cfg(any(all(test, unix), target_os = "linux"))]
1845fn set_aside(path: &Path, stamp: &str) -> std::io::Result<()> {
1846    let occupied = match std::fs::read_dir(path) {
1847        Ok(mut entries) => entries.next().is_some(),
1848        Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
1849        Err(error) => return Err(error),
1850    };
1851    if occupied {
1852        let name = path.file_name().map_or_else(
1853            || std::borrow::Cow::Borrowed("directory"),
1854            |name| name.to_string_lossy(),
1855        );
1856        std::fs::rename(
1857            path,
1858            path.with_file_name(format!("{name}.before-move-{stamp}")),
1859        )?;
1860    }
1861    Ok(())
1862}
1863
1864/// Copies the tree at `from` into `to`, except a top-level entry named `skip`.
1865/// Directories are created `0700`, files keep their mode, and symbolic links
1866/// are copied as links rather than followed.
1867#[cfg(any(all(test, unix), target_os = "linux"))]
1868fn copy_tree(from: &Path, to: &Path, skip: Option<&str>) -> std::io::Result<()> {
1869    use std::os::unix::fs::DirBuilderExt as _;
1870
1871    std::fs::DirBuilder::new()
1872        .recursive(true)
1873        .mode(0o700)
1874        .create(to)?;
1875    for entry in std::fs::read_dir(from)? {
1876        let entry = entry?;
1877        if skip.is_some_and(|skip| entry.file_name() == skip) {
1878            continue;
1879        }
1880        let source = entry.path();
1881        let target = to.join(entry.file_name());
1882        let kind = entry.file_type()?;
1883        if kind.is_symlink() {
1884            std::os::unix::fs::symlink(std::fs::read_link(&source)?, &target)?;
1885        } else if kind.is_dir() {
1886            copy_tree(&source, &target, None)?;
1887        } else {
1888            std::fs::copy(&source, &target)?;
1889        }
1890    }
1891    Ok(())
1892}
1893
1894/// Why a boot unit could not use `path`, owned by `owner`, or `None` when it
1895/// can.
1896///
1897/// A boot unit runs as `root` with no capability at all, so a directory
1898/// another account owns is one it cannot open, and one under a home tree is
1899/// one its `ProtectHome=` hides.
1900#[cfg(any(test, all(unix, not(target_os = "macos"))))]
1901fn boot_directory_refusal(path: &Path, owner: Option<u32>) -> Option<String> {
1902    if let Some(root) = SYSTEMD_HOME_ROOTS
1903        .iter()
1904        .find(|root| path.starts_with(root))
1905    {
1906        return Some(format!(
1907            "{} is under {root}, which a boot service cannot see",
1908            path.display()
1909        ));
1910    }
1911    match owner {
1912        Some(0) => None,
1913        Some(uid) => Some(format!(
1914            "{} belongs to uid {uid}, and a boot service runs as root with no capability \
1915             to open another account's directory",
1916            path.display()
1917        )),
1918        None => Some(format!("{} cannot be inspected", path.display())),
1919    }
1920}
1921
1922// -- launchd -----------------------------------------------------------------
1923
1924/// The `ProcessType` every rendered plist carries.
1925///
1926/// # Why not `Background`
1927///
1928/// launchd applies a job's `ProcessType` to the whole process tree it starts,
1929/// not only to the daemon. Under `Background` every runner the daemon launched
1930/// ran at base priority 4, on efficiency cores with throttled I/O, so each job
1931/// took 4 to 20 times as long as on a classic runner.
1932///
1933/// A child cannot leave it. This is the process type launchd chose at spawn
1934/// (`spawn type = background (5)` in `launchctl print`), not the
1935/// `PRIO_DARWIN_BG` flag `setpriority` controls: measured on macOS 27, a child
1936/// of a `Background` agent reads `getpriority(PRIO_DARWIN_PROCESS) == 0`, and
1937/// `setpriority(PRIO_DARWIN_PROCESS, 0, 0)` returns success and leaves it at
1938/// 4, before `exec` and after it alike. 0.4.31 tried exactly that.
1939///
1940/// The measured alternatives, as the base priority of a runner the agent
1941/// starts: `Background` 4, `Adaptive` 4, `Standard` (the default when the key
1942/// is absent) 20, `Interactive` 31 (the same as a classic runner, and the value
1943/// GitHub's own runner service template uses). The daemon itself mostly waits,
1944/// so it costs nothing to run it at that priority too.
1945pub const LAUNCHD_PROCESS_TYPE: &str = "Interactive";
1946
1947/// Renders the launchd property list for this plan.
1948///
1949/// `KeepAlive` is a dictionary rather than `<true/>` on purpose: the
1950/// requirement is *restart on failure*, and a bare `KeepAlive` also restarts a
1951/// job that exited cleanly, which would turn a deliberate `service stop` into a
1952/// fight with launchd.
1953#[must_use]
1954pub fn launchd_plist(plan: &InstallPlan) -> String {
1955    let identity = plan.identity();
1956    let directories = plan.directories();
1957    let mut out = String::new();
1958    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
1959    out.push_str(
1960        "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
1961         \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
1962    );
1963    out.push_str("<plist version=\"1.0\">\n<dict>\n");
1964    out.push_str(&plist_string("Label", &identity.launchd_label()));
1965
1966    out.push_str("  <key>ProgramArguments</key>\n  <array>\n");
1967    out.push_str(&format!(
1968        "    <string>{}</string>\n",
1969        xml_escape(&plan.binary().to_string_lossy())
1970    ));
1971    for argument in plan.arguments() {
1972        out.push_str(&format!(
1973            "    <string>{}</string>\n",
1974            xml_escape(&argument.to_string_lossy())
1975        ));
1976    }
1977    out.push_str("  </array>\n");
1978
1979    out.push_str("  <key>RunAtLoad</key>\n  <true/>\n");
1980    out.push_str("  <key>KeepAlive</key>\n  <dict>\n");
1981    out.push_str("    <key>SuccessfulExit</key>\n    <false/>\n");
1982    out.push_str("  </dict>\n");
1983    out.push_str(&format!(
1984        "  <key>ThrottleInterval</key>\n  <integer>{}</integer>\n",
1985        plan.restart().delay().as_secs()
1986    ));
1987    out.push_str(&plist_string("ProcessType", LAUNCHD_PROCESS_TYPE));
1988    out.push_str(&plist_string(
1989        "WorkingDirectory",
1990        &directories.state.to_string_lossy(),
1991    ));
1992    out.push_str(&plist_string(
1993        "StandardOutPath",
1994        &directories
1995            .logs
1996            .join("runner-manager.launchd.out.log")
1997            .to_string_lossy(),
1998    ));
1999    out.push_str(&plist_string(
2000        "StandardErrorPath",
2001        &directories
2002            .logs
2003            .join("runner-manager.launchd.err.log")
2004            .to_string_lossy(),
2005    ));
2006
2007    match plan.start_mode() {
2008        StartMode::Boot => {
2009            // A LaunchDaemon runs as root unless told otherwise, and here it
2010            // must: the System Keychain's master key is root-only.
2011            out.push_str(&plist_string(
2012                "UserName",
2013                ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
2014                    .as_str(),
2015            ));
2016            // A daemon has no user session and must not be given one.
2017            out.push_str("  <key>SessionCreate</key>\n  <false/>\n");
2018        }
2019        StartMode::Login => {
2020            // A LaunchAgent already runs as the operator. Naming a `UserName`
2021            // here would be asking launchd for an account switch a login-mode
2022            // registration has no reason to want.
2023        }
2024    }
2025
2026    out.push_str("</dict>\n</plist>\n");
2027    out
2028}
2029
2030/// One `<key>`/`<string>` pair, indented as the rest of the plist is.
2031fn plist_string(key: &str, value: &str) -> String {
2032    format!(
2033        "  <key>{}</key>\n  <string>{}</string>\n",
2034        xml_escape(key),
2035        xml_escape(value)
2036    )
2037}
2038
2039// -- Windows Task Scheduler --------------------------------------------------
2040
2041/// The account a Task Scheduler task runs as.
2042///
2043/// Task Scheduler, unlike the other three managers, needs the principal spelled
2044/// out in the document. Isolating the one impure step keeps
2045/// [`windows_scheduled_task_xml`] a pure function that every CI leg can assert.
2046#[derive(Debug, Clone, PartialEq, Eq)]
2047pub struct TaskPrincipal {
2048    user_id: String,
2049}
2050
2051impl TaskPrincipal {
2052    /// The account running this call, as `DOMAIN\user`.
2053    ///
2054    /// # Errors
2055    ///
2056    /// [`ServiceError::BinaryPath`] is not the right shape here, so this
2057    /// reports [`ServiceError::Control`] naming what the environment failed to
2058    /// say.
2059    pub fn current() -> Result<Self, ServiceError> {
2060        let user = std::env::var("USERNAME")
2061            .ok()
2062            .filter(|value| !value.trim().is_empty());
2063        let Some(user) = user else {
2064            return Err(ServiceError::Control {
2065                operation: "identify the account for",
2066                name: SERVICE_NAME.to_string(),
2067                manager: "Windows Task Scheduler",
2068                detail: "this session reports no %USERNAME%, so there is no principal to \
2069                         register a logon-triggered task for"
2070                    .to_string(),
2071            });
2072        };
2073        let domain = std::env::var("USERDOMAIN")
2074            .ok()
2075            .filter(|value| !value.trim().is_empty());
2076        Ok(Self {
2077            user_id: match domain {
2078                Some(domain) => format!("{domain}\\{user}"),
2079                None => user,
2080            },
2081        })
2082    }
2083
2084    /// A named principal, for tests and for a caller that already knows.
2085    #[must_use]
2086    pub fn named(user_id: impl Into<String>) -> Self {
2087        Self {
2088            user_id: user_id.into(),
2089        }
2090    }
2091
2092    /// `DOMAIN\user`.
2093    #[must_use]
2094    pub fn user_id(&self) -> &str {
2095        &self.user_id
2096    }
2097}
2098
2099/// Renders the Task Scheduler document for a `--start-at login` registration.
2100///
2101/// `RunLevel` is `LeastPrivilege`, which is the whole of Windows' answer to
2102/// item 2 in this domain: the task runs with the operator's ordinary filtered
2103/// token and never with an elevated one, whatever the operator's group
2104/// membership.
2105#[must_use]
2106pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
2107    let identity = plan.identity();
2108    let user = xml_escape(principal.user_id());
2109    let arguments = plan
2110        .arguments()
2111        .iter()
2112        .map(|argument| quote_argument(&argument.to_string_lossy()))
2113        .collect::<Vec<_>>()
2114        .join(" ");
2115    let mut out = String::new();
2116    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
2117    out.push_str(
2118        "<Task version=\"1.4\" \
2119         xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
2120    );
2121    out.push_str("  <RegistrationInfo>\n");
2122    out.push_str(&format!(
2123        "    <Description>{}</Description>\n",
2124        xml_escape(identity.description())
2125    ));
2126    out.push_str(&format!(
2127        "    <URI>\\{}</URI>\n",
2128        xml_escape(identity.name())
2129    ));
2130    out.push_str("  </RegistrationInfo>\n");
2131
2132    out.push_str("  <Triggers>\n    <LogonTrigger>\n");
2133    out.push_str("      <Enabled>true</Enabled>\n");
2134    out.push_str(&format!("      <UserId>{user}</UserId>\n"));
2135    out.push_str("    </LogonTrigger>\n  </Triggers>\n");
2136
2137    out.push_str("  <Principals>\n    <Principal id=\"Author\">\n");
2138    out.push_str(&format!("      <UserId>{user}</UserId>\n"));
2139    out.push_str("      <LogonType>InteractiveToken</LogonType>\n");
2140    out.push_str("      <RunLevel>LeastPrivilege</RunLevel>\n");
2141    out.push_str("    </Principal>\n  </Principals>\n");
2142
2143    out.push_str("  <Settings>\n");
2144    // One agent per host is `d1`'s lock; saying so here means Task Scheduler
2145    // refuses the second start rather than starting a process that then loses
2146    // the race and exits.
2147    out.push_str("    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
2148    out.push_str("    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
2149    out.push_str("    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
2150    out.push_str("    <AllowHardTerminate>true</AllowHardTerminate>\n");
2151    out.push_str("    <StartWhenAvailable>true</StartWhenAvailable>\n");
2152    out.push_str("    <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
2153    out.push_str("    <IdleSettings>\n");
2154    out.push_str("      <StopOnIdleEnd>false</StopOnIdleEnd>\n");
2155    out.push_str("      <RestartOnIdle>false</RestartOnIdle>\n");
2156    out.push_str("    </IdleSettings>\n");
2157    out.push_str("    <AllowStartOnDemand>true</AllowStartOnDemand>\n");
2158    out.push_str("    <Enabled>true</Enabled>\n");
2159    out.push_str("    <Hidden>false</Hidden>\n");
2160    out.push_str("    <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
2161    out.push_str("    <WakeToRun>false</WakeToRun>\n");
2162    // A daemon has no natural end, so any limit here would be a scheduled kill.
2163    out.push_str("    <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
2164    out.push_str("    <Priority>7</Priority>\n");
2165    out.push_str("    <RestartOnFailure>\n");
2166    out.push_str(&format!(
2167        "      <Interval>{}</Interval>\n",
2168        iso8601_minutes(
2169            plan.restart()
2170                .effective_delay(DefinitionKind::WindowsScheduledTask)
2171        )
2172    ));
2173    out.push_str(&format!("      <Count>{START_LIMIT_BURST}</Count>\n"));
2174    out.push_str("    </RestartOnFailure>\n");
2175    out.push_str("  </Settings>\n");
2176
2177    out.push_str("  <Actions Context=\"Author\">\n    <Exec>\n");
2178    out.push_str(&format!(
2179        "      <Command>{}</Command>\n",
2180        xml_escape(&plan.binary().to_string_lossy())
2181    ));
2182    if !arguments.is_empty() {
2183        out.push_str(&format!(
2184            "      <Arguments>{}</Arguments>\n",
2185            xml_escape(&arguments)
2186        ));
2187    }
2188    out.push_str(&format!(
2189        "      <WorkingDirectory>{}</WorkingDirectory>\n",
2190        xml_escape(&plan.directories().state.to_string_lossy())
2191    ));
2192    out.push_str("    </Exec>\n  </Actions>\n");
2193    out.push_str("</Task>\n");
2194    out
2195}
2196
2197/// `PT<n>M`, which is the only shape Task Scheduler accepts for a restart
2198/// interval. See [`RestartPolicy::effective_delay`].
2199fn iso8601_minutes(duration: Duration) -> String {
2200    format!("PT{}M", duration.as_secs() / 60)
2201}
2202
2203/// Escapes the five XML entities. Applied to every value that reaches a plist
2204/// or a task document, because a Windows account name may legitimately contain
2205/// `&` and a path may contain `<`.
2206///
2207/// `pub(crate)` rather than private because [`crate::wsl::task`] renders a
2208/// second kind of Task Scheduler document and must escape it the same way; two
2209/// escapers in one crate is exactly how one of them ends up subtly different.
2210pub(crate) fn xml_escape(value: &str) -> String {
2211    let mut out = String::with_capacity(value.len());
2212    for c in value.chars() {
2213        match c {
2214            '&' => out.push_str("&amp;"),
2215            '<' => out.push_str("&lt;"),
2216            '>' => out.push_str("&gt;"),
2217            '"' => out.push_str("&quot;"),
2218            '\'' => out.push_str("&apos;"),
2219            other => out.push(other),
2220        }
2221    }
2222    out
2223}
2224
2225/// The inverse of [`xml_escape`], for reading a definition back off disk.
2226///
2227/// `&amp;` is replaced last, which is the whole subtlety: replacing it first
2228/// would turn a literal `&amp;amp;` into `&` in two passes instead of `&amp;`
2229/// in one.
2230fn xml_unescape(value: &str) -> String {
2231    value
2232        .replace("&lt;", "<")
2233        .replace("&gt;", ">")
2234        .replace("&quot;", "\"")
2235        .replace("&apos;", "'")
2236        .replace("&amp;", "&")
2237}
2238
2239// -- Windows Service Control Manager -----------------------------------------
2240
2241/// How `CreateServiceW` should be called, in a form that can be reviewed,
2242/// asserted, and printed on any platform.
2243///
2244/// The Windows backend builds its `ServiceInfo` from **this** value rather than
2245/// from the plan, so the descriptor a Linux CI leg asserts and the parameters a
2246/// Windows host registers cannot drift apart.
2247#[derive(Debug, Clone, PartialEq, Eq)]
2248pub struct WindowsServiceSpec {
2249    /// The service name.
2250    pub name: String,
2251    /// What `services.msc` shows.
2252    pub display_name: String,
2253    /// The one-line description.
2254    pub description: String,
2255    /// `true` for `SERVICE_AUTO_START`, `false` for `SERVICE_DEMAND_START`.
2256    pub automatic_start: bool,
2257    /// The account, or `None` for `LocalSystem`, which is what
2258    /// `CreateServiceW` takes a null `lpServiceStartName` to mean.
2259    pub account: Option<String>,
2260    /// The full launch command, quoted.
2261    pub command_line: String,
2262    /// The restart-on-failure policy.
2263    pub restart: RestartPolicy,
2264}
2265
2266/// Derives the Windows service parameters from a plan.
2267#[must_use]
2268pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
2269    WindowsServiceSpec {
2270        name: plan.identity().name().to_string(),
2271        display_name: plan.identity().display_name().to_string(),
2272        description: plan.identity().description().to_string(),
2273        // Only a boot-mode registration is a service at all; a login-mode one
2274        // is a scheduled task. So an automatic start is the only kind here, and
2275        // the field exists because the privileged tests register an on-demand
2276        // fixture rather than one that starts with the test machine.
2277        automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
2278        account: match ServiceAccount::for_definition(
2279            DefinitionKind::WindowsService,
2280            plan.start_mode(),
2281        ) {
2282            // `None` means LocalSystem to `CreateServiceW`, and naming it
2283            // explicitly would be one more string to spell right.
2284            ServiceAccount::LocalSystem => None,
2285            other => Some(other.as_str().to_string()),
2286        },
2287        command_line: plan.command_line(),
2288        restart: plan.restart(),
2289    }
2290}
2291
2292/// Renders the descriptor [`review_least_privilege`] reads and `service status`
2293/// can print.
2294#[must_use]
2295fn windows_service_descriptor(plan: &InstallPlan) -> String {
2296    let spec = windows_service_spec(plan);
2297    let mut out = String::new();
2298    out.push_str("[windows-service]\n");
2299    out.push_str(&format!("Name={}\n", spec.name));
2300    out.push_str(&format!("DisplayName={}\n", spec.display_name));
2301    out.push_str(&format!("Description={}\n", spec.description));
2302    // OWN_PROCESS and never INTERACTIVE_PROCESS: an interactive service would
2303    // put a process this product controls on the operator's desktop, which is
2304    // both deprecated by Windows and more than the requirement asks for.
2305    out.push_str("ServiceType=OWN_PROCESS\n");
2306    out.push_str(&format!(
2307        "StartType={}\n",
2308        if spec.automatic_start {
2309            "AutoStart"
2310        } else {
2311            "OnDemand"
2312        }
2313    ));
2314    out.push_str("ErrorControl=Normal\n");
2315    out.push_str(&format!(
2316        "Account={}\n",
2317        spec.account
2318            .as_deref()
2319            .unwrap_or(ServiceAccount::LocalSystem.as_str())
2320    ));
2321    out.push_str(&format!("CommandLine={}\n", spec.command_line));
2322    out.push_str(&format!(
2323        "FailureActionRestartDelaySecs={}\n",
2324        spec.restart.delay().as_secs()
2325    ));
2326    out.push_str(&format!(
2327        "FailureActionsResetPeriodSecs={}\n",
2328        spec.restart.reset_after().as_secs()
2329    ));
2330    // Without this flag the Service Control Manager applies failure actions
2331    // only to a crash, and a daemon that exits non-zero after failing to reach
2332    // GitHub is not a crash.
2333    out.push_str("FailureActionsOnNonCrashFailures=true\n");
2334    out.push_str(&format!(
2335        "ReadWritePaths={}\n",
2336        plan.directories()
2337            .all()
2338            .iter()
2339            .map(|path| quote_argument(&path.to_string_lossy()))
2340            .collect::<Vec<_>>()
2341            .join(" ")
2342    ));
2343    out
2344}
2345
2346// ---------------------------------------------------------------------------
2347// The least-privilege review
2348// ---------------------------------------------------------------------------
2349
2350/// Whether a finding is about too much authority or too little.
2351#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2352pub enum FindingKind {
2353    /// The definition grants more than `05-infrastructure.md` item 2 asks for.
2354    /// This is what makes a review fail.
2355    Excess,
2356    /// The definition does not grant something the daemon needs, or does not
2357    /// state something the review cannot verify without.
2358    Shortfall,
2359}
2360
2361impl fmt::Display for FindingKind {
2362    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2363        f.write_str(match self {
2364            Self::Excess => "excess",
2365            Self::Shortfall => "shortfall",
2366        })
2367    }
2368}
2369
2370/// One thing the review has to say about a definition.
2371#[derive(Debug, Clone, PartialEq, Eq)]
2372pub struct PrivilegeFinding {
2373    /// Excess or shortfall.
2374    pub kind: FindingKind,
2375    /// The directive, key, or element it is about.
2376    pub subject: String,
2377    /// What is wrong with it, in terms an operator can act on.
2378    pub detail: String,
2379}
2380
2381impl fmt::Display for PrivilegeFinding {
2382    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2383        write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2384    }
2385}
2386
2387/// What a definition actually grants, measured against the requirement.
2388///
2389/// `05-infrastructure.md` item 2 and `07-security.md`'s release gate
2390/// (*"Service account permissions are documented and verified least
2391/// privilege"*) are the same requirement stated twice, and this is the
2392/// verification half. The documentation half is `docs/service-account.md`.
2393///
2394/// The review reads the **rendered text**, not the plan it came from. That is
2395/// deliberate and it is the only version of this check worth having: a review
2396/// that re-derived its expectations from the same values the renderer used
2397/// would agree with the renderer by construction and could never fail. Reading
2398/// the text means a hand-edited unit on a real host is reviewed as it is, and
2399/// means a test can widen a definition and watch this say so.
2400#[derive(Debug, Clone, PartialEq, Eq)]
2401pub struct PrivilegeReview {
2402    kind: DefinitionKind,
2403    account: ServiceAccount,
2404    controls: Vec<String>,
2405    findings: Vec<PrivilegeFinding>,
2406}
2407
2408impl PrivilegeReview {
2409    /// Whether the definition grants nothing beyond the requirement.
2410    #[must_use]
2411    pub fn is_least_privilege(&self) -> bool {
2412        !self
2413            .findings
2414            .iter()
2415            .any(|finding| finding.kind == FindingKind::Excess)
2416    }
2417
2418    /// Everything the review found, excesses and shortfalls together.
2419    #[must_use]
2420    pub fn findings(&self) -> &[PrivilegeFinding] {
2421        &self.findings
2422    }
2423
2424    /// Only the excesses — the findings that make [`Self::is_least_privilege`]
2425    /// false.
2426    #[must_use]
2427    pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2428        self.findings
2429            .iter()
2430            .filter(|finding| finding.kind == FindingKind::Excess)
2431            .collect()
2432    }
2433
2434    /// The controls the definition was confirmed to carry.
2435    ///
2436    /// Present so that a passing review says *what it checked* rather than only
2437    /// that it passed. A check that reports nothing when it succeeds is
2438    /// indistinguishable from a check that did not run.
2439    #[must_use]
2440    pub fn controls(&self) -> &[String] {
2441        &self.controls
2442    }
2443
2444    /// The account the registration runs under, and why it is the minimum.
2445    #[must_use]
2446    pub const fn account(&self) -> &ServiceAccount {
2447        &self.account
2448    }
2449
2450    /// Which platform's definition was reviewed.
2451    #[must_use]
2452    pub const fn kind(&self) -> DefinitionKind {
2453        self.kind
2454    }
2455}
2456
2457impl fmt::Display for PrivilegeReview {
2458    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2459        writeln!(
2460            f,
2461            "{} runs as {} ({})",
2462            self.kind,
2463            self.account,
2464            self.account.justification()
2465        )?;
2466        for control in &self.controls {
2467            writeln!(f, "  confirmed  {control}")?;
2468        }
2469        for finding in &self.findings {
2470            writeln!(f, "  {finding}")?;
2471        }
2472        if self.is_least_privilege() {
2473            write!(f, "  verdict    least privilege")
2474        } else {
2475            write!(
2476                f,
2477                "  verdict    NOT least privilege: {} excess(es)",
2478                self.excesses().len()
2479            )
2480        }
2481    }
2482}
2483
2484/// Reads a definition back and reports what it grants.
2485///
2486/// `plan` supplies only the two things the text cannot: the four directories
2487/// the registration is *allowed* to write, and the start mode, which decides
2488/// which account is the minimum.
2489#[must_use]
2490pub fn review_least_privilege(
2491    definition: &ServiceDefinition,
2492    plan: &InstallPlan,
2493) -> PrivilegeReview {
2494    let mut controls = Vec::new();
2495    let mut findings = Vec::new();
2496    match definition.kind() {
2497        DefinitionKind::SystemdUnit => {
2498            review_systemd(definition.text(), plan, &mut controls, &mut findings);
2499        }
2500        DefinitionKind::LaunchdPlist => {
2501            review_launchd(definition.text(), plan, &mut controls, &mut findings);
2502        }
2503        DefinitionKind::WindowsScheduledTask => {
2504            review_scheduled_task(definition.text(), &mut controls, &mut findings);
2505        }
2506        DefinitionKind::WindowsService => {
2507            review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2508        }
2509    }
2510    PrivilegeReview {
2511        kind: definition.kind(),
2512        // The definition's account, not the plan's: a Linux CI leg reviewing the
2513        // Windows descriptor must report `LocalSystem`, which is the account
2514        // that descriptor registers, and not `root`, which is the account this
2515        // leg's own host would use.
2516        account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2517        controls,
2518        findings,
2519    }
2520}
2521
2522/// Every writable path a definition may name, as the strings it names them by.
2523fn permitted_paths(kind: DefinitionKind, plan: &InstallPlan) -> Vec<String> {
2524    let mut permitted = plan
2525        .directories()
2526        .all()
2527        .iter()
2528        .map(|path| path.to_string_lossy().into_owned())
2529        .collect::<Vec<_>>();
2530    if kind == DefinitionKind::SystemdUnit
2531        && let Some(secret_directory) = plan.secret_guard().and_then(Path::parent)
2532    {
2533        permitted.push(secret_directory.to_string_lossy().into_owned());
2534    }
2535    permitted
2536}
2537
2538/// Whether two path spellings name the same place on **this host's**
2539/// filesystem. Case-insensitive on Windows, exact elsewhere.
2540///
2541/// For a rendered definition use [`same_path_for`] instead: which comparison is
2542/// right there follows the platform the definition is *for*, and every leg of
2543/// the CI matrix reviews all four.
2544fn same_path_text(left: &str, right: &str) -> bool {
2545    if cfg!(windows) {
2546        left.eq_ignore_ascii_case(right)
2547    } else {
2548        left == right
2549    }
2550}
2551
2552/// Whether two path spellings name the same place on the platform a given
2553/// definition targets.
2554fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
2555    match kind {
2556        DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
2557            left.eq_ignore_ascii_case(right)
2558        }
2559        DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
2560    }
2561}
2562
2563/// Checks a `ReadWritePaths`-style list against the permitted directories.
2564fn review_writable_paths(
2565    kind: DefinitionKind,
2566    subject: &str,
2567    listed: &[String],
2568    plan: &InstallPlan,
2569    controls: &mut Vec<String>,
2570    findings: &mut Vec<PrivilegeFinding>,
2571) {
2572    let permitted = permitted_paths(kind, plan);
2573    for entry in listed {
2574        if !permitted
2575            .iter()
2576            .any(|allowed| same_path_for(kind, allowed, entry))
2577        {
2578            findings.push(PrivilegeFinding {
2579                kind: FindingKind::Excess,
2580                subject: subject.to_string(),
2581                detail: format!(
2582                    "{entry} is writable but is not one of this registration's required paths"
2583                ),
2584            });
2585        }
2586    }
2587    for allowed in &permitted {
2588        if !listed
2589            .iter()
2590            .any(|entry| same_path_for(kind, allowed, entry))
2591        {
2592            findings.push(PrivilegeFinding {
2593                kind: FindingKind::Shortfall,
2594                subject: subject.to_string(),
2595                detail: format!(
2596                    "{allowed} is one of this registration's directories but is not writable, \
2597                     so the daemon cannot use it"
2598                ),
2599            });
2600        }
2601    }
2602    if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
2603        controls.push(format!(
2604            "{subject} names exactly the required application-data and credential paths"
2605        ));
2606    }
2607}
2608
2609/// Compares a unit's capability bounding set with the one it should keep, as
2610/// sets: a capability it lacks is a shortfall, one it adds is an excess.
2611fn review_capabilities(
2612    expected: &str,
2613    wanted: &str,
2614    actual: &str,
2615    controls: &mut Vec<String>,
2616    findings: &mut Vec<PrivilegeFinding>,
2617) {
2618    let subject = "CapabilityBoundingSet";
2619    // `~` inverts the list, keeping every capability *except* those named.
2620    if actual.trim_start().starts_with('~') {
2621        findings.push(PrivilegeFinding {
2622            kind: FindingKind::Excess,
2623            subject: subject.to_string(),
2624            detail: format!("is `{actual}`, which keeps every capability it does not name"),
2625        });
2626        return;
2627    }
2628    let wanted: BTreeSet<&str> = wanted.split_whitespace().collect();
2629    let actual: BTreeSet<&str> = actual.split_whitespace().collect();
2630    let extra = actual.difference(&wanted).copied().collect::<Vec<_>>();
2631    let missing = wanted.difference(&actual).copied().collect::<Vec<_>>();
2632    if !extra.is_empty() {
2633        findings.push(PrivilegeFinding {
2634            kind: FindingKind::Excess,
2635            subject: subject.to_string(),
2636            detail: format!(
2637                "keeps {}, which the requirement does not ask for",
2638                extra.join(" ")
2639            ),
2640        });
2641    }
2642    if !missing.is_empty() {
2643        findings.push(PrivilegeFinding {
2644            kind: FindingKind::Shortfall,
2645            subject: subject.to_string(),
2646            detail: format!("drops {}, which the daemon needs", missing.join(" ")),
2647        });
2648    }
2649    if extra.is_empty() && missing.is_empty() {
2650        controls.push(expected.to_string());
2651    }
2652}
2653
2654/// The one inbound-surface rule, applied to whichever platform names it.
2655///
2656/// `07-security.md` handling rule 2: *"The product exposes no inbound HTTP,
2657/// socket, or RPC surface anywhere, in any command."* A definition that asks the
2658/// service manager to open a socket or publish a Mach service on the daemon's
2659/// behalf would create exactly that surface without a line of product code
2660/// changing, which is why it is checked here and not only in review.
2661fn review_inbound_surface(
2662    text: &str,
2663    markers: &[(&str, &str)],
2664    controls: &mut Vec<String>,
2665    findings: &mut Vec<PrivilegeFinding>,
2666) {
2667    let mut clean = true;
2668    for (marker, detail) in markers {
2669        if text.contains(marker) {
2670            clean = false;
2671            findings.push(PrivilegeFinding {
2672                kind: FindingKind::Excess,
2673                subject: (*marker).to_string(),
2674                detail: (*detail).to_string(),
2675            });
2676        }
2677    }
2678    if clean {
2679        controls.push(
2680            "no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
2681        );
2682    }
2683}
2684
2685fn review_systemd(
2686    text: &str,
2687    plan: &InstallPlan,
2688    controls: &mut Vec<String>,
2689    findings: &mut Vec<PrivilegeFinding>,
2690) {
2691    let directives = ini_directives(text, "Service");
2692    for expected in systemd_hardening(plan.start_mode()) {
2693        let (key, value) = expected
2694            .split_once('=')
2695            .expect("every hardening directive is written as key=value");
2696        match directives.get(key) {
2697            Some(actual) if key == "CapabilityBoundingSet" => {
2698                review_capabilities(expected, value, actual, controls, findings);
2699            }
2700            Some(actual) if actual == value => controls.push((*expected).to_string()),
2701            Some(actual) => findings.push(PrivilegeFinding {
2702                kind: FindingKind::Excess,
2703                subject: key.to_string(),
2704                detail: format!(
2705                    "is `{actual}`, not `{value}`, so the unit keeps authority the \
2706                                 requirement does not ask for"
2707                ),
2708            }),
2709            None => findings.push(PrivilegeFinding {
2710                kind: FindingKind::Excess,
2711                subject: key.to_string(),
2712                detail: format!(
2713                    "is absent, so the unit inherits systemd's default rather than `{value}`"
2714                ),
2715            }),
2716        }
2717    }
2718
2719    match directives.get("ReadWritePaths") {
2720        Some(value) => {
2721            let listed = split_quoted(value);
2722            review_writable_paths(
2723                DefinitionKind::SystemdUnit,
2724                "ReadWritePaths",
2725                &listed,
2726                plan,
2727                controls,
2728                findings,
2729            );
2730        }
2731        None => findings.push(PrivilegeFinding {
2732            kind: FindingKind::Shortfall,
2733            subject: "ReadWritePaths".to_string(),
2734            detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
2735                .to_string(),
2736        }),
2737    }
2738
2739    // `User=` on a system unit is an escalation only in the other direction, so
2740    // what is checked is the pair systemd actually treats as privileged.
2741    if directives.contains_key("PrivateUsers")
2742        && directives.get("PrivateUsers") == Some(&"no".to_string())
2743    {
2744        findings.push(PrivilegeFinding {
2745            kind: FindingKind::Excess,
2746            subject: "PrivateUsers".to_string(),
2747            detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
2748                .to_string(),
2749        });
2750    }
2751
2752    review_inbound_surface(
2753        text,
2754        &[
2755            (
2756                "ListenStream=",
2757                "asks systemd to open a listening socket for this service, which \
2758                 07-security.md rule 2 forbids the product to have",
2759            ),
2760            (
2761                "ListenDatagram=",
2762                "asks systemd to open a listening socket for this service, which \
2763                 07-security.md rule 2 forbids the product to have",
2764            ),
2765        ],
2766        controls,
2767        findings,
2768    );
2769}
2770
2771fn review_launchd(
2772    text: &str,
2773    plan: &InstallPlan,
2774    controls: &mut Vec<String>,
2775    findings: &mut Vec<PrivilegeFinding>,
2776) {
2777    // A scheduling class is not an authority, so no value here is an excess.
2778    // Anything but `Interactive` is a shortfall: launchd applies it to every
2779    // runner the daemon starts. See `LAUNCHD_PROCESS_TYPE`.
2780    match plist_string_value(text, "ProcessType") {
2781        Some(value) if value == LAUNCHD_PROCESS_TYPE => {
2782            controls.push(format!("ProcessType={LAUNCHD_PROCESS_TYPE}"));
2783        }
2784        found => findings.push(PrivilegeFinding {
2785            kind: FindingKind::Shortfall,
2786            subject: "ProcessType".to_string(),
2787            detail: format!(
2788                "is {}, and launchd applies it to every runner the daemon starts, so their jobs \
2789                 run below normal priority. The daemon rewrites it to `{LAUNCHD_PROCESS_TYPE}` \
2790                 when it next starts with no runner; `service status` says how to restart it",
2791                found.map_or_else(
2792                    || "absent (launchd's `Standard`)".to_string(),
2793                    |value| format!("`{value}`")
2794                )
2795            ),
2796        }),
2797    }
2798
2799    match plan.start_mode() {
2800        StartMode::Boot => {
2801            if plist_bool_value(text, "SessionCreate") == Some(true) {
2802                findings.push(PrivilegeFinding {
2803                    kind: FindingKind::Excess,
2804                    subject: "SessionCreate".to_string(),
2805                    detail: "asks launchd to create a security session for a job that runs \
2806                             outside every login session and has no use for one"
2807                        .to_string(),
2808                });
2809            } else {
2810                controls.push("SessionCreate is not requested".to_string());
2811            }
2812            match plist_string_value(text, "UserName").as_deref() {
2813                Some("root") => {
2814                    controls.push("UserName=root, stated rather than inherited".to_string())
2815                }
2816                Some(other) => findings.push(PrivilegeFinding {
2817                    kind: FindingKind::Shortfall,
2818                    subject: "UserName".to_string(),
2819                    detail: format!(
2820                        "is `{other}`, which cannot unlock the System Keychain: \
2821                         /var/db/SystemKey is root-only, so the daemon would start and then \
2822                         find no credential"
2823                    ),
2824                }),
2825                None => findings.push(PrivilegeFinding {
2826                    kind: FindingKind::Shortfall,
2827                    subject: "UserName".to_string(),
2828                    detail: "is absent, so the account is launchd's implicit default and this \
2829                             review cannot confirm it"
2830                        .to_string(),
2831                }),
2832            }
2833        }
2834        StartMode::Login => {
2835            if let Some(named) = plist_string_value(text, "UserName") {
2836                findings.push(PrivilegeFinding {
2837                    kind: FindingKind::Excess,
2838                    subject: "UserName".to_string(),
2839                    detail: format!(
2840                        "names `{named}` in a LaunchAgent, which already runs as the operator; \
2841                         naming an account here asks launchd for a switch a login-mode \
2842                         registration has no reason to want"
2843                    ),
2844                });
2845            } else {
2846                controls
2847                    .push("no UserName: the agent runs as the operator and no other".to_string());
2848            }
2849        }
2850    }
2851
2852    review_inbound_surface(
2853        text,
2854        &[
2855            (
2856                "<key>Sockets</key>",
2857                "asks launchd to open a socket for this job, which 07-security.md rule 2 \
2858                 forbids the product to have",
2859            ),
2860            (
2861                "<key>MachServices</key>",
2862                "publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
2863                 forbids the product to have",
2864            ),
2865        ],
2866        controls,
2867        findings,
2868    );
2869}
2870
2871fn review_scheduled_task(
2872    text: &str,
2873    controls: &mut Vec<String>,
2874    findings: &mut Vec<PrivilegeFinding>,
2875) {
2876    match xml_value(text, "RunLevel").as_deref() {
2877        Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
2878        Some(other) => findings.push(PrivilegeFinding {
2879            kind: FindingKind::Excess,
2880            subject: "RunLevel".to_string(),
2881            detail: format!(
2882                "is `{other}`, so the task runs with an elevated token whenever the operator is \
2883                 an administrator"
2884            ),
2885        }),
2886        None => findings.push(PrivilegeFinding {
2887            kind: FindingKind::Excess,
2888            subject: "RunLevel".to_string(),
2889            detail: "is absent, so Task Scheduler decides the token rather than the definition"
2890                .to_string(),
2891        }),
2892    }
2893
2894    match xml_value(text, "LogonType").as_deref() {
2895        Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
2896        Some(other) => findings.push(PrivilegeFinding {
2897            kind: FindingKind::Excess,
2898            subject: "LogonType".to_string(),
2899            detail: format!(
2900                "is `{other}`, which asks Windows to store or synthesise a credential for this \
2901                 task; an interactive token needs neither"
2902            ),
2903        }),
2904        None => findings.push(PrivilegeFinding {
2905            kind: FindingKind::Shortfall,
2906            subject: "LogonType".to_string(),
2907            detail: "is absent, so this review cannot confirm that no credential is stored"
2908                .to_string(),
2909        }),
2910    }
2911}
2912
2913fn review_windows_service(
2914    text: &str,
2915    plan: &InstallPlan,
2916    controls: &mut Vec<String>,
2917    findings: &mut Vec<PrivilegeFinding>,
2918) {
2919    let directives = ini_directives(text, "windows-service");
2920
2921    match directives.get("ServiceType").map(String::as_str) {
2922        Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
2923        Some(other) => findings.push(PrivilegeFinding {
2924            kind: FindingKind::Excess,
2925            subject: "ServiceType".to_string(),
2926            detail: format!(
2927                "is `{other}`; an interactive or shared-process service reaches further than a \
2928                 daemon that only talks to GitHub over HTTPS"
2929            ),
2930        }),
2931        None => findings.push(PrivilegeFinding {
2932            kind: FindingKind::Shortfall,
2933            subject: "ServiceType".to_string(),
2934            detail: "is absent, so this review cannot confirm the service is not interactive"
2935                .to_string(),
2936        }),
2937    }
2938
2939    // The account is not a free choice on Windows: `d2`'s DACL decides it. What
2940    // the review can check is that the definition names the one account that
2941    // DACL admits, and no broader one.
2942    match directives.get("Account").map(String::as_str) {
2943        Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
2944            controls.push(format!(
2945                "Account={account}: the only stock account the machine-scoped store's DACL \
2946                 (SY, BA, OW) admits"
2947            ));
2948        }
2949        Some(other) => findings.push(PrivilegeFinding {
2950            kind: FindingKind::Shortfall,
2951            subject: "Account".to_string(),
2952            detail: format!(
2953                "is `{other}`, which the machine-scoped store's DACL does not name, so the \
2954                 daemon would start and then find no credential. Widening that DACL is not this \
2955                 registration's to do: an ACE reaching `{other}` would also reach every other \
2956                 service running under it"
2957            ),
2958        }),
2959        None => findings.push(PrivilegeFinding {
2960            kind: FindingKind::Shortfall,
2961            subject: "Account".to_string(),
2962            detail: "is absent, so this review cannot confirm which account was registered"
2963                .to_string(),
2964        }),
2965    }
2966
2967    match directives.get("ReadWritePaths") {
2968        Some(value) => {
2969            let listed = split_quoted(value);
2970            review_writable_paths(
2971                DefinitionKind::WindowsService,
2972                "ReadWritePaths",
2973                &listed,
2974                plan,
2975                controls,
2976                findings,
2977            );
2978        }
2979        None => findings.push(PrivilegeFinding {
2980            kind: FindingKind::Shortfall,
2981            subject: "ReadWritePaths".to_string(),
2982            detail: "is absent, so the directories the service was installed against are not \
2983                     recorded"
2984                .to_string(),
2985        }),
2986    }
2987}
2988
2989// -- parsing helpers ---------------------------------------------------------
2990
2991/// Reads `key=value` lines out of one `[section]` of an INI-shaped document.
2992///
2993/// systemd's unit format and this module's Windows descriptor are both this
2994/// shape. A repeated key takes its last value, which is systemd's own rule for
2995/// every directive here.
2996fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
2997    let mut out = BTreeMap::new();
2998    let mut inside = false;
2999    for line in text.lines() {
3000        let line = line.trim();
3001        if line.starts_with('[') && line.ends_with(']') {
3002            inside = &line[1..line.len() - 1] == section;
3003            continue;
3004        }
3005        if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
3006            continue;
3007        }
3008        if let Some((key, value)) = line.split_once('=') {
3009            out.insert(key.trim().to_string(), value.trim().to_string());
3010        }
3011    }
3012    out
3013}
3014
3015/// The command line in a systemd `ExecStart=`, without the prefixes systemd
3016/// reads off its front (`-`, `@`, `:`, `+`, `!`, `!!`).
3017///
3018/// A drop-in may carry any of them, and left in place they become part of the
3019/// executable's path. With `@` the second word is the `argv[0]` handed to the
3020/// process rather than an argument, so it goes as well.
3021#[cfg(any(test, all(unix, not(target_os = "macos"))))]
3022fn systemd_exec_command_line(value: &str) -> String {
3023    let value = value.trim();
3024    let command = value.trim_start_matches(['-', '@', ':', '+', '!']);
3025    if !value[..value.len() - command.len()].contains('@') {
3026        return command.to_string();
3027    }
3028    let mut words = split_command_line(command);
3029    if words.len() > 1 {
3030        words.remove(1);
3031    }
3032    words
3033        .iter()
3034        .map(|word| quote_argument(word))
3035        .collect::<Vec<_>>()
3036        .join(" ")
3037}
3038
3039/// Parses a systemd time span such as `15`, `15s`, `500ms`, `1min 30s` or
3040/// `2h`. A bare number is seconds. `None` for `infinity`, an empty value, or
3041/// anything systemd would not accept either.
3042#[cfg(any(test, all(unix, not(target_os = "macos"))))]
3043fn systemd_timespan(value: &str) -> Option<Duration> {
3044    let mut rest = value.trim();
3045    if rest.is_empty() || rest == "infinity" {
3046        return None;
3047    }
3048    let mut seconds = 0f64;
3049    while !rest.is_empty() {
3050        let digits = rest
3051            .find(|c: char| !(c.is_ascii_digit() || c == '.'))
3052            .unwrap_or(rest.len());
3053        if digits == 0 {
3054            return None;
3055        }
3056        let number: f64 = rest[..digits].parse().ok()?;
3057        rest = rest[digits..].trim_start();
3058        let letters = rest
3059            .find(|c: char| !c.is_alphabetic())
3060            .unwrap_or(rest.len());
3061        let scale = match &rest[..letters] {
3062            "" | "s" | "sec" | "second" | "seconds" => 1.0,
3063            "us" | "usec" | "\u{3bc}s" | "\u{b5}s" => 1e-6,
3064            "ms" | "msec" => 1e-3,
3065            "m" | "min" | "minute" | "minutes" => 60.0,
3066            "h" | "hr" | "hour" | "hours" => 3_600.0,
3067            "d" | "day" | "days" => 86_400.0,
3068            "w" | "week" | "weeks" => 604_800.0,
3069            "M" | "month" | "months" => 2_629_800.0,
3070            "y" | "year" | "years" => 31_557_600.0,
3071            _ => return None,
3072        };
3073        seconds += number * scale;
3074        rest = rest[letters..].trim_start();
3075    }
3076    Duration::try_from_secs_f64(seconds).ok()
3077}
3078
3079/// Splits a whitespace-separated list that may contain arguments quoted by
3080/// [`quote_argument`].
3081fn split_quoted(value: &str) -> Vec<String> {
3082    let mut out = Vec::new();
3083    let mut rest = value.trim();
3084    while !rest.is_empty() {
3085        if rest.starts_with('"') {
3086            // `executable_from_command_line` already implements Windows' quoting
3087            // rules; reuse it rather than write a second, subtly different one.
3088            if let Some(parsed) = executable_from_command_line(rest) {
3089                out.push(parsed.to_string_lossy().into_owned());
3090            }
3091            // Advance past the closing quote.
3092            let mut depth = 0usize;
3093            let mut end = rest.len();
3094            for (index, c) in rest.char_indices() {
3095                match c {
3096                    '\\' => depth += 1,
3097                    '"' => {
3098                        if depth.is_multiple_of(2) && index > 0 {
3099                            end = index + 1;
3100                            break;
3101                        }
3102                        depth = 0;
3103                    }
3104                    _ => depth = 0,
3105                }
3106            }
3107            rest = rest[end..].trim_start();
3108        } else {
3109            let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
3110            out.push(rest[..end].to_string());
3111            rest = rest[end..].trim_start();
3112        }
3113    }
3114    out
3115}
3116
3117/// The text between the **first** `<tag>` and its `</tag>`, unescaped, for the
3118/// small, known documents this module renders. Not a general XML parser and
3119/// does not pretend to be one.
3120///
3121/// "First" matters for a document that repeats a tag: a Task Scheduler task has
3122/// an `<Enabled>` in its trigger *and* one in its settings, so a caller after
3123/// the second one passes the slice that starts at `<Settings>`.
3124///
3125/// `pub(crate)` rather than private because [`crate::wsl::task`] reads back a
3126/// second kind of Task Scheduler document; one reader for both is the same
3127/// argument [`xml_escape`] makes for one escaper.
3128pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
3129    let open = format!("<{tag}>");
3130    let close = format!("</{tag}>");
3131    let start = text.find(&open)? + open.len();
3132    let end = text[start..].find(&close)? + start;
3133    Some(xml_unescape(text[start..end].trim()))
3134}
3135
3136/// Whether Task Scheduler will launch a login task automatically.
3137///
3138/// Its `/Query /XML` output normalises away values that equal the schema
3139/// default. In particular, an enabled task commonly has no `<Enabled>` node at
3140/// all, so only an explicit `false` means disabled.
3141#[cfg(any(windows, test))]
3142fn windows_login_task_starts_automatically(document: &str) -> bool {
3143    document.contains("<LogonTrigger>")
3144        && xml_value(document, "Enabled").as_deref() != Some("false")
3145}
3146
3147/// The `<string>` that follows `<key>key</key>` in a property list.
3148fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
3149    let marker = format!("<key>{key}</key>");
3150    let start = text.find(&marker)? + marker.len();
3151    Some(text[start..].trim_start())
3152}
3153
3154pub fn plist_string_value(text: &str, key: &str) -> Option<String> {
3155    let rest = plist_value_after_key(text, key)?;
3156    if !rest.starts_with("<string>") {
3157        return None;
3158    }
3159    xml_value(rest, "string")
3160}
3161
3162fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
3163    let rest = plist_value_after_key(text, key)?;
3164    if rest.starts_with("<true/>") {
3165        Some(true)
3166    } else if rest.starts_with("<false/>") {
3167        Some(false)
3168    } else {
3169        None
3170    }
3171}
3172
3173// ---------------------------------------------------------------------------
3174// The install record
3175// ---------------------------------------------------------------------------
3176
3177/// The version of [`InstallRecord`] on disk.
3178///
3179/// Bumped when a record written by an older version can no longer be read.
3180/// `service status` reports an unreadable record as a problem with a remedy
3181/// rather than failing, because the remedy — uninstall and install again —
3182/// touches no configuration, no secret and no cache.
3183pub const RECORD_SCHEMA_VERSION: u32 = 1;
3184
3185/// What `service install` wrote down, and what `service status` reads back.
3186///
3187/// This is the *record* half of `05-infrastructure.md` item 6. The service
3188/// manager also knows the binary path, and the two are compared: a registration
3189/// whose command line no longer matches the record is a registration something
3190/// else has edited, and saying so is more useful than picking one of them to
3191/// believe.
3192///
3193/// It holds no credential. The nearest thing is the *location* of the secret
3194/// store, which `d2` publishes for `host show` to print, and even that is
3195/// derived rather than stored.
3196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3197pub struct InstallRecord {
3198    /// [`RECORD_SCHEMA_VERSION`] at the time of writing.
3199    pub schema_version: u32,
3200    /// What the operating system calls the registration.
3201    pub service_name: String,
3202    /// Which service manager holds it.
3203    pub manager: String,
3204    /// Boot or login.
3205    pub start_mode: StartMode,
3206    /// The account it runs under.
3207    pub account: ServiceAccount,
3208    /// **The resolved absolute path of the binary at install time.** Item 6.
3209    pub binary: PathBuf,
3210    /// Where [`Self::binary`] was copied from, when it is a copy.
3211    ///
3212    /// `#[serde(default)]` rather than a schema bump: a record written before
3213    /// this existed is still readable, and reads as `None`. That is not a
3214    /// silent downgrade — `None` means the registration names a package
3215    /// manager's own file, which is the layout that cannot be upgraded while
3216    /// the service runs, and `service status` says so by name.
3217    #[serde(default)]
3218    pub source_binary: Option<PathBuf>,
3219    /// The arguments it was registered with.
3220    pub arguments: Vec<String>,
3221    /// The restart-on-failure delay.
3222    pub restart_delay_secs: u64,
3223    /// How long it must run before the failure count resets.
3224    pub restart_reset_secs: u64,
3225    /// The rotating diagnostic log's stem. Item 4.
3226    pub log_file: PathBuf,
3227    /// Whether the manager was asked not to start this by itself. Always
3228    /// `false` for a registration an operator made; see
3229    /// [`InstallRequest::started_on_demand`].
3230    ///
3231    /// `#[serde(default)]` so that a record missing the field is rejected by
3232    /// the schema check below, with its remedy, rather than by a parse error
3233    /// that names a field an operator has never heard of.
3234    #[serde(default)]
3235    pub starts_on_demand: bool,
3236    /// Where the platform's definition file was written, when there is one.
3237    pub definition_path: Option<PathBuf>,
3238    /// When the registration was made.
3239    pub installed_at: DateTime<Utc>,
3240    /// Which build of the product made it.
3241    pub installed_by_version: String,
3242    // A TOML table has to follow every scalar at its level, so this field is
3243    // last by necessity rather than by taste.
3244    /// The four directories the registration was installed against. Item 2's
3245    /// *"configured cache and runtime directories"*.
3246    pub directories: ServiceDirectories,
3247}
3248
3249impl InstallRecord {
3250    /// Where the record lives, given this host's directories.
3251    #[must_use]
3252    pub fn path(paths: &AppPaths) -> PathBuf {
3253        paths.config_dir().join(RECORD_FILE)
3254    }
3255
3256    /// Builds the record for a plan that has just been applied.
3257    #[must_use]
3258    pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
3259        Self {
3260            schema_version: RECORD_SCHEMA_VERSION,
3261            service_name: plan.identity().name().to_string(),
3262            manager: definition.kind().manager().to_string(),
3263            start_mode: plan.start_mode(),
3264            account: plan.account().clone(),
3265            binary: plan.binary().to_path_buf(),
3266            arguments: plan
3267                .arguments()
3268                .iter()
3269                .map(|argument| argument.to_string_lossy().into_owned())
3270                .collect(),
3271            restart_delay_secs: plan.restart().delay().as_secs(),
3272            restart_reset_secs: plan.restart().reset_after().as_secs(),
3273            starts_on_demand: plan.is_on_demand(),
3274            source_binary: plan.source_binary().map(Path::to_path_buf),
3275            log_file: plan.directories().log_file(),
3276            definition_path: definition.install_path().map(Path::to_path_buf),
3277            installed_at: at,
3278            installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
3279            directories: plan.directories().clone(),
3280        }
3281    }
3282
3283    /// The plan this record describes, as this build would register it.
3284    ///
3285    /// The directories, binary, arguments and restart policy are the recorded
3286    /// ones; everything else -- hardening, the secret store's guard -- is what
3287    /// this build renders. That difference is the point: it is how a
3288    /// registration written by an older build is compared against, and
3289    /// repaired to, the current definition.
3290    #[must_use]
3291    pub fn plan(&self, identity: ServiceIdentity) -> InstallPlan {
3292        let plan = InstallPlan::unchecked(
3293            identity,
3294            self.start_mode,
3295            self.binary.clone(),
3296            self.directories.clone(),
3297        )
3298        .with_arguments(self.arguments.clone())
3299        .with_restart(self.restart());
3300        let plan = if self.starts_on_demand {
3301            plan.started_on_demand()
3302        } else {
3303            plan
3304        };
3305        match crate::secrets::PlatformSecretStore::for_start_mode(self.start_mode) {
3306            Ok(store) => plan.with_secret_guard(store.guard()),
3307            Err(_) => plan,
3308        }
3309    }
3310
3311    /// The restart policy this record describes, or [`RestartPolicy::default`]
3312    /// when the recorded numbers are outside the supported range — which can
3313    /// only happen to a record something else has edited.
3314    #[must_use]
3315    pub fn restart(&self) -> RestartPolicy {
3316        RestartPolicy::new(
3317            Duration::from_secs(self.restart_delay_secs),
3318            Duration::from_secs(self.restart_reset_secs),
3319        )
3320        .unwrap_or_default()
3321    }
3322
3323    /// Reads the record, or reports that there is none.
3324    ///
3325    /// `Ok(None)` means no registration was made by this product on this host.
3326    ///
3327    /// # Errors
3328    ///
3329    /// [`ServiceError::Record`] when the file exists and cannot be read,
3330    /// [`ServiceError::RecordNotPermitted`] when it exists and this account may
3331    /// not read it, and [`ServiceError::RecordUnreadable`] when it is not a
3332    /// record this version understands.
3333    pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
3334        let path = Self::path(paths);
3335        let text = match std::fs::read_to_string(&path) {
3336            Ok(text) => text,
3337            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3338            // Told apart from every other read failure because `status` carries
3339            // on through this one alone. See `ServiceError::RecordNotPermitted`.
3340            Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
3341                return Err(ServiceError::RecordNotPermitted {
3342                    path,
3343                    detail: error.to_string(),
3344                });
3345            }
3346            Err(error) => {
3347                return Err(ServiceError::Record {
3348                    operation: "read",
3349                    path,
3350                    detail: error.to_string(),
3351                });
3352            }
3353        };
3354        let record: Self =
3355            toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
3356                path: path.clone(),
3357                detail: error.to_string(),
3358            })?;
3359        if record.schema_version != RECORD_SCHEMA_VERSION {
3360            return Err(ServiceError::RecordUnreadable {
3361                path,
3362                detail: format!(
3363                    "it declares schema version {} and this build reads version {}",
3364                    record.schema_version, RECORD_SCHEMA_VERSION
3365                ),
3366            });
3367        }
3368        Ok(Some(record))
3369    }
3370
3371    /// Writes the record, replacing whatever was there.
3372    ///
3373    /// # Errors
3374    ///
3375    /// [`ServiceError::Record`].
3376    pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
3377        use std::io::Write as _;
3378
3379        let path = Self::path(paths);
3380        let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
3381            operation: "encode",
3382            path: path.clone(),
3383            detail: error.to_string(),
3384        })?;
3385        if let Some(parent) = path.parent() {
3386            std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
3387                operation: "write",
3388                path: path.clone(),
3389                detail: error.to_string(),
3390            })?;
3391        }
3392        let parent = path.parent().ok_or_else(|| ServiceError::Record {
3393            operation: "write",
3394            path: path.clone(),
3395            detail: "the record path has no parent directory".to_string(),
3396        })?;
3397        let mut temporary =
3398            tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
3399                operation: "write",
3400                path: path.clone(),
3401                detail: error.to_string(),
3402            })?;
3403        temporary
3404            .write_all(text.as_bytes())
3405            .and_then(|()| temporary.as_file().sync_all())
3406            .map_err(|error| ServiceError::Record {
3407                operation: "write",
3408                path: path.clone(),
3409                detail: error.to_string(),
3410            })?;
3411        // ------------------------------------------------------------------
3412        // THE RECORD IS READABLE BY THE ACCOUNT WHOSE DIRECTORY IT IS IN.
3413        // ------------------------------------------------------------------
3414        // `NamedTempFile` creates at `0600`, which is right for a temporary
3415        // file and wrong for this one. A boot-mode `service install` runs under
3416        // `sudo`, so `0600` made the record `root`-owned and unreadable by the
3417        // operator — and `service status`, which asks no privilege of anybody,
3418        // failed on their own host with `Permission denied` and a remedy that
3419        // named the command that had just failed.
3420        //
3421        // `0644` does not widen anything: `RECORD_FILE`'s own documentation is
3422        // that this is *"non-secret TOML"* holding no credential, and
3423        // `AppPaths::create_all` puts it in a `0700` directory, so no other
3424        // local account can reach it whatever its own mode says. It is the same
3425        // arrangement the rotating diagnostics already have.
3426        #[cfg(unix)]
3427        {
3428            use std::os::unix::fs::PermissionsExt as _;
3429
3430            temporary
3431                .as_file()
3432                .set_permissions(std::fs::Permissions::from_mode(0o644))
3433                .map_err(|error| ServiceError::Record {
3434                    operation: "write",
3435                    path: path.clone(),
3436                    detail: error.to_string(),
3437                })?;
3438        }
3439        temporary
3440            .persist(&path)
3441            .map(|_| ())
3442            .map_err(|error| ServiceError::Record {
3443                operation: "write",
3444                path,
3445                detail: error.error.to_string(),
3446            })
3447    }
3448
3449    /// Removes the record. Returns whether there was one.
3450    ///
3451    /// # Errors
3452    ///
3453    /// [`ServiceError::Record`].
3454    pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
3455        let path = Self::path(paths);
3456        match std::fs::remove_file(&path) {
3457            Ok(()) => Ok(true),
3458            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
3459            Err(error) => Err(ServiceError::Record {
3460                operation: "remove",
3461                path,
3462                detail: error.to_string(),
3463            }),
3464        }
3465    }
3466}
3467
3468// ---------------------------------------------------------------------------
3469// The last successful GitHub contact
3470// ---------------------------------------------------------------------------
3471
3472/// The heartbeat file's own schema version.
3473const CONTACT_SCHEMA_VERSION: u32 = 1;
3474
3475#[derive(Debug, Clone, Serialize, Deserialize)]
3476struct ContactRecord {
3477    schema_version: u32,
3478    last_success: DateTime<Utc>,
3479}
3480
3481/// Records that GitHub was reached successfully, for `service status` to
3482/// report.
3483///
3484/// # The contract, for `f3` and for the agent
3485///
3486/// Journey 5 step 4 requires `service status` to report *"the last successful
3487/// GitHub contact"*, and `service status` runs in the operator's terminal while
3488/// the daemon runs in a service. They share no memory, so the fact has to be on
3489/// disk, and this is the file. **The daemon calls this after each successful
3490/// GitHub call; nothing else writes it.**
3491///
3492/// It is a single timestamp and deliberately not a log: an operator asking this
3493/// question wants to know whether the agent is alive and reaching GitHub *now*,
3494/// and a value that is minutes old answers it. Writing it is a whole-file
3495/// replace through a temporary in the same directory, so a status command can
3496/// never read a half-written timestamp.
3497///
3498/// # Errors
3499///
3500/// [`ServiceError::Record`] when `state/` cannot be written.
3501pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3502    write_state_record(
3503        &contact_path(paths),
3504        &ContactRecord {
3505            schema_version: CONTACT_SCHEMA_VERSION,
3506            last_success: at,
3507        },
3508    )
3509}
3510
3511/// Reads the last successful GitHub contact, or reports that none was recorded.
3512///
3513/// `Ok(None)` is the honest answer on a host whose agent has never run, and it
3514/// is what `service status` prints rather than a zero timestamp.
3515///
3516/// # Errors
3517///
3518/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3519/// A malformed heartbeat is reported rather than treated as absence: absence
3520/// means *"the agent has never reached GitHub"*, and reporting a parse failure
3521/// as that would be a wrong answer to the question Journey 5 asks.
3522pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3523    Ok(read_state_record::<ContactRecord>(&contact_path(paths))?.map(|record| record.last_success))
3524}
3525
3526/// Where the heartbeat lives.
3527#[must_use]
3528pub fn contact_path(paths: &AppPaths) -> PathBuf {
3529    paths.state_dir().join(CONTACT_FILE)
3530}
3531
3532/// Writes a small TOML record under `state/`, through a temporary in the same
3533/// directory and a rename, so a status command never reads half of one.
3534pub(crate) fn write_state_record(path: &Path, record: &impl Serialize) -> Result<(), ServiceError> {
3535    let failed = |detail: String| ServiceError::Record {
3536        operation: "write",
3537        path: path.to_path_buf(),
3538        detail,
3539    };
3540    let text = toml::to_string_pretty(record).map_err(|error| failed(error.to_string()))?;
3541    let directory = path.parent().unwrap_or_else(|| Path::new("."));
3542    std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3543    let temporary = path.with_extension("toml.new");
3544    std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3545    std::fs::rename(&temporary, path).map_err(|error| failed(error.to_string()))
3546}
3547
3548/// Reads a record [`write_state_record`] wrote. `Ok(None)` when there is none;
3549/// one that exists and does not parse is an error, never absence.
3550pub(crate) fn read_state_record<T: serde::de::DeserializeOwned>(
3551    path: &Path,
3552) -> Result<Option<T>, ServiceError> {
3553    let failed = |detail: String| ServiceError::Record {
3554        operation: "read",
3555        path: path.to_path_buf(),
3556        detail,
3557    };
3558    let text = match std::fs::read_to_string(path) {
3559        Ok(text) => text,
3560        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3561        Err(error) => return Err(failed(error.to_string())),
3562    };
3563    toml::from_str(&text)
3564        .map(Some)
3565        .map_err(|error| failed(error.to_string()))
3566}
3567
3568/// Removes a record; one that is not there is already removed.
3569pub(crate) fn remove_state_record(path: &Path) -> Result<(), ServiceError> {
3570    match std::fs::remove_file(path) {
3571        Ok(()) => Ok(()),
3572        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3573        Err(error) => Err(ServiceError::Record {
3574            operation: "remove",
3575            path: path.to_path_buf(),
3576            detail: error.to_string(),
3577        }),
3578    }
3579}
3580
3581// ---------------------------------------------------------------------------
3582// The credential GitHub rejected
3583// ---------------------------------------------------------------------------
3584
3585#[derive(Debug, Serialize, Deserialize)]
3586#[serde(deny_unknown_fields)]
3587struct CredentialRejectionRecord {
3588    schema_version: u32,
3589    rejected_since: DateTime<Utc>,
3590}
3591
3592/// Records that GitHub rejected the daemon's credential, keeping the first
3593/// moment of an ongoing rejection.
3594///
3595/// # Why a file of its own
3596///
3597/// A rejected credential used to be visible only in the daemon's log: the
3598/// contact heartbeat simply stopped moving, `service status` still printed
3599/// `healthy`, and the TUI -- which authenticates with its own, perhaps freshly
3600/// renewed, copy -- showed nothing at all while the service started no runner
3601/// for hours. Separate from [`CONTACT_FILE`] so that an older build reading
3602/// that file is not handed a field it refuses.
3603///
3604/// # Errors
3605///
3606/// [`ServiceError::Record`] when `state/` cannot be written.
3607pub fn record_github_credential_rejected(
3608    paths: &AppPaths,
3609    at: DateTime<Utc>,
3610) -> Result<(), ServiceError> {
3611    if github_credential_rejected_since(paths)
3612        .ok()
3613        .flatten()
3614        .is_some()
3615    {
3616        return Ok(());
3617    }
3618    write_state_record(
3619        &credential_rejection_path(paths),
3620        &CredentialRejectionRecord {
3621            schema_version: CONTACT_SCHEMA_VERSION,
3622            rejected_since: at,
3623        },
3624    )
3625}
3626
3627/// Clears a recorded rejection once GitHub accepts the credential again.
3628///
3629/// # Errors
3630///
3631/// [`ServiceError::Record`] when the record exists and cannot be removed.
3632pub fn clear_github_credential_rejection(paths: &AppPaths) -> Result<(), ServiceError> {
3633    remove_state_record(&credential_rejection_path(paths))
3634}
3635
3636/// Since when GitHub has been rejecting the daemon's credential, if it is.
3637///
3638/// # Errors
3639///
3640/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3641pub fn github_credential_rejected_since(
3642    paths: &AppPaths,
3643) -> Result<Option<DateTime<Utc>>, ServiceError> {
3644    Ok(
3645        read_state_record::<CredentialRejectionRecord>(&credential_rejection_path(paths))?
3646            .map(|record| record.rejected_since),
3647    )
3648}
3649
3650/// Where the rejection record lives.
3651#[must_use]
3652pub fn credential_rejection_path(paths: &AppPaths) -> PathBuf {
3653    paths.state_dir().join(CREDENTIAL_REJECTION_FILE)
3654}
3655
3656#[derive(Debug, Serialize, Deserialize)]
3657#[serde(deny_unknown_fields)]
3658struct CredentialUnreadableRecord {
3659    schema_version: u32,
3660    unreadable_since: DateTime<Utc>,
3661}
3662
3663/// Records that the daemon could not read its stored credential, keeping the
3664/// first moment of an ongoing failure.
3665///
3666/// # Why a file of its own
3667///
3668/// A daemon that cannot read its credential exits, and the service manager
3669/// starts it again a few seconds later, for ever. `service status` reads the
3670/// registration, which is fine, and the heartbeat, which merely stops moving --
3671/// so it said `healthy` while a macOS host that had just been upgraded was
3672/// locked out of its own keychain item and started no runner. The reason was
3673/// only in the daemon's log. The daemon is the one process whose view of the
3674/// store matters, and it shares no memory with `service status`, so it says
3675/// so on disk; [`clear_credential_unreadable`] removes it as soon as a read
3676/// succeeds.
3677///
3678/// # Errors
3679///
3680/// [`ServiceError::Record`] when `state/` cannot be written.
3681pub fn record_credential_unreadable(
3682    paths: &AppPaths,
3683    at: DateTime<Utc>,
3684) -> Result<(), ServiceError> {
3685    if credential_unreadable_since(paths).ok().flatten().is_some() {
3686        return Ok(());
3687    }
3688    write_state_record(
3689        &credential_unreadable_path(paths),
3690        &CredentialUnreadableRecord {
3691            schema_version: CONTACT_SCHEMA_VERSION,
3692            unreadable_since: at,
3693        },
3694    )
3695}
3696
3697/// Clears a recorded failure once the daemon reads its credential again.
3698///
3699/// # Errors
3700///
3701/// [`ServiceError::Record`] when the record exists and cannot be removed.
3702pub fn clear_credential_unreadable(paths: &AppPaths) -> Result<(), ServiceError> {
3703    remove_state_record(&credential_unreadable_path(paths))
3704}
3705
3706/// Since when the daemon has been unable to read its stored credential, if it
3707/// has.
3708///
3709/// # Errors
3710///
3711/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3712pub fn credential_unreadable_since(
3713    paths: &AppPaths,
3714) -> Result<Option<DateTime<Utc>>, ServiceError> {
3715    Ok(
3716        read_state_record::<CredentialUnreadableRecord>(&credential_unreadable_path(paths))?
3717            .map(|record| record.unreadable_since),
3718    )
3719}
3720
3721/// Where the unreadable-credential record lives.
3722#[must_use]
3723pub fn credential_unreadable_path(paths: &AppPaths) -> PathBuf {
3724    paths.state_dir().join(CREDENTIAL_UNREADABLE_FILE)
3725}
3726
3727/// How to sign this host in again for `mode`: the exact command, in
3728/// backticks, and where to run it when that matters.
3729///
3730/// A machine-scoped store needs privilege: `sudo` on Unix, an elevated
3731/// terminal on Windows. A login-mode store on macOS is the login keychain,
3732/// which is unlocked for the desktop session only, so an SSH session cannot
3733/// write it.
3734#[must_use]
3735pub fn sign_in_instruction(mode: StartMode) -> String {
3736    let (command, place) = match mode {
3737        StartMode::Boot if cfg!(unix) => (sudo_command("auth login --start-at boot"), ""),
3738        StartMode::Boot => (
3739            "runner-manager auth login --start-at boot".to_owned(),
3740            " from an elevated terminal",
3741        ),
3742        StartMode::Login => (
3743            "runner-manager auth login --start-at login".to_owned(),
3744            MACOS_LOGIN_PLACE,
3745        ),
3746    };
3747    format!("`{command}`{place}")
3748}
3749
3750/// `sudo`, then this program, then `arguments`: a command an operator can
3751/// paste.
3752///
3753/// `sudo` searches only its own `secure_path`, which an npm or nvm
3754/// installation is not on, and the npm command is a Node shim that root's
3755/// `PATH` could not run either. So the command names this executable -- the
3756/// native binary, even behind that shim -- unless it already lives on
3757/// `secure_path`, where the bare name reads better and works.
3758#[must_use]
3759pub fn sudo_command(arguments: &str) -> String {
3760    // A test binary is not the product; its own path would only make every
3761    // remedy a test compares depend on where the build directory is.
3762    let executable = if cfg!(test) {
3763        None
3764    } else {
3765        std::env::current_exe().ok()
3766    };
3767    format!("sudo {} {arguments}", sudo_program(executable.as_deref()))
3768}
3769
3770/// How a `sudo` command names `executable`. See [`sudo_command`].
3771fn sudo_program(executable: Option<&Path>) -> String {
3772    /// Ubuntu's and Debian's `secure_path`, which other distributions'
3773    /// defaults are a subset of.
3774    const SECURE_PATH: [&str; 7] = [
3775        "/usr/local/sbin",
3776        "/usr/local/bin",
3777        "/usr/sbin",
3778        "/usr/bin",
3779        "/sbin",
3780        "/bin",
3781        "/snap/bin",
3782    ];
3783    let Some(executable) = executable else {
3784        return "runner-manager".to_owned();
3785    };
3786    if executable
3787        .parent()
3788        .is_some_and(|directory| SECURE_PATH.iter().any(|path| directory == Path::new(path)))
3789    {
3790        return "runner-manager".to_owned();
3791    }
3792    let text = executable.to_string_lossy();
3793    if text
3794        .chars()
3795        .all(|c| c.is_ascii_alphanumeric() || "/._-+@".contains(c))
3796    {
3797        text.into_owned()
3798    } else {
3799        format!("'{}'", text.replace('\'', "'\\''"))
3800    }
3801}
3802
3803/// Where a login-mode sign-in has to happen on macOS, as a phrase to follow
3804/// the command; nothing elsewhere.
3805pub const MACOS_LOGIN_PLACE: &str = if cfg!(target_os = "macos") {
3806    " in a Terminal in this Mac's desktop session (an SSH session cannot unlock the login \
3807     keychain)"
3808} else {
3809    ""
3810};
3811
3812// ---------------------------------------------------------------------------
3813// A definition written by an older build
3814// ---------------------------------------------------------------------------
3815
3816/// An installed service definition that differs from what this build renders
3817/// for the same recorded installation.
3818///
3819/// # Why this exists
3820///
3821/// An upgrade replaces the binary a registration runs and nothing else. A
3822/// systemd unit written by 0.4.7 still carried `LoadCredential=`, which hands
3823/// the daemon a copy of the credential frozen at service start, and lacked
3824/// write access to the credential store. Every release after the fix kept
3825/// running under that unit: the daemon could neither renew the token nor see
3826/// one renewed by anybody else, and stopped starting runners the moment the
3827/// frozen access token expired.
3828#[derive(Debug, Clone, PartialEq, Eq)]
3829pub struct DefinitionDrift {
3830    /// The definition file on disk.
3831    pub path: PathBuf,
3832    /// The build that wrote it, as its install record says.
3833    pub installed_by_version: String,
3834    /// What this build renders in its place.
3835    pub rendered: String,
3836    /// The start mode it was installed for.
3837    pub start_mode: StartMode,
3838    /// Which manager reads the definition.
3839    pub manager: DefinitionKind,
3840}
3841
3842/// Compares the installed definition against this build's rendering.
3843///
3844/// A systemd unit is compared byte for byte. A launchd plist is compared on
3845/// `ProcessType` alone (see [`launchd_definition_drift`]). `None` for every
3846/// other manager, for a definition that cannot be read, and for one that
3847/// already matches. The file is read through `controls`, so a double never
3848/// compares against a real host's definition.
3849fn definition_drift(
3850    controls: &dyn ControlFactory,
3851    identity: &ServiceIdentity,
3852    record: &InstallRecord,
3853) -> Option<DefinitionDrift> {
3854    let compare = match host_definition_kind(record.start_mode) {
3855        DefinitionKind::SystemdUnit => systemd_definition_drift,
3856        DefinitionKind::LaunchdPlist => launchd_definition_drift,
3857        DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => return None,
3858    };
3859    let path = record.definition_path.clone()?;
3860    let installed = controls.installed_definition(&path)?;
3861    compare(identity, record, path, &installed)
3862}
3863
3864/// A plist whose `ProcessType` is not [`LAUNCHD_PROCESS_TYPE`], with the plist
3865/// this build renders in its place.
3866///
3867/// Only that one key, unlike the systemd comparison. The daemon repairs this
3868/// drift by itself (`daemon.rs`, `converge_launchd_definition`), so a
3869/// whole-file comparison would make it rewrite and reload over every edit an
3870/// operator made to the plist. A plist that does carry the wrong value is
3871/// replaced whole, as `service install` would replace it.
3872fn launchd_definition_drift(
3873    identity: &ServiceIdentity,
3874    record: &InstallRecord,
3875    path: PathBuf,
3876    installed: &str,
3877) -> Option<DefinitionDrift> {
3878    (plist_string_value(installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE)).then(
3879        || DefinitionDrift {
3880            path,
3881            installed_by_version: record.installed_by_version.clone(),
3882            rendered: launchd_plist(&record.plan(identity.clone())),
3883            start_mode: record.start_mode,
3884            manager: DefinitionKind::LaunchdPlist,
3885        },
3886    )
3887}
3888
3889fn systemd_definition_drift(
3890    identity: &ServiceIdentity,
3891    record: &InstallRecord,
3892    path: PathBuf,
3893    installed: &str,
3894) -> Option<DefinitionDrift> {
3895    let rendered = systemd_unit(&record.plan(identity.clone()));
3896    (installed != rendered).then(|| DefinitionDrift {
3897        path,
3898        installed_by_version: record.installed_by_version.clone(),
3899        rendered,
3900        start_mode: record.start_mode,
3901        manager: DefinitionKind::SystemdUnit,
3902    })
3903}
3904
3905// ---------------------------------------------------------------------------
3906// The last runner-root refusal
3907// ---------------------------------------------------------------------------
3908
3909const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
3910
3911#[derive(Debug, Clone, Serialize, Deserialize)]
3912struct RootRefusalFile {
3913    schema_version: u32,
3914    /// Keyed by policy, because the fact is per-policy and not per-host.
3915    #[serde(default)]
3916    refusals: BTreeMap<String, RootRefusalEntry>,
3917}
3918
3919#[derive(Debug, Clone, Serialize, Deserialize)]
3920struct RootRefusalEntry {
3921    at: DateTime<Utc>,
3922    kind: String,
3923    root: String,
3924    detail: String,
3925}
3926
3927/// One policy's last runner-root refusal, as `service status` reports it.
3928#[derive(Debug, Clone, PartialEq, Eq)]
3929pub struct RunnerRootRefusal {
3930    /// The policy that placed no runner.
3931    pub policy: String,
3932    /// When it was last refused.
3933    pub at: DateTime<Utc>,
3934    /// The closed-vocabulary cause, as `RunnerRootError::kind` names it.
3935    pub kind: String,
3936    /// The root it was refused.
3937    pub root: String,
3938    /// The refusal in full, including the remediation. Not redacted: this file
3939    /// is read by a command in the operator's terminal, not by the log sink.
3940    pub detail: String,
3941}
3942
3943/// Records that one policy could not use its runner root.
3944///
3945/// # The contract, and why a file
3946///
3947/// A launch the runner root refuses fails before any attempt row exists, so the
3948/// journal cannot carry it and no screen can show it. The daemon's own log
3949/// cannot either: it redacts every field it does not allow-list and scrubs
3950/// anything path-shaped out of the ones it does, which is most of a
3951/// `RunnerRootError`. So the sentence an operator needs — the directory and the
3952/// remediation, verbatim — has nowhere to go, and the failure reads
3953/// `runner_start_failed reason=other` once per poll for as long as it lasts.
3954/// That is not hypothetical: it cost three hours.
3955///
3956/// **Keyed by policy, and that is load-bearing.** A host runs several policies
3957/// and they do not share a fate: `allocate_persistent_slot` tolerates an
3958/// unresolvable host default, so a repository with its own persistent root
3959/// places runners happily while an ephemeral policy on a withheld volume places
3960/// none. A single host-wide slot would have the working policy clear the broken
3961/// one's record on the same reconcile pass, and `service status` would report a
3962/// healthy host that had started zero runners for an entire target.
3963///
3964/// **The daemon writes this when a root refuses a launch and clears the policy's
3965/// entry when one succeeds; nothing else writes it.** It keeps the last refusal
3966/// per policy rather than a history, for the reason [`record_github_contact`] is
3967/// a single timestamp: the operator is asking whether runners can be placed
3968/// *now*.
3969///
3970/// Written whole through a temporary in the same directory, so a status command
3971/// cannot read half a record.
3972///
3973/// # Errors
3974///
3975/// [`ServiceError::Record`] when `state/` cannot be read or written.
3976pub fn record_runner_root_refusal(
3977    paths: &AppPaths,
3978    policy: &str,
3979    at: DateTime<Utc>,
3980    kind: &str,
3981    root: &str,
3982    detail: &str,
3983) -> Result<(), ServiceError> {
3984    let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
3985        schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
3986        refusals: BTreeMap::new(),
3987    });
3988    file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
3989    file.refusals.insert(
3990        policy.to_owned(),
3991        RootRefusalEntry {
3992            at,
3993            kind: kind.to_owned(),
3994            root: root.to_owned(),
3995            detail: detail.to_owned(),
3996        },
3997    );
3998    write_refusal_file(paths, &file)
3999}
4000
4001/// Clears one policy's entry, because that policy placed a runner.
4002///
4003/// Removes the file once the last entry goes, so a host that is working leaves
4004/// nothing behind for a later `service status` to report. A policy with no entry
4005/// is not an error: this is called on every successful placement, and almost
4006/// every one of those follows another success.
4007///
4008/// # Errors
4009///
4010/// [`ServiceError::Record`] when `state/` cannot be read or written.
4011pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
4012    let Some(mut file) = read_refusal_file(paths)? else {
4013        return Ok(());
4014    };
4015    if file.refusals.remove(policy).is_none() {
4016        return Ok(());
4017    }
4018    if file.refusals.is_empty() {
4019        let path = root_refusal_path(paths);
4020        return match std::fs::remove_file(&path) {
4021            Ok(()) => Ok(()),
4022            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
4023            Err(error) => Err(ServiceError::Record {
4024                operation: "remove",
4025                path,
4026                detail: error.to_string(),
4027            }),
4028        };
4029    }
4030    write_refusal_file(paths, &file)
4031}
4032
4033/// Every policy whose runner root refused it and has not since succeeded.
4034///
4035/// Ordered by policy, so two runs of `service status` on an unchanged host
4036/// print the same thing in the same order.
4037///
4038/// # Errors
4039///
4040/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
4041/// Reported rather than treated as absence, for the reason
4042/// [`last_github_contact`] gives: absence means "every policy is placing
4043/// runners", and answering a parse failure with that would be a wrong answer.
4044pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
4045    Ok(read_refusal_file(paths)?
4046        .map(|file| {
4047            file.refusals
4048                .into_iter()
4049                .map(|(policy, entry)| RunnerRootRefusal {
4050                    policy,
4051                    at: entry.at,
4052                    kind: entry.kind,
4053                    root: entry.root,
4054                    detail: entry.detail,
4055                })
4056                .collect()
4057        })
4058        .unwrap_or_default())
4059}
4060
4061fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
4062    read_state_record(&root_refusal_path(paths))
4063}
4064
4065fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
4066    write_state_record(&root_refusal_path(paths), file)
4067}
4068
4069/// Where the refusals live.
4070#[must_use]
4071pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
4072    paths.state_dir().join(ROOT_REFUSAL_FILE)
4073}
4074
4075// ---------------------------------------------------------------------------
4076// The recorded binary path
4077// ---------------------------------------------------------------------------
4078
4079/// What became of the absolute path `install` recorded.
4080///
4081/// `05-infrastructure.md` item 6 in one type. Three of the four variants are
4082/// errors, and `service status` reports them as errors rather than as health —
4083/// which is the whole point, because the npm case produces a service that looks
4084/// installed, is registered, and cannot start.
4085#[derive(Debug, Clone, PartialEq, Eq)]
4086pub enum BinaryPath {
4087    /// The recorded path is a file, and the service manager still starts it.
4088    Current {
4089        /// The path.
4090        path: PathBuf,
4091    },
4092    /// **Nothing is at the recorded path.**
4093    ///
4094    /// This is the npm upgrade: `npm i -g @ivan-murzak/runner-manager` puts the binary under
4095    /// the active Node installation's global prefix, and switching Node versions
4096    /// with `nvm`, `fnm`, or `volta` moves that prefix. The service is still
4097    /// registered, still set to start at boot, and starts nothing.
4098    Missing {
4099        /// The path the record names.
4100        recorded: PathBuf,
4101    },
4102    /// Something is at the recorded path, but it is not a file the service
4103    /// manager could start.
4104    NotExecutable {
4105        /// The path the record names.
4106        recorded: PathBuf,
4107        /// What is there instead.
4108        detail: String,
4109    },
4110    /// The record and the service manager name different binaries.
4111    Diverged {
4112        /// What the record says.
4113        recorded: PathBuf,
4114        /// What the service manager is registered to start.
4115        registered: PathBuf,
4116    },
4117}
4118
4119impl BinaryPath {
4120    /// Whether this is a state `service status` must report as an error.
4121    #[must_use]
4122    pub const fn is_error(&self) -> bool {
4123        !matches!(self, Self::Current { .. })
4124    }
4125
4126    /// The path the record names, whatever state it is in.
4127    #[must_use]
4128    pub fn recorded(&self) -> &Path {
4129        match self {
4130            Self::Current { path } => path,
4131            Self::Missing { recorded }
4132            | Self::NotExecutable { recorded, .. }
4133            | Self::Diverged { recorded, .. } => recorded,
4134        }
4135    }
4136}
4137
4138impl fmt::Display for BinaryPath {
4139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4140        match self {
4141            Self::Current { path } => write!(f, "{}", path.display()),
4142            Self::Missing { recorded } => write!(
4143                f,
4144                "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
4145                 package manager that moved the binary is the usual cause; an `npm i -g` \
4146                 installation moves with the active Node version. Run `service install` again \
4147                 from the binary that is now installed.",
4148                recorded.display()
4149            ),
4150            Self::NotExecutable { recorded, detail } => write!(
4151                f,
4152                "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
4153                 from the installed binary.",
4154                recorded.display()
4155            ),
4156            Self::Diverged {
4157                recorded,
4158                registered,
4159            } => write!(
4160                f,
4161                "{} -- STALE: the service manager is registered to start {} instead. Something \
4162                 has edited the registration since it was installed. Run `service uninstall` and \
4163                 `service install`; neither touches configuration, secrets, or the cache.",
4164                recorded.display(),
4165                registered.display()
4166            ),
4167        }
4168    }
4169}
4170
4171/// Decides what became of a recorded path.
4172///
4173/// `registered` is what the service manager says it will start, when the
4174/// platform can be asked. Order matters and is deliberate: **absence is checked
4175/// first**, because a missing binary is the failure item 6 exists for and an
4176/// operator hearing "the registration disagrees with the record" about a file
4177/// that is not there would be sent to the wrong problem.
4178#[must_use]
4179pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
4180    match std::fs::metadata(recorded) {
4181        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
4182            return BinaryPath::Missing {
4183                recorded: recorded.to_path_buf(),
4184            };
4185        }
4186        Err(error) => {
4187            return BinaryPath::NotExecutable {
4188                recorded: recorded.to_path_buf(),
4189                detail: format!("it cannot be inspected ({error})"),
4190            };
4191        }
4192        Ok(metadata) if !metadata.is_file() => {
4193            return BinaryPath::NotExecutable {
4194                recorded: recorded.to_path_buf(),
4195                detail: "what is there is not a file".to_string(),
4196            };
4197        }
4198        Ok(_) => {}
4199    }
4200    if let Some(registered) = registered
4201        && !same_path_text(&recorded.to_string_lossy(), &registered.to_string_lossy())
4202    {
4203        return BinaryPath::Diverged {
4204            recorded: recorded.to_path_buf(),
4205            registered: registered.to_path_buf(),
4206        };
4207    }
4208    BinaryPath::Current {
4209        path: recorded.to_path_buf(),
4210    }
4211}
4212
4213// ---------------------------------------------------------------------------
4214// The control seam
4215// ---------------------------------------------------------------------------
4216
4217/// What a service manager says about a registration it holds.
4218#[derive(Debug, Clone, PartialEq, Eq)]
4219pub struct Registration {
4220    /// Which manager holds it, which is also what decides the start mode:
4221    /// a Windows service is a boot registration and a Task Scheduler task is a
4222    /// login one; a LaunchDaemon and a system unit are boot, a LaunchAgent and
4223    /// a user unit are login.
4224    pub manager: DefinitionKind,
4225    /// The start mode the *domain* it was found in implies.
4226    pub start_mode: StartMode,
4227    /// The full command line, as the manager stores it.
4228    pub command_line: String,
4229    /// The account it runs under, spelled as [`ServiceAccount`] spells it, when
4230    /// the manager names a real account.
4231    ///
4232    /// `None` where the manager reports only a placeholder: a login
4233    /// registration that runs as whoever signs in, or a Task Scheduler `UserId`
4234    /// that is a raw SID. Status then reports the install record's account.
4235    pub account: Option<String>,
4236    /// Whether it is running now.
4237    pub running: bool,
4238    /// Whether it will start by itself.
4239    ///
4240    /// Distinct from [`Registration::start_mode`]: a Windows service can be
4241    /// registered in the boot domain and still be set to `demand` start, which
4242    /// is a service that exists, looks installed, and does not come back after
4243    /// a reboot.
4244    pub starts_automatically: bool,
4245    /// The restart-on-failure delay the manager reports, when it reports one.
4246    ///
4247    /// The *delay* rather than the whole [`RestartPolicy`], because that is the
4248    /// half every one of the four managers can be asked for. The failure-count
4249    /// reset window has no representation at all in Task Scheduler or launchd,
4250    /// so a `RestartPolicy` read back from them would carry one number the
4251    /// manager reported and one this module invented — and a comparison against
4252    /// an invented number is a comparison that cannot fail.
4253    pub restart_delay: Option<Duration>,
4254}
4255
4256impl Registration {
4257    /// The executable the manager will start, parsed out of the command line.
4258    #[must_use]
4259    pub fn binary(&self) -> Option<PathBuf> {
4260        executable_from_command_line(&self.command_line)
4261    }
4262}
4263
4264/// One platform's service manager, for one start-mode domain.
4265///
4266/// Every method takes the identity rather than storing it, so one control can
4267/// answer about the product's registration and about a test fixture's without
4268/// either being able to reach the other by accident.
4269pub trait ServiceControl: fmt::Debug {
4270    /// Which manager this is.
4271    fn manager(&self) -> DefinitionKind;
4272
4273    /// Registers the plan, returning the definition that was applied.
4274    ///
4275    /// # Errors
4276    ///
4277    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`].
4278    fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
4279
4280    /// Deregisters. Returns whether there was a registration to remove.
4281    ///
4282    /// **Removes the registration and nothing else.** No implementation of this
4283    /// method may delete a directory, a database, a secret, or a cache;
4284    /// `05-infrastructure.md` item 5 is a property of every backend, not a
4285    /// check somewhere above them.
4286    ///
4287    /// # Errors
4288    ///
4289    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`].
4290    fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
4291
4292    /// What the manager knows about this registration, if anything.
4293    ///
4294    /// # Errors
4295    ///
4296    /// [`ServiceError::Control`].
4297    fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
4298
4299    /// Starts it now, without waiting for the next boot or logon.
4300    ///
4301    /// # Errors
4302    ///
4303    /// [`ServiceError::Control`], [`ServiceError::NeedsElevation`], or
4304    /// [`ServiceError::NotInstalled`].
4305    fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
4306
4307    /// Stops it. Returns whether it was running.
4308    ///
4309    /// # Errors
4310    ///
4311    /// As [`ServiceControl::start`].
4312    fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
4313}
4314
4315/// Chooses the control for a start mode.
4316///
4317/// The indirection is what makes [`ServiceOperations`] testable: production
4318/// hands it [`HostControls`], every test in this file hands it
4319/// [`RecordingControls`], and neither knows the difference.
4320pub trait ControlFactory: fmt::Debug + Send + Sync {
4321    /// The control for one start-mode domain.
4322    ///
4323    /// # Errors
4324    ///
4325    /// [`ServiceError::Control`] when this host has no manager for that domain.
4326    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
4327
4328    /// The text of an installed definition file, for drift detection. `None`
4329    /// when there is none to read. The default reads nothing, so a double
4330    /// never reports the real host's unit as its own.
4331    fn installed_definition(&self, _path: &Path) -> Option<String> {
4332        None
4333    }
4334
4335    /// Whether this Mac signs somebody in after a restart, and whether
4336    /// FileVault holds it at the unlock screen. `None` where the platform has
4337    /// no such probe; the default probes nothing, for the same reason as
4338    /// [`Self::installed_definition`].
4339    fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
4340        None
4341    }
4342}
4343
4344/// The real service managers of the host this binary was built for.
4345#[derive(Debug, Clone, Copy, Default)]
4346pub struct HostControls;
4347
4348// ---------------------------------------------------------------------------
4349// The operations
4350// ---------------------------------------------------------------------------
4351
4352/// What `install` did.
4353#[derive(Debug, Clone)]
4354pub struct Installed {
4355    /// The plan that was applied, including the resolved absolute binary path.
4356    pub plan: InstallPlan,
4357    /// The definition the platform was given.
4358    pub definition: ServiceDefinition,
4359    /// The record that was written.
4360    pub record: InstallRecord,
4361    /// What the definition grants, measured against the requirement.
4362    pub review: PrivilegeReview,
4363    /// What was done to the runner root the registration will run jobs under.
4364    ///
4365    /// Named trustees rather than SIDs, so printing it adds no identity to the
4366    /// output. See [`crate::runner_root_access`].
4367    pub runner_root: RootAccessSummary,
4368    /// Whether this replaced a registration that was already there, rather than
4369    /// making a new one.
4370    ///
4371    /// Reported rather than glossed over: "Service installed" on a host that
4372    /// had one already reads as though nothing had been taken away and put
4373    /// back, and what was taken away and put back is the thing an operator
4374    /// watching a running agent wants to know about.
4375    pub replaced_existing: bool,
4376}
4377
4378/// What `uninstall` did — and, as importantly, what it did not.
4379#[derive(Debug, Clone, PartialEq, Eq)]
4380pub struct Uninstalled {
4381    /// Whether a registration was removed.
4382    pub removed_registration: bool,
4383    /// Whether the install record was removed.
4384    pub removed_record: bool,
4385    /// Whether a definition file was removed, and which.
4386    pub removed_definition: Option<PathBuf>,
4387    /// The directories that were **left exactly as they were**.
4388    ///
4389    /// `05-infrastructure.md` item 5 stated as a value rather than as a
4390    /// promise: `service uninstall` prints this list, so an operator can see
4391    /// that the configuration, the SQLite database, the stored token and the
4392    /// runner package cache are all still there.
4393    pub preserved: Vec<PathBuf>,
4394}
4395
4396impl fmt::Display for Uninstalled {
4397    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4398        if self.removed_registration {
4399            writeln!(f, "The service registration was removed.")?;
4400        } else {
4401            writeln!(f, "There was no service registration to remove.")?;
4402        }
4403        writeln!(f, "Nothing else was deleted. These are untouched:")?;
4404        for path in &self.preserved {
4405            writeln!(f, "  {}", path.display())?;
4406        }
4407        write!(
4408            f,
4409            "The stored GitHub token is untouched too; `auth logout` is what purges it."
4410        )
4411    }
4412}
4413
4414/// What `set_start_mode` did.
4415#[derive(Debug, Clone, PartialEq, Eq)]
4416pub struct StartModeChange {
4417    /// What it was.
4418    pub from: StartMode,
4419    /// What it is now.
4420    pub to: StartMode,
4421    /// `false` when the registration was already in the requested mode.
4422    pub changed: bool,
4423    /// The secret store the new mode obliges, so a caller can tell an operator
4424    /// whether the token has to move too.
4425    pub store_scope: crate::secrets::SecretScope,
4426    /// What the mode change did to the runner root's access control.
4427    ///
4428    /// A mode change moves the account the daemon runs as, and the runner root
4429    /// admits that account by name — so the two move together or the new
4430    /// registration cannot write its own workspaces.
4431    pub runner_root: RootAccessSummary,
4432}
4433
4434impl fmt::Display for StartModeChange {
4435    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4436        if !self.changed {
4437            return write!(f, "The service already starts at {}.", self.to);
4438        }
4439        write!(
4440            f,
4441            "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
4442             if the token was stored under the other scope, run `auth login` again. {}",
4443            self.to, self.from, self.store_scope, self.runner_root
4444        )
4445    }
4446}
4447
4448/// Install, uninstall, inspect, and switch start mode.
4449///
4450/// This is the whole of the library contract `f3` builds `service install`,
4451/// `service uninstall` and `service status` on. Nothing here prints; every
4452/// operation returns a value with a [`fmt::Display`] a command can write, so the
4453/// same facts are available to the TUI and to a JSON document without being
4454/// re-derived from text.
4455#[derive(Debug, Clone)]
4456pub struct ServiceOperations {
4457    paths: AppPaths,
4458    identity: ServiceIdentity,
4459    controls: std::sync::Arc<dyn ControlFactory>,
4460    runner_root: Option<LocalAbsolutePath>,
4461}
4462
4463impl ServiceOperations {
4464    /// Operates on this host's real service managers.
4465    #[must_use]
4466    pub fn on_this_host(paths: AppPaths) -> Self {
4467        Self::with_controls(
4468            paths,
4469            ServiceIdentity::product(),
4470            std::sync::Arc::new(HostControls),
4471        )
4472    }
4473
4474    /// Operates on the controls a caller supplies.
4475    ///
4476    /// Both other arguments are explicit for the same reason: the privileged
4477    /// installer tests register a real service under a
4478    /// [`ServiceIdentity::fixture`] name, against a disposable
4479    /// [`AppPaths::rooted_at`] tree, and must not be able to touch an operator's
4480    /// installation even by mistake.
4481    #[must_use]
4482    pub fn with_controls(
4483        paths: AppPaths,
4484        identity: ServiceIdentity,
4485        controls: std::sync::Arc<dyn ControlFactory>,
4486    ) -> Self {
4487        Self {
4488            paths,
4489            identity,
4490            controls,
4491            runner_root: None,
4492        }
4493    }
4494
4495    /// Points runner-root preparation at a directory the caller owns, for the
4496    /// **current account**.
4497    ///
4498    /// **Only a [`ServiceIdentity::fixture`] registration is allowed to move
4499    /// it, and the guard is here rather than at the call site.** A product
4500    /// registration ignores the override entirely and resolves
4501    /// [`crate::runner_root::default_runner_root`] itself, which is what keeps
4502    /// "custom roots are never re-ACLed" true no matter what a caller passes.
4503    ///
4504    /// It exists because the directory the product uses is
4505    /// `%SystemDrive%\rman`, and a smoke test that created and re-permissioned
4506    /// *that* would be editing the machine it runs on from outside its own
4507    /// fixture. So a fixture aims this at a temporary directory it created and
4508    /// will delete — and "and will delete" is why the override is always
4509    /// reconciled for the calling account rather than for the account the start
4510    /// mode obliges. A boot-mode root admits `SY` and `BA` only, which an
4511    /// ordinary filtered token is neither, so a test that supplied one could
4512    /// not then inspect or remove its own temporary directory.
4513    #[must_use]
4514    pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
4515        self.runner_root = Some(root);
4516        self
4517    }
4518
4519    /// The directories this operates against.
4520    #[must_use]
4521    pub const fn paths(&self) -> &AppPaths {
4522        &self.paths
4523    }
4524
4525    /// The registration this operates on.
4526    #[must_use]
4527    pub const fn identity(&self) -> &ServiceIdentity {
4528        &self.identity
4529    }
4530
4531    /// Registers `daemon run` with the operating system.
4532    ///
4533    /// The order is the contract, and each step is a requirement:
4534    ///
4535    /// 1. the four directories are created, because item 2 is about writing them;
4536    /// 2. **the single-instance lock is taken** — item 1. It is held for the
4537    ///    whole of the install rather than probed and released, so a daemon that
4538    ///    starts halfway through cannot end up racing a registration;
4539    /// 3. neither start-mode domain already holds a registration — item 6's
4540    ///    record would otherwise describe one of two;
4541    /// 4. the binary path is resolved and confirmed to be a file — item 6;
4542    /// 5. the platform registers it;
4543    /// 6. the record is written.
4544    ///
4545    /// # Errors
4546    ///
4547    /// [`ServiceError::LockHeld`] when an agent is already running,
4548    /// [`ServiceError::AlreadyInstalled`], [`ServiceError::BinaryMissing`], and
4549    /// whatever the platform reports.
4550    pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
4551        self.paths
4552            .create_all()
4553            .map_err(|source| ServiceError::Paths {
4554                source: Box::new(source),
4555            })?;
4556
4557        // Item 1. The guard lives until the end of this function; dropping it is
4558        // the release, so an early return releases it too.
4559        let _guard = self.refuse_while_an_agent_runs()?;
4560        let adopted = self.adopt_boot_installation(request.start_mode())?;
4561        // Replacing applies to this account's own registration only: over
4562        // another installation's it would re-register that service against
4563        // this account's directories.
4564        if !adopted {
4565            self.refuse_foreign("replace")?;
4566        }
4567
4568        // --------------------------------------------------------------------
4569        // AN INSTALL OVER THE SAME START MODE REPLACES; IT DOES NOT REFUSE.
4570        // --------------------------------------------------------------------
4571        // This used to refuse any existing registration, and that was wrong in
4572        // a way that took a real host down.
4573        //
4574        // `service install` is *also* how an operator moves to a new version by
4575        // hand — `install_owned_copy` says so, and the `.old` rename it does
4576        // exists precisely so that reinstalling over a **running** service
4577        // works. So the caller replaces the copy the service runs and *then*
4578        // asks to register it. Refusing at that point does not undo the swap:
4579        // the daemon is left running a binary nobody registered, the command
4580        // reports failure, and on macOS the new binary is a program the stored
4581        // credential's keychain grant does not name. Watched on 0.1.17: the
4582        // copy under `state/bin` was replaced at 00:16, `service install` said
4583        // "already registered", and the daemon crash-looped on `-25293` from
4584        // then on.
4585        //
4586        // Registering the same service twice is not what an operator is asking
4587        // for here and is not what they get: the platform is asked to drop the
4588        // registration and take it again, which is a deregister followed by a
4589        // register because that is the only thing launchd, systemd and the SCM
4590        // all support — `launchctl bootstrap` refuses a label already loaded.
4591        //
4592        // The *other* mode is still refused. Moving between boot and login
4593        // moves the registration between two service managers and changes the
4594        // account and the secret store with it; `set_start_mode` does that,
4595        // carefully, and an install is not the place for it.
4596        let (replacing, previous_was_running) = match self.find_registration()? {
4597            Some((existing, registration)) if existing == request.start_mode() => {
4598                (true, registration.running)
4599            }
4600            Some((existing, _)) => {
4601                return Err(ServiceError::AlreadyInstalled {
4602                    name: self.identity.name().to_string(),
4603                    existing,
4604                    requested: request.start_mode(),
4605                });
4606            }
4607            None => (false, false),
4608        };
4609
4610        let plan = InstallPlan::resolve(
4611            self.identity.clone(),
4612            request,
4613            ServiceDirectories::of(&self.paths),
4614        )?;
4615
4616        // Resolved before the runner root, and used after it: every step from
4617        // here on has to be able to undo the directory, and a `?` between the
4618        // preparation and the first fallible use would leave one behind with
4619        // nothing to say so.
4620        let control = self.controls.control(plan.start_mode())?;
4621
4622        // Item 8, and the one step that can refuse an otherwise valid install.
4623        // Before the platform is asked to register anything, because a
4624        // registration whose workspaces would land in a directory ordinary local
4625        // users can write is one that should never have existed — and because
4626        // undoing a directory is cheaper than undoing a service.
4627        let root = self.prepare_runner_root(plan.start_mode())?;
4628
4629        // Read before the registration is dropped, so that a replace which then
4630        // fails to take can put back what was there. A record this account
4631        // cannot read leaves nothing to reinstate, which is reported rather
4632        // than pretended: see `reinstate`.
4633        let previous = if replacing {
4634            InstallRecord::read(&self.paths).ok().flatten()
4635        } else {
4636            None
4637        };
4638        if replacing && let Err(cause) = control.uninstall(&self.identity) {
4639            return Err(undo_runner_root(&root, "install", &self.identity, cause));
4640        }
4641
4642        let definition = match control.install(&plan) {
4643            Ok(definition) => definition,
4644            Err(cause) => {
4645                // Nothing was deregistered on a first install, so there is
4646                // nothing to put back and `reinstate` says so with `Ok(())`.
4647                let restored =
4648                    self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running);
4649                return Err(rolled_back(
4650                    retained_runner_root(&root),
4651                    restored,
4652                    "install",
4653                    &self.identity,
4654                    cause,
4655                ));
4656            }
4657        };
4658        let review = review_least_privilege(&definition, &plan);
4659        let record = InstallRecord::of(&plan, &definition, Utc::now());
4660        if let Err(cause) = record.write(&self.paths) {
4661            let rollback = control
4662                .uninstall(&self.identity)
4663                .map(|_| ())
4664                .and_then(|()| {
4665                    self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4666                });
4667            return Err(rolled_back(
4668                retained_runner_root(&root),
4669                rollback,
4670                "install",
4671                &self.identity,
4672                cause,
4673            ));
4674        }
4675
4676        // Registration is not the postcondition of an install: a service the
4677        // current session still has to start by hand is only half-installed.
4678        // This matters most for a logon-triggered Windows task because its
4679        // trigger has already passed by the time an operator reinstalls it.
4680        // Start through the same control that registered the definition and
4681        // roll the whole replacement back if the manager refuses it.
4682        if let Err(cause) = control.start(&self.identity) {
4683            let remove_new = control.uninstall(&self.identity).map(|_| ());
4684            let restore_registration = remove_new.and_then(|()| {
4685                self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4686            });
4687            let restore_record = restore_registration.and_then(|()| match &previous {
4688                Some(previous) => previous.write(&self.paths),
4689                None => InstallRecord::remove(&self.paths).map(|_| ()),
4690            });
4691            return Err(rolled_back(
4692                retained_runner_root(&root),
4693                restore_record,
4694                "install and start",
4695                &self.identity,
4696                cause,
4697            ));
4698        }
4699        Ok(Installed {
4700            plan,
4701            definition,
4702            record,
4703            review,
4704            runner_root: root.summary().clone(),
4705            replaced_existing: replacing,
4706        })
4707    }
4708
4709    /// Puts back a registration this call deregistered in order to replace it.
4710    ///
4711    /// `Ok(())` when there was nothing to put back, which is the ordinary case:
4712    /// a first install deregisters nothing. When there *was* a record, the plan
4713    /// is rebuilt from it rather than from the request, because the request is
4714    /// the one that just failed.
4715    ///
4716    /// Best effort by construction — the caller passes the result to
4717    /// [`rolled_back`], which reports a failed rollback alongside the original
4718    /// cause rather than replacing it.
4719    fn reinstate(
4720        &self,
4721        control: &dyn ServiceControl,
4722        previous: Option<&InstallRecord>,
4723        was_running: bool,
4724    ) -> Result<(), ServiceError> {
4725        let Some(record) = previous else {
4726            return Ok(());
4727        };
4728        control.install(&record.plan(self.identity.clone()))?;
4729        if was_running {
4730            control.start(&self.identity)?;
4731        }
4732        Ok(())
4733    }
4734
4735    /// Deregisters, and deletes nothing else.
4736    ///
4737    /// # Errors
4738    ///
4739    /// Whatever the platform reports. A missing registration is **not** an
4740    /// error: `service uninstall` on a host that has none should say so and
4741    /// exit cleanly, because an operator running it twice has not made a
4742    /// mistake.
4743    pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
4744        let record = InstallRecord::read(&self.paths).ok().flatten();
4745        let removed_definition = record
4746            .as_ref()
4747            .and_then(|record| record.definition_path.clone());
4748
4749        let mut removed_registration = false;
4750        // Both domains, not only the recorded one. A record can be lost while a
4751        // registration survives, and leaving a registration behind because a
4752        // TOML file was deleted is exactly the state `uninstall` exists to end.
4753        //
4754        // Except another installation's: it is skipped, and when it is all
4755        // there is, the uninstall is refused and this account's record kept.
4756        let mut foreign = None;
4757        for mode in [StartMode::Boot, StartMode::Login] {
4758            let control = self.controls.control(mode)?;
4759            if let Some(directories) = control
4760                .query(&self.identity)
4761                .ok()
4762                .flatten()
4763                .and_then(|registration| self.foreign_directories(&registration))
4764            {
4765                foreign = Some(directories);
4766                continue;
4767            }
4768            if control.uninstall(&self.identity)? {
4769                removed_registration = true;
4770            }
4771        }
4772        if !removed_registration && let Some(directories) = foreign {
4773            return Err(self.foreign_refusal("uninstall", directories));
4774        }
4775        let removed_record = InstallRecord::remove(&self.paths)?;
4776        Ok(Uninstalled {
4777            removed_registration,
4778            removed_record,
4779            removed_definition: removed_definition.filter(|_| removed_registration),
4780            preserved: self
4781                .paths
4782                .all()
4783                .iter()
4784                .map(|(_, path)| (*path).to_path_buf())
4785                .collect(),
4786        })
4787    }
4788
4789    /// Switches between `boot` and `login` **without re-resolving anything**.
4790    ///
4791    /// `05-infrastructure.md` item 7. Everything the new registration carries —
4792    /// the absolute binary path, the arguments, the restart policy, the four
4793    /// directories — comes from the existing record, so the operator is not
4794    /// asked to reinstall the product and a binary that has since moved is not
4795    /// silently swapped for whichever one happens to be running this command.
4796    ///
4797    /// On Windows the registration necessarily moves between two different
4798    /// Windows facilities, because Windows has no service that starts at logon;
4799    /// see this module's documentation. That is still not a reinstall: no file
4800    /// is downloaded, replaced, or re-resolved.
4801    ///
4802    /// The caller must persist the new mode onto this host's `Host` record so
4803    /// that `host show` reports it — [`StartModeChange::to`] is the value, and
4804    /// `f1` already emits `host.service_start_mode` from that field.
4805    ///
4806    /// # Errors
4807    ///
4808    /// [`ServiceError::NotInstalled`] when there is no record to switch, and
4809    /// whatever the platform reports.
4810    pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
4811        let Some(record) = InstallRecord::read(&self.paths)? else {
4812            return Err(ServiceError::NotInstalled {
4813                name: self.identity.name().to_string(),
4814                operation: "switch the start mode of",
4815            });
4816        };
4817        let from = record.start_mode;
4818        if from == to {
4819            // Nothing moves, so nothing about the root's access control has to.
4820            // Reconciling it here would turn a no-op command into one that can
4821            // fail on a permission it does not need.
4822            return Ok(StartModeChange {
4823                from,
4824                to,
4825                changed: false,
4826                store_scope: crate::secrets::SecretScope::for_start_mode(to),
4827                runner_root: RootAccessSummary::NotApplicable,
4828            });
4829        }
4830
4831        // A real switch only: a no-op touches no service manager at all. Over
4832        // another installation's registration it would move that service.
4833        self.refuse_foreign("switch the start mode of")?;
4834
4835        #[cfg(windows)]
4836        let arguments = {
4837            let mut arguments = record.arguments.clone();
4838            arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
4839            if to == StartMode::Boot {
4840                arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
4841            }
4842            arguments
4843        };
4844        #[cfg(not(windows))]
4845        let arguments = record.arguments.clone();
4846        let plan = InstallPlan::unchecked(
4847            self.identity.clone(),
4848            to,
4849            record.binary.clone(),
4850            record.directories.clone(),
4851        )
4852        .with_arguments(arguments)
4853        .with_restart(record.restart());
4854        let plan = if record.starts_on_demand {
4855            plan.started_on_demand()
4856        } else {
4857            plan
4858        };
4859        let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
4860            Ok(store) => plan.with_secret_guard(store.guard()),
4861            Err(_) => plan,
4862        };
4863
4864        // Install the target domain before touching the live one. This makes a
4865        // failed target install a no-op from the operator's point of view and,
4866        // unlike uninstall-first ordering, never trades a working service for
4867        // an error message. Resolved before the runner root and used after it,
4868        // so that no `?` sits between preparing the directory and the first
4869        // step that knows how to undo it.
4870        let target = self.controls.control(to)?;
4871        // The domain being left, resolved here rather than where it is used for
4872        // the same reason: the only step that removes it runs after the runner
4873        // root has been prepared, and a `?` there would abandon the target
4874        // registration, the record and the directory without a word.
4875        let previous = self.controls.control(from)?;
4876
4877        // The account changes with the mode, and so must the account the runner
4878        // root admits: `04-security-recovery.md` requires the selected identity
4879        // to be *reconciled* when service mode changes, which means adding the
4880        // operator's on the way to login and dropping it again on the way back.
4881        // Before the target install, for the same reason `install` does it
4882        // first — a root that cannot be made safe must not produce a working
4883        // registration.
4884        let root = self.prepare_runner_root(to)?;
4885
4886        let definition = match target.install(&plan) {
4887            Ok(definition) => definition,
4888            Err(cause) => {
4889                return Err(undo_runner_root(
4890                    &root,
4891                    "switch start mode",
4892                    &self.identity,
4893                    cause,
4894                ));
4895            }
4896        };
4897        let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
4898        if let Err(cause) = next_record.write(&self.paths) {
4899            return Err(rolled_back(
4900                retained_runner_root(&root),
4901                target.uninstall(&self.identity),
4902                "switch start mode",
4903                &self.identity,
4904                cause,
4905            ));
4906        }
4907
4908        // Only after the target registration and its durable record exist is
4909        // it safe to remove the old domain. If that last step fails, remove the
4910        // target and put the old record back so status and reality agree.
4911        if let Err(cause) = previous.uninstall(&self.identity) {
4912            let target_rollback = target.uninstall(&self.identity);
4913            let record_rollback = record.write(&self.paths);
4914            return Err(rolled_back(
4915                retained_runner_root(&root),
4916                target_rollback.and(record_rollback),
4917                "switch start mode",
4918                &self.identity,
4919                cause,
4920            ));
4921        }
4922        Ok(StartModeChange {
4923            from,
4924            to,
4925            changed: true,
4926            store_scope: crate::secrets::SecretScope::for_start_mode(to),
4927            runner_root: root.summary().clone(),
4928        })
4929    }
4930
4931    /// Starts the registration now.
4932    ///
4933    /// # Errors
4934    ///
4935    /// [`ServiceError::NotInstalled`], or whatever the platform reports.
4936    pub fn start(&self) -> Result<(), ServiceError> {
4937        let mode = self.own_registration_mode("start")?;
4938        self.controls.control(mode)?.start(&self.identity)
4939    }
4940
4941    /// The domain holding this account's own registration, for `operation`.
4942    ///
4943    /// # Errors
4944    ///
4945    /// [`ServiceError::NotInstalled`] when there is none, and
4946    /// [`ServiceError::ForeignInstallation`] when the only one is another
4947    /// installation's.
4948    fn own_registration_mode(&self, operation: &'static str) -> Result<StartMode, ServiceError> {
4949        match self.registration_for_status()? {
4950            None => Err(ServiceError::NotInstalled {
4951                name: self.identity.name().to_string(),
4952                operation,
4953            }),
4954            Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
4955            Some((registration, None)) => Ok(registration.start_mode),
4956        }
4957    }
4958
4959    /// Stops the registration. Returns whether it was running.
4960    ///
4961    /// # Errors
4962    ///
4963    /// As [`ServiceOperations::start`].
4964    pub fn stop(&self) -> Result<bool, ServiceError> {
4965        let mode = self.own_registration_mode("stop")?;
4966        self.controls.control(mode)?.stop(&self.identity)
4967    }
4968
4969    /// Everything Journey 5 step 4 asks `service status` to report.
4970    ///
4971    /// # Errors
4972    ///
4973    /// [`ServiceError::Record`] when local state cannot be read, and whatever
4974    /// the platform reports. A *stale binary path* is deliberately **not** an
4975    /// error here: it is a reported state, because a status command that
4976    /// refused to print anything else would hide the very facts an operator
4977    /// needs in order to fix it.
4978    ///
4979    /// A record **this account may not read** is reported for the same reason
4980    /// and is the second exception. On a boot-mode host the record was written
4981    /// by `sudo service install`, and a version before this one wrote it `0600`
4982    /// — so the operator's own `service status` ended with `Permission denied`
4983    /// and printed nothing at all, on a host whose registration the service
4984    /// manager would have described perfectly well. What launchd, systemd or
4985    /// the SCM says is a separate fact from the record, and it is still worth
4986    /// having.
4987    ///
4988    /// # A registration made from another data root
4989    ///
4990    /// The service manager holds one registration per machine under one name,
4991    /// and every account on the machine sees it. When its command line names
4992    /// application-data directories that are not this account's, it is
4993    /// **another installation's service**, and comparing this account's
4994    /// install record against it would report a stale binary, a mismatched
4995    /// start mode and a secret store in the wrong scope, each with a remedy
4996    /// (`service uninstall`, `service install`, `auth login`) that, followed
4997    /// from this account, breaks a healthy service. So it is reported as what
4998    /// it is: see [`ForeignService`].
4999    pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
5000        match self.registration_for_status()? {
5001            Some((registration, Some(directories))) => {
5002                Ok(self.foreign_status(registration, directories))
5003            }
5004            found => {
5005                let (record, record_refused) = match InstallRecord::read(&self.paths) {
5006                    Ok(record) => (record, None),
5007                    Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
5008                    Err(error) => return Err(error),
5009                };
5010                self.status_at(
5011                    &self.paths,
5012                    record,
5013                    record_refused.as_ref(),
5014                    found.map(|(registration, _)| registration),
5015                )
5016            }
5017        }
5018    }
5019
5020    /// [`Self::status`] for the installation whose directories are `paths`,
5021    /// from the record already read there.
5022    fn status_at(
5023        &self,
5024        paths: &AppPaths,
5025        record: Option<InstallRecord>,
5026        record_refused: Option<&ServiceError>,
5027        found: Option<Registration>,
5028    ) -> Result<ServiceStatus, ServiceError> {
5029        let last_github_contact = last_github_contact(paths)?;
5030        let drift = record
5031            .as_ref()
5032            .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, record));
5033        let credential_rejected_since = github_credential_rejected_since(paths).ok().flatten();
5034        let credential_unreadable_since = credential_unreadable_since(paths).ok().flatten();
5035        let probed = self.controls.unattended_login();
5036        let unattended = record.as_ref().and_then(|record| {
5037            // The account is only read where something was probed to judge
5038            // it against.
5039            let account = probed.as_ref().map_or_else(String::new, |_| {
5040                found
5041                    .as_ref()
5042                    .and_then(|registration| registration.account.clone())
5043                    .unwrap_or_else(|| service_account_name(&record.account))
5044            });
5045            unattended_gap(record.start_mode, &account, probed.as_ref())
5046        });
5047        let now = Utc::now();
5048        let installed = record.is_some() || found.is_some();
5049        let status = ServiceStatus::compose(
5050            self.identity.clone(),
5051            record,
5052            record_refused,
5053            found,
5054            last_github_contact,
5055            paths,
5056        )
5057        .with_definition_drift(drift)
5058        .with_credential_rejection(credential_rejected_since)
5059        .with_credential_unreadable(credential_unreadable_since)
5060        .with_launches_blocked(crate::launch_health::launches_blocked(paths, now))
5061        .with_unattended_gap(unattended)
5062        .with_host_unfit(
5063            crate::host_fitness::host_unfit_in_force(paths, now).filter(|_| installed),
5064        );
5065        let restart = status.restart_command();
5066        Ok(status.with_stall(
5067            crate::daemon_heartbeat::liveness(paths, now).stall(last_github_contact, &restart),
5068        ))
5069    }
5070
5071    /// The installed definition, when it is not what this build renders for
5072    /// the recorded installation. See [`definition_drift`].
5073    ///
5074    /// # Errors
5075    ///
5076    /// Whatever reading the install record reports.
5077    pub fn definition_drift(&self) -> Result<Option<DefinitionDrift>, ServiceError> {
5078        Ok(InstallRecord::read(&self.paths)?
5079            .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, &record)))
5080    }
5081
5082    /// Writes the plist this build renders over an outdated one, then has a
5083    /// detached helper boot the job out and bootstrap it again from the new
5084    /// file.
5085    ///
5086    /// launchd reads a plist only when the job is bootstrapped. Rewriting the
5087    /// file changes nothing for a job that is already loaded, and a `KeepAlive`
5088    /// restart reuses the loaded copy. So the job has to be booted out, and
5089    /// that terminates the daemon that asked for it. The helper runs in a
5090    /// session of its own, which launchd does not kill with the job, and
5091    /// retries the bootstrap: launchd refuses it for a few seconds while the
5092    /// old job is still exiting (`5: Input/output error`, measured).
5093    ///
5094    /// Call this only from the daemon the job runs, and only while it holds no
5095    /// runner. It is an operation on the registration the daemon itself runs
5096    /// under, and it never changes the start mode.
5097    ///
5098    /// # Errors
5099    ///
5100    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`] when the
5101    /// plist cannot be written or the helper cannot be started. The loaded job
5102    /// is untouched in both cases.
5103    #[cfg(target_os = "macos")]
5104    pub fn reload_launchd_definition(&self, drift: &DefinitionDrift) -> Result<(), ServiceError> {
5105        // The record names the start mode; the account this process runs as has
5106        // to agree, or the record describes a registration other than the one
5107        // running, and root would end up rewriting an operator's LaunchAgent.
5108        // SAFETY: `getuid` reads the calling process's real user id and cannot
5109        // fail.
5110        let root = unsafe { libc::getuid() } == 0;
5111        if root != (drift.start_mode == StartMode::Boot) {
5112            return Err(ServiceError::Control {
5113                operation: "rewrite",
5114                name: self.identity.name().to_string(),
5115                manager: "launchd",
5116                detail: format!(
5117                    "the install record says --start-at {}, but this daemon runs as {}; the plist \
5118                     is left as it is",
5119                    drift.start_mode,
5120                    if root { "root" } else { "the operator" }
5121                ),
5122            });
5123        }
5124        replace_definition(self.identity.name(), drift)?;
5125        // The definition is now this build's, as after the WSL unit rewrite.
5126        // Best effort: the record says who wrote the plist and nothing reads it
5127        // to decide whether to repair.
5128        if let Ok(Some(mut record)) = InstallRecord::read(&self.paths) {
5129            record.installed_by_version = env!("CARGO_PKG_VERSION").to_string();
5130            let _ = record.write(&self.paths);
5131        }
5132        sys::reload_detached(&self.identity, drift.start_mode, &drift.path)
5133    }
5134
5135    /// Creates or reconciles the runner root this start mode's account needs.
5136    ///
5137    /// The account is not an argument: it is [`ServiceAccount::for_start_mode`],
5138    /// the same function the registration's own principal comes from, so the
5139    /// directory admits exactly the identity the definition registers and a mode
5140    /// change reconciles both together or neither.
5141    ///
5142    /// On macOS and Linux this is a no-op that returns
5143    /// [`RootAccessSummary::NotApplicable`]; see [`crate::runner_root_access`].
5144    fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
5145        #[cfg(not(windows))]
5146        {
5147            // `runner_root` is consumed here as well as in the Windows arm: a
5148            // field only one platform reads is a dead field on the other.
5149            let _ = (mode, &self.runner_root);
5150            Ok(RootAccessChange::not_applicable())
5151        }
5152        #[cfg(windows)]
5153        {
5154            let wrap = |source| ServiceError::RunnerRoot {
5155                source: Box::new(source),
5156            };
5157            // The test seam, and the two things allowed through it. `cfg!(test)`
5158            // is false in every shipped binary, so what a released build honours
5159            // is the fixture name alone — and a fixture name cannot be the
5160            // product's, which is what keeps a released `service install`
5161            // pointed at the platform default whatever a caller passes.
5162            //
5163            // An overridden root is always reconciled **for this account**,
5164            // which is the foreground admission — a real mode of the product
5165            // rather than a concession invented here. It has to be: a boot-mode
5166            // root admits `SY` and `BA` only, and a test process holding an
5167            // ordinary filtered token is neither, so it could not inspect the
5168            // temporary directory it just supplied nor delete it afterwards.
5169            // What that costs is that the *boot* descriptor is not proved
5170            // through this path; it is proved purely, by this module's
5171            // `the_runner_root_a_boot_registration_needs_admits_only_the_service`
5172            // and by `runner_root_access`'s own tests, and for real by the
5173            // privileged installer test, which runs elevated.
5174            if let Some(root) = self
5175                .runner_root
5176                .as_ref()
5177                .filter(|_| self.identity.is_fixture() || cfg!(test))
5178            {
5179                let admission = RootAdmission::of_this_account().map_err(wrap)?;
5180                return crate::runner_root_access::reconcile(&self.paths, root, &admission)
5181                    .map_err(wrap);
5182            }
5183
5184            let admission = match ServiceAccount::for_start_mode(mode) {
5185                // A boot registration runs as LocalSystem, which the constant
5186                // `SY` ace already names.
5187                ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
5188                // A login registration runs as this account, under a filtered
5189                // token in which Administrators is deny-only — so without an
5190                // ace of its own it would be admitted by nothing.
5191                ServiceAccount::InvokingUser | ServiceAccount::Root => {
5192                    RootAdmission::of_this_account().map_err(wrap)?
5193                }
5194            };
5195            crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
5196        }
5197    }
5198
5199    /// Takes the single-instance lock, or refuses with `d1`'s own message.
5200    fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
5201        HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
5202            |source| match source {
5203                held @ LockError::Held { .. } => ServiceError::LockHeld {
5204                    source: Box::new(held),
5205                },
5206                other => ServiceError::LockUnreadable {
5207                    source: Box::new(other),
5208                },
5209            },
5210        )
5211    }
5212
5213    /// The registration, in whichever domain holds it.
5214    fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
5215        for mode in [StartMode::Boot, StartMode::Login] {
5216            let control = self.controls.control(mode)?;
5217            if let Some(registration) = control.query(&self.identity)? {
5218                return Ok(Some((registration.start_mode, registration)));
5219            }
5220        }
5221        Ok(None)
5222    }
5223
5224    /// The registration [`Self::status`] describes, with the directories it
5225    /// runs against when they are not this account's: this account's own
5226    /// registration when either domain holds one, else whichever there is.
5227    ///
5228    /// [`Self::find_registration`] stops at the boot domain, so a boot service
5229    /// made from another data root would hide this account's own login
5230    /// registration. The other domain is asked only in that case, and a
5231    /// failure to ask it is not this status's failure.
5232    fn registration_for_status(
5233        &self,
5234    ) -> Result<Option<(Registration, Option<ServiceDirectories>)>, ServiceError> {
5235        let Some((mode, found)) = self.find_registration()? else {
5236            return Ok(None);
5237        };
5238        let Some(directories) = self.foreign_directories(&found) else {
5239            return Ok(Some((found, None)));
5240        };
5241        let other = match mode {
5242            StartMode::Boot => StartMode::Login,
5243            StartMode::Login => StartMode::Boot,
5244        };
5245        let own = self
5246            .controls
5247            .control(other)
5248            .ok()
5249            .and_then(|control| control.query(&self.identity).ok().flatten())
5250            .filter(|registration| self.foreign_directories(registration).is_none());
5251        Ok(Some(match own {
5252            Some(own) => (own, None),
5253            None => (found, Some(directories)),
5254        }))
5255    }
5256
5257    /// Moves a Linux boot service registered against another tree onto this
5258    /// one, which for `root` is the system tree. Returns whether it did.
5259    ///
5260    /// `service install` calls this before it opens anything in this tree, so
5261    /// that the copy lands in an empty one; the install itself calls it again,
5262    /// which then only confirms the registration is to be replaced.
5263    ///
5264    /// Before the system tree, a boot service ran against whichever tree
5265    /// `sudo` resolved: `/root`'s on some distributions, the operator's own on
5266    /// others. A boot unit with no capabilities cannot open the operator's, and
5267    /// `ProtectHome=` hides both, so the service is stopped, its configuration
5268    /// and state are copied here unless this tree already has its own, and the
5269    /// install then replaces the registration. The old tree is left as it was.
5270    ///
5271    /// # Errors
5272    ///
5273    /// Whatever stopping the old service or copying its tree reports.
5274    pub fn adopt_boot_installation(&self, mode: StartMode) -> Result<bool, ServiceError> {
5275        #[cfg(target_os = "linux")]
5276        return self.adopt_boot_installation_on_linux(mode);
5277        #[cfg(not(target_os = "linux"))]
5278        {
5279            let _ = mode;
5280            Ok(false)
5281        }
5282    }
5283
5284    #[cfg(target_os = "linux")]
5285    fn adopt_boot_installation_on_linux(&self, mode: StartMode) -> Result<bool, ServiceError> {
5286        if mode != StartMode::Boot {
5287            return Ok(false);
5288        }
5289        self.refuse_overridden_account()?;
5290        if !crate::host_fitness::is_elevated() {
5291            return Ok(false);
5292        }
5293        let Some((StartMode::Boot, found)) = self.find_registration()? else {
5294            return Ok(false);
5295        };
5296        let Some(old) = self.foreign_directories(&found) else {
5297            return Ok(false);
5298        };
5299        self.controls
5300            .control(StartMode::Boot)?
5301            .stop(&self.identity)?;
5302        // No service was ever installed against this tree, so whatever is in it
5303        // is what an interrupted attempt or a stray command left: set aside,
5304        // never deleted, in favour of the data the running service used. A
5305        // tree with its own install record keeps it.
5306        if matches!(InstallRecord::read(&self.paths), Ok(None)) {
5307            let stamp = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
5308            for (from, to, skip) in [
5309                (&old.config, self.paths.config_dir(), None),
5310                // `service install` copies the binary in again.
5311                (&old.state, self.paths.state_dir(), Some("bin")),
5312            ] {
5313                set_aside(to, &stamp)
5314                    .and_then(|()| copy_tree(from, to, skip))
5315                    .map_err(|error| ServiceError::Control {
5316                        operation: "move the data of",
5317                        name: self.identity.name().to_string(),
5318                        manager: "systemd",
5319                        detail: format!(
5320                            "cannot copy {} to {}: {error}",
5321                            from.display(),
5322                            to.display()
5323                        ),
5324                    })?;
5325            }
5326        }
5327        Ok(true)
5328    }
5329
5330    /// Refuses a Linux boot install over a unit an administrator's drop-in made
5331    /// run as another account.
5332    ///
5333    /// A drop-in outlives the unit file `service install` rewrites, so the new
5334    /// unit would run as that account too, and that account cannot open the
5335    /// system tree `root` owns. Said before anything is stopped or copied.
5336    #[cfg(target_os = "linux")]
5337    fn refuse_overridden_account(&self) -> Result<(), ServiceError> {
5338        let Some((StartMode::Boot, found)) = self.find_registration()? else {
5339            return Ok(());
5340        };
5341        match found.account.as_deref() {
5342            Some(account) if account != ServiceAccount::Root.as_str() => {
5343                Err(ServiceError::Control {
5344                    operation: "install",
5345                    name: self.identity.name().to_string(),
5346                    manager: "systemd",
5347                    detail: format!(
5348                        "a drop-in under {SYSTEMD_SYSTEM_DIR}/{}.d/ makes it run as {account}, and \
5349                         a boot service must run as root to use {}. Remove `User=` and `Group=` \
5350                         from that drop-in, run `sudo systemctl daemon-reload`, and install again",
5351                        self.identity.systemd_unit(),
5352                        crate::paths::SYSTEM_DATA_ROOT,
5353                    ),
5354                })
5355            }
5356            _ => Ok(()),
5357        }
5358    }
5359
5360    /// Refuses `operation` when the machine's registration is another
5361    /// installation's. See [`ServiceError::ForeignInstallation`].
5362    fn refuse_foreign(&self, operation: &'static str) -> Result<(), ServiceError> {
5363        match self.registration_for_status()? {
5364            Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
5365            _ => Ok(()),
5366        }
5367    }
5368
5369    /// [`ServiceError::ForeignInstallation`] for the installation at
5370    /// `directories`.
5371    fn foreign_refusal(
5372        &self,
5373        operation: &'static str,
5374        directories: ServiceDirectories,
5375    ) -> ServiceError {
5376        let foreign = self.foreign_service_at(directories);
5377        ServiceError::ForeignInstallation {
5378            name: self.identity.name().to_string(),
5379            operation,
5380            installed_by: foreign.installed_by(),
5381            act_as: foreign.act_as(),
5382        }
5383    }
5384
5385    /// The directories `registration` runs against, when they are not this
5386    /// account's. See [`ForeignService`].
5387    fn foreign_directories(&self, registration: &Registration) -> Option<ServiceDirectories> {
5388        ServiceDirectories::from_command_line(&registration.command_line)
5389            .filter(|directories| !same_directory(&directories.config, self.paths.config_dir()))
5390    }
5391
5392    /// The machine's service and the installation it belongs to, when that is
5393    /// not this account's, without reading anything of that installation's
5394    /// beyond its path. `None` when the service is this account's, when there
5395    /// is none, or when the service manager cannot be asked.
5396    #[must_use]
5397    pub fn foreign_service(&self) -> Option<ForeignService> {
5398        let (_, directories) = self.registration_for_status().ok()??;
5399        Some(self.foreign_service_at(directories?))
5400    }
5401
5402    /// The [`ForeignService`] for an installation at `directories`, before
5403    /// anything of its own is read.
5404    fn foreign_service_at(&self, directories: ServiceDirectories) -> ForeignService {
5405        let root = directories.root();
5406        let home = host_home();
5407        // This account's own installation under other directories, when the
5408        // four are exactly a layout a command of this account resolves.
5409        let this_account = home
5410            .as_deref()
5411            .filter(|home| contains_directory(home, &root))
5412            .and_then(|_| {
5413                if directories == ServiceDirectories::of(&AppPaths::rooted_at(&root)) {
5414                    Some(format!(
5415                        "with `--data-dir {}`",
5416                        quote_argument(&root.to_string_lossy())
5417                    ))
5418                } else {
5419                    AppPaths::discover()
5420                        .ok()
5421                        .filter(|standard| directories == ServiceDirectories::of(standard))
5422                        .map(|_| "without `--data-dir`".to_owned())
5423                }
5424            });
5425        ForeignService {
5426            owner: data_root_owner(&root, home.as_deref()),
5427            this_account,
5428            root,
5429            directories,
5430            local_root: ServiceDirectories::of(&self.paths).root(),
5431            local_record: InstallRecord::path(&self.paths),
5432            unused_local_record: InstallRecord::read(&self.paths).ok().flatten(),
5433            unreadable: None,
5434        }
5435    }
5436
5437    /// [`Self::status`] for a registration made from another data root.
5438    ///
5439    /// What that installation recorded is read when this account may read it,
5440    /// and the status is then exactly the one its owner would see. When it may
5441    /// not, the status holds what the service manager says and nothing it
5442    /// would have to guess.
5443    fn foreign_status(
5444        &self,
5445        registration: Registration,
5446        directories: ServiceDirectories,
5447    ) -> ServiceStatus {
5448        let service_paths = directories.paths();
5449        let mut foreign = self.foreign_service_at(directories);
5450        let unreadable = match InstallRecord::read(&service_paths) {
5451            Ok(Some(record)) => {
5452                match self.status_at(
5453                    &service_paths,
5454                    Some(record),
5455                    None,
5456                    Some(registration.clone()),
5457                ) {
5458                    Ok(status) => return status.with_foreign(foreign),
5459                    Err(error) => error.to_string(),
5460                }
5461            }
5462            Ok(None) => format!(
5463                "there is no install record at {}",
5464                InstallRecord::path(&service_paths).display()
5465            ),
5466            // Not the error's own words: `RecordNotPermitted` and
5467            // `RecordUnreadable` both end in a `service install` remedy, which
5468            // from this account would re-register the machine's service
5469            // against the wrong directories.
5470            Err(
5471                ServiceError::RecordNotPermitted { path, detail }
5472                | ServiceError::RecordUnreadable { path, detail }
5473                | ServiceError::Record { path, detail, .. },
5474            ) => format!("this account cannot read {} ({detail})", path.display()),
5475            Err(error) => error.to_string(),
5476        };
5477        foreign.unreadable = Some(unreadable);
5478        ServiceStatus::foreign_unread(self.identity.clone(), registration, foreign)
5479    }
5480
5481    /// Moves this account's own configuration aside, when no registration on
5482    /// this machine runs against it: `host forget-local`.
5483    ///
5484    /// Everything in the configuration directory -- the install record, the
5485    /// database with its host row and policies, `runner.env` and the rest -- is
5486    /// **renamed** into a `forgotten-<timestamp>` directory beside it, never
5487    /// deleted, so moving it back restores it exactly. The state, runtime and
5488    /// log directories are left alone even where the platform puts them inside
5489    /// the configuration directory (macOS does), and so is every secret store
5490    /// and every directory a registration names.
5491    ///
5492    /// Both domains are asked, strictly: a domain that cannot be asked might
5493    /// hold this account's own registration, so it refuses rather than reads
5494    /// as empty.
5495    ///
5496    /// # Errors
5497    ///
5498    /// [`ServiceError::LocalConfigurationInUse`] when a registration on this
5499    /// machine runs against these directories, when a registration from
5500    /// another data root names a directory overlapping them, or when an agent
5501    /// holds this account's single-instance lock. [`ServiceError::Record`] when
5502    /// the move fails; whatever had already moved is put back first, and
5503    /// anything that could not be put back is named.
5504    pub fn forget_local(&self, at: DateTime<Utc>) -> Result<ForgottenLocal, ServiceError> {
5505        let config = self.paths.config_dir();
5506        let in_use = |detail: String| ServiceError::LocalConfigurationInUse { detail };
5507        let mut service_root = None;
5508        for mode in [StartMode::Boot, StartMode::Login] {
5509            let registration = self
5510                .controls
5511                .control(mode)
5512                .and_then(|control| control.query(&self.identity))
5513                .map_err(|error| {
5514                    in_use(format!(
5515                        "whether a {mode} registration runs against it could not be read ({error})"
5516                    ))
5517                })?;
5518            let Some(registration) = registration else {
5519                continue;
5520            };
5521            let Some(directories) = self.foreign_directories(&registration) else {
5522                return Err(in_use(format!(
5523                    "{} holds a registration that runs against {}. `service status` describes it",
5524                    registration.manager,
5525                    config.display()
5526                )));
5527            };
5528            if let Some(overlap) = directories.all().into_iter().find(|directory| {
5529                contains_directory(directory, config) || contains_directory(config, directory)
5530            }) {
5531                return Err(in_use(format!(
5532                    "the machine's service runs against {}, which overlaps {}",
5533                    overlap.display(),
5534                    config.display()
5535                )));
5536            }
5537            service_root.get_or_insert_with(|| directories.root());
5538        }
5539        let _lock =
5540            HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(|source| {
5541                in_use(format!(
5542                    "an agent may be running against these directories: {source}"
5543                ))
5544            })?;
5545        let failed = |operation: &'static str, path: &Path| {
5546            let path = path.to_path_buf();
5547            move |error: std::io::Error| ServiceError::Record {
5548                operation,
5549                path,
5550                detail: error.to_string(),
5551            }
5552        };
5553        // Never the agent's own directories, wherever the platform nests them.
5554        let kept = [
5555            self.paths.state_dir(),
5556            self.paths.runtime_dir(),
5557            self.paths.logs_dir(),
5558        ];
5559        let mut entries = Vec::new();
5560        for entry in std::fs::read_dir(config).map_err(failed("list", config))? {
5561            let entry = entry.map_err(failed("list", config))?.path();
5562            let archived = entry
5563                .file_name()
5564                .is_some_and(|name| name.to_string_lossy().starts_with(FORGOTTEN_PREFIX));
5565            if !archived && !kept.iter().any(|kept| contains_directory(&entry, kept)) {
5566                entries.push(entry);
5567            }
5568        }
5569        if entries.is_empty() {
5570            return Ok(ForgottenLocal {
5571                archive: None,
5572                moved: entries,
5573                service_root,
5574            });
5575        }
5576        entries.sort();
5577        let archive = config.join(format!("{FORGOTTEN_PREFIX}{}", at.format("%Y%m%dT%H%M%SZ")));
5578        std::fs::create_dir(&archive).map_err(failed("create", &archive))?;
5579        let inside = |entry: &Path| archive.join(entry.file_name().unwrap_or_default());
5580        for (index, entry) in entries.iter().enumerate() {
5581            if let Err(error) = std::fs::rename(entry, inside(entry)) {
5582                let stranded: Vec<String> = entries[..index]
5583                    .iter()
5584                    .rev()
5585                    .filter(|done| std::fs::rename(inside(done), done).is_err())
5586                    .map(|done| inside(done).display().to_string())
5587                    .collect();
5588                let _ = std::fs::remove_dir(&archive);
5589                let mut detail = error.to_string();
5590                if !stranded.is_empty() {
5591                    detail.push_str(&format!(
5592                        "; these could not be put back and are still in the archive: {}",
5593                        stranded.join(", ")
5594                    ));
5595                }
5596                return Err(ServiceError::Record {
5597                    operation: "archive",
5598                    path: entry.clone(),
5599                    detail,
5600                });
5601            }
5602        }
5603        Ok(ForgottenLocal {
5604            archive: Some(archive),
5605            moved: entries,
5606            service_root,
5607        })
5608    }
5609}
5610
5611/// The name every `host forget-local` archive starts with. A later run leaves
5612/// earlier archives where they are.
5613pub const FORGOTTEN_PREFIX: &str = "forgotten-";
5614
5615/// What [`ServiceOperations::forget_local`] did.
5616#[derive(Debug, Clone, PartialEq, Eq)]
5617pub struct ForgottenLocal {
5618    /// Where the configuration went, or `None` when there was nothing to move.
5619    pub archive: Option<PathBuf>,
5620    /// What was moved, by its old path.
5621    pub moved: Vec<PathBuf>,
5622    /// The data root of the machine's service, when one runs from another
5623    /// installation. Named so the operator can see it was not touched.
5624    pub service_root: Option<PathBuf>,
5625}
5626
5627impl fmt::Display for ForgottenLocal {
5628    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5629        let Some(archive) = &self.archive else {
5630            return write!(
5631                f,
5632                "Nothing to archive: this account's configuration directory is empty."
5633            );
5634        };
5635        writeln!(f, "Archived this account's unused local configuration")?;
5636        writeln!(f, "  archive                   {}", archive.display())?;
5637        for path in &self.moved {
5638            writeln!(f, "  moved                     {}", path.display())?;
5639        }
5640        if let Some(root) = &self.service_root {
5641            writeln!(
5642                f,
5643                "  service data root         {} (untouched)",
5644                root.display()
5645            )?;
5646        }
5647        write!(
5648            f,
5649            "  restore                   move everything in the archive back into its parent \
5650             directory"
5651        )
5652    }
5653}
5654
5655/// Whether two directory spellings name the same place: the same text on
5656/// this platform's terms, or the same canonical directory.
5657fn same_directory(left: &Path, right: &Path) -> bool {
5658    let text = |path: &Path| {
5659        path.to_string_lossy()
5660            .trim_end_matches(['/', '\\'])
5661            .to_owned()
5662    };
5663    same_path_text(&text(left), &text(right))
5664        || matches!(
5665            (std::fs::canonicalize(left), std::fs::canonicalize(right)),
5666            (Ok(left), Ok(right)) if left == right
5667        )
5668}
5669
5670/// Where the remedy starts in a [`StatusProblem`]'s detail: the sentence
5671/// openings every builder above uses for it.
5672const REMEDY_OPENINGS: [&str; 8] = [
5673    ". Run ",
5674    ". If nothing is waiting",
5675    ". Re-register",
5676    ". Switch the start mode",
5677    " Sign in again",
5678    ". Fix: ",
5679    "; to do it now",
5680    "; if the service manager denies",
5681];
5682
5683/// A problem's diagnosis without its remedy. See
5684/// [`ServiceStatus::with_foreign`].
5685///
5686/// A detail that still names a command that changes the registration or the
5687/// credential after the cut -- a builder this list does not know yet -- is
5688/// reduced to the first clause rather than passed on, because the cost of
5689/// showing too little is a shorter sentence and the cost of showing too much
5690/// is a broken service.
5691fn diagnosis_only(detail: &str) -> String {
5692    let cut = REMEDY_OPENINGS
5693        .iter()
5694        .filter_map(|opening| detail.find(opening))
5695        .min()
5696        .unwrap_or(detail.len());
5697    let diagnosis = detail[..cut].trim_end_matches(['.', ' ']);
5698    let names_a_change = |text: &str| {
5699        [
5700            "service install",
5701            "service uninstall",
5702            "auth login",
5703            "launchctl",
5704            "systemctl",
5705        ]
5706        .iter()
5707        .any(|command| text.contains(command))
5708    };
5709    if !names_a_change(diagnosis) {
5710        return diagnosis.to_owned();
5711    }
5712    // Clause ends followed by a space, so a `.` inside a path does not cut.
5713    let end = [", ", "; ", ". "]
5714        .iter()
5715        .filter_map(|separator| diagnosis.find(separator))
5716        .min()
5717        .unwrap_or(diagnosis.len());
5718    let first = &diagnosis[..end];
5719    if names_a_change(first) {
5720        "the installation's own records report this".to_owned()
5721    } else {
5722        first.to_owned()
5723    }
5724}
5725
5726/// A path's components as this platform compares them: case-insensitively
5727/// on Windows.
5728fn path_key(path: &Path) -> Vec<String> {
5729    path.components()
5730        .map(|component| {
5731            let text = component.as_os_str().to_string_lossy();
5732            if cfg!(windows) {
5733                text.to_lowercase()
5734            } else {
5735                text.into_owned()
5736            }
5737        })
5738        .collect()
5739}
5740
5741/// Whether `inner` is `outer` or a directory under it, by the rule
5742/// [`same_directory`] uses: on this platform's terms, or canonically.
5743fn contains_directory(outer: &Path, inner: &Path) -> bool {
5744    path_key(inner).starts_with(&path_key(outer))
5745        || matches!(
5746            (std::fs::canonicalize(outer), std::fs::canonicalize(inner)),
5747            (Ok(outer), Ok(inner)) if inner.starts_with(&outer)
5748        )
5749}
5750
5751/// The account whose data root `root` is, when the path says: the folder
5752/// under the directory that holds this account's `home`, `root` for root's
5753/// home on Unix, and `LocalSystem` for its profile on Windows. `None` when
5754/// the path names nobody, which is reported as the path alone.
5755#[must_use]
5756pub fn data_root_owner(root: &Path, home: Option<&Path>) -> Option<String> {
5757    let target = path_key(root);
5758    if cfg!(windows) {
5759        if target
5760            .windows(3)
5761            .any(|run| run == ["system32", "config", "systemprofile"])
5762        {
5763            return Some("LocalSystem".to_owned());
5764        }
5765    } else if [
5766        "/root",
5767        "/var/root",
5768        "/private/var/root",
5769        crate::paths::SYSTEM_DATA_ROOT,
5770    ]
5771    .iter()
5772    .any(|home| root.starts_with(home))
5773    {
5774        return Some("root".to_owned());
5775    }
5776    let users = path_key(home?.parent()?);
5777    // A home directly under the root (`/root` under `sudo`) names no directory
5778    // of homes: every path would then "belong" to its first component.
5779    if users.len() < 2 {
5780        return None;
5781    }
5782    if target.len() > users.len() && target[..users.len()] == users[..] {
5783        root.components()
5784            .nth(users.len())
5785            .map(|name| name.as_os_str().to_string_lossy().into_owned())
5786    } else {
5787        None
5788    }
5789}
5790
5791/// A registration whose command line names application-data directories that
5792/// are not this account's: **another installation's service**.
5793///
5794/// Every account on a machine sees the one registration its service manager
5795/// holds under the product's name. A second account -- one that ran a
5796/// login-mode install once, say -- has an install record and a database of its
5797/// own, and comparing them against the other installation's registration used
5798/// to report a stale binary, a mismatched start mode and a secret store in the
5799/// wrong scope, with `service uninstall`, `service install` and `auth login`
5800/// as the remedies. Followed from that account, each of them breaks or
5801/// replaces a service that is healthy. So the registration is reported as the
5802/// other installation's, with what can be read of it, and this account's own
5803/// record as the unused configuration it is.
5804#[derive(Debug, Clone, PartialEq, Eq)]
5805pub struct ForeignService {
5806    /// The directories its command line names.
5807    pub directories: ServiceDirectories,
5808    /// The data root they sit under. See [`ServiceDirectories::root`].
5809    pub root: PathBuf,
5810    /// The account that data root belongs to, when its path says.
5811    pub owner: Option<String>,
5812    /// How a command run by this account reaches the installation, when it is
5813    /// this account's own under other directories -- an install made with
5814    /// `--data-dir`, seen from a command run without it, or the other way
5815    /// round. `None` for another account's.
5816    pub this_account: Option<String>,
5817    /// This account's own data root, which no registration runs against.
5818    pub local_root: PathBuf,
5819    /// Where this account's own install record is, or would be.
5820    pub local_record: PathBuf,
5821    /// This account's own install record, from an earlier install.
5822    pub unused_local_record: Option<InstallRecord>,
5823    /// Why the service's own records could not be read, or `None` when they
5824    /// were and the status describes them.
5825    pub unreadable: Option<String>,
5826}
5827
5828impl ForeignService {
5829    /// How to see the service's own details: as its owner, or elevated.
5830    #[must_use]
5831    pub fn where_to_look(&self) -> String {
5832        if let Some(reach) = &self.this_account {
5833            return reach.clone();
5834        }
5835        let elevated = if cfg!(windows) {
5836            "from an elevated terminal (Run as administrator)"
5837        } else {
5838            "with sudo"
5839        };
5840        match &self.owner {
5841            Some(owner) if owner != "LocalSystem" && owner != "root" => {
5842                format!("{elevated}, or signed in as {owner}")
5843            }
5844            _ => elevated.to_owned(),
5845        }
5846    }
5847
5848    /// Where a change to the service has to be made from: the installation's
5849    /// own account, because every remedy a command prints acts on the
5850    /// directories of whoever runs it. Elevation alone reads the service's
5851    /// records but is still this account.
5852    #[must_use]
5853    pub fn act_as(&self) -> String {
5854        if let Some(reach) = &self.this_account {
5855            return reach.clone();
5856        }
5857        match &self.owner {
5858            Some(owner) if owner != "LocalSystem" && owner != "root" => {
5859                format!("signed in as {owner}")
5860            }
5861            _ => format!(
5862                "from the account whose data root {} is",
5863                self.root.display()
5864            ),
5865        }
5866    }
5867
5868    /// Whether the service's own records could not be read, so only what the
5869    /// service manager says is known.
5870    #[must_use]
5871    pub const fn is_unread(&self) -> bool {
5872        self.unreadable.is_some()
5873    }
5874
5875    /// Who installed it, for a sentence.
5876    #[must_use]
5877    pub fn installed_by(&self) -> String {
5878        match &self.owner {
5879            Some(owner) => format!("{owner}'s data root {}", self.root.display()),
5880            None => format!("the data root {}", self.root.display()),
5881        }
5882    }
5883
5884    /// The data root and its owner, for a field: `<root> (<owner>)`.
5885    #[must_use]
5886    pub fn label(&self) -> String {
5887        match &self.owner {
5888            Some(owner) => format!("{} ({owner})", self.root.display()),
5889            None => self.root.display().to_string(),
5890        }
5891    }
5892
5893    /// The note describing this account's own record, when there is one.
5894    #[must_use]
5895    pub fn unused_local_note(&self) -> Option<String> {
5896        let record = self.unused_local_record.as_ref()?;
5897        Some(format!(
5898            "unused local configuration from an earlier install: {} records a {} registration \
5899             made by runner-manager {} on {}, and no service on this machine runs against it. \
5900             `runner-manager host forget-local` moves it aside; it never touches the service's \
5901             own directories or any secret.",
5902            self.local_record.display(),
5903            record.start_mode,
5904            record.installed_by_version,
5905            record.installed_at.format("%Y-%m-%d"),
5906        ))
5907    }
5908}
5909
5910// ---------------------------------------------------------------------------
5911// Status
5912// ---------------------------------------------------------------------------
5913
5914/// One thing `service status` has to report as wrong.
5915#[derive(Debug, Clone, PartialEq, Eq)]
5916pub struct StatusProblem {
5917    /// What it is about.
5918    pub subject: &'static str,
5919    /// What is wrong, and what to do.
5920    pub detail: String,
5921}
5922
5923impl fmt::Display for StatusProblem {
5924    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5925        write!(f, "{}: {}", self.subject, self.detail)
5926    }
5927}
5928
5929/// Everything `service status` reports.
5930///
5931/// Journey 5 step 4 names four of these — the start mode, the resolved binary
5932/// path, the diagnostic log path, and the last successful GitHub contact — and
5933/// the release gate adds the fifth: *"`service status` reports a stale binary
5934/// path as an error rather than appearing healthy"*. [`Self::is_healthy`] is
5935/// what an exit code should be derived from, and it is false whenever
5936/// [`Self::problems`] is non-empty.
5937#[derive(Debug, Clone)]
5938pub struct ServiceStatus {
5939    identity: ServiceIdentity,
5940    record: Option<InstallRecord>,
5941    registration: Option<Registration>,
5942    binary: Option<BinaryPath>,
5943    log_file: PathBuf,
5944    store: Option<crate::secrets::ActiveStore>,
5945    last_github_contact: Option<DateTime<Utc>>,
5946    runner_root: Option<(PathBuf, RootAccessReport)>,
5947    credential_rejected_since: Option<DateTime<Utc>>,
5948    launches_blocked: Option<crate::launch_health::LaunchesBlocked>,
5949    unattended_gap: Option<UnattendedGap>,
5950    foreign: Option<ForeignService>,
5951    problems: Vec<StatusProblem>,
5952    notes: Vec<String>,
5953}
5954
5955/// Why a registration does not come back by itself after an unattended
5956/// restart, and what an operator can do about it.
5957#[derive(Debug, Clone, PartialEq, Eq)]
5958pub struct UnattendedGap {
5959    /// One or more sentences, the way `service status` prints them.
5960    pub reason: String,
5961    /// What changes it. Never something this product does by itself.
5962    pub remedy: String,
5963}
5964
5965/// The name of the account a registration runs as: the invoking user's own
5966/// for a login-mode one.
5967#[must_use]
5968pub fn service_account_name(account: &ServiceAccount) -> String {
5969    match account {
5970        // Under `sudo` the invoking user is the one sudo names, not root.
5971        ServiceAccount::InvokingUser => std::env::var("SUDO_USER")
5972            .ok()
5973            .filter(|user| !user.is_empty() && crate::host_fitness::is_elevated())
5974            .or_else(crate::unattended_login::current_account)
5975            .unwrap_or_else(|| "the operator".to_owned()),
5976        other => other.as_str().to_owned(),
5977    }
5978}
5979
5980/// The account `registration` names, which an administrator can change from
5981/// the one the install record saved, and otherwise the record's.
5982fn registered_account<'a>(registration: Option<&'a Registration>, recorded: &'a str) -> &'a str {
5983    registration
5984        .and_then(|registration| registration.account.as_deref())
5985        .unwrap_or(recorded)
5986}
5987
5988/// A Windows service account as `QueryServiceConfigW` names it, spelled the
5989/// way [`ServiceAccount`] does, so a service nobody edited reports the account
5990/// the record holds.
5991#[cfg(any(windows, test))]
5992fn windows_service_account(name: &str) -> String {
5993    if name.eq_ignore_ascii_case("LocalSystem") || name.eq_ignore_ascii_case(".\\LocalSystem") {
5994        ServiceAccount::LocalSystem.as_str().to_string()
5995    } else {
5996        name.to_string()
5997    }
5998}
5999
6000/// A Task Scheduler `UserId`, unless it is a raw SID, which names no account
6001/// an operator would recognise.
6002#[cfg(any(windows, test))]
6003fn scheduled_task_account(user_id: Option<String>) -> Option<String> {
6004    user_id.filter(|id| !id.to_ascii_uppercase().starts_with("S-1-"))
6005}
6006
6007/// Works out the gap for a registration in `mode` running as `account`.
6008///
6009/// `found` is `None` where nothing could be probed (every platform but macOS):
6010/// a login registration then keeps the general statement that it waits for a
6011/// sign-in. On a Mac the statement depends on automatic login and FileVault,
6012/// and a login registration that automatic login brings back has no gap. The
6013/// earlier statement was printed for every login registration, so a Mac set to
6014/// sign its runner account in automatically was told it would not resume.
6015#[must_use]
6016pub fn unattended_gap(
6017    mode: StartMode,
6018    account: &str,
6019    found: Option<&crate::unattended_login::UnattendedLogin>,
6020) -> Option<UnattendedGap> {
6021    let Some(found) = found else {
6022        return (mode == StartMode::Login).then(|| UnattendedGap {
6023            reason: "This registration starts at login, so the agent does not run until the \
6024                     operator signs in; this host does not resume work after an unattended \
6025                     reboot."
6026                .to_owned(),
6027            remedy: "For machine-on operation, move the service and credential to `--start-at \
6028                     boot` from an elevated terminal."
6029                .to_owned(),
6030        });
6031    };
6032    let resume = crate::unattended_login::resume(found, mode, account);
6033    if matches!(resume, crate::unattended_login::Resume::Unknown(_)) {
6034        // `host doctor` says what could not be read; a note here would claim
6035        // a gap nobody has seen.
6036        return None;
6037    }
6038    Some(UnattendedGap {
6039        reason: resume.explain(account)?,
6040        remedy: resume.remedy().unwrap_or_else(|| {
6041            "Run `runner-manager host doctor` on the Mac's desktop, where automatic login and \
6042             FileVault can be read."
6043                .to_owned()
6044        }),
6045    })
6046}
6047
6048impl ServiceStatus {
6049    /// `record_refused` carries a [`ServiceError::RecordNotPermitted`] when
6050    /// there **is** a record and this account may not read it, which is not the
6051    /// same state as having none and must not be reported as one. See
6052    /// [`Operations::status`].
6053    fn compose(
6054        identity: ServiceIdentity,
6055        record: Option<InstallRecord>,
6056        record_refused: Option<&ServiceError>,
6057        registration: Option<Registration>,
6058        last_github_contact: Option<DateTime<Utc>>,
6059        paths: &AppPaths,
6060    ) -> Self {
6061        let mut problems = Vec::new();
6062        let mut notes = Vec::new();
6063
6064        if let Some(refusal) = record_refused {
6065            // A problem rather than a note: `is_healthy` stays false, because
6066            // an operator who cannot read their own install record does have
6067            // something to fix. It is just not a reason to withhold everything
6068            // the service manager knows.
6069            problems.push(StatusProblem {
6070                subject: "install record",
6071                detail: refusal.to_string(),
6072            });
6073        }
6074
6075        let log_file = record.as_ref().map_or_else(
6076            || ServiceDirectories::of(paths).log_file(),
6077            |record| record.log_file.clone(),
6078        );
6079
6080        let binary = record.as_ref().map(|record| {
6081            inspect_binary(
6082                &record.binary,
6083                registration
6084                    .as_ref()
6085                    .and_then(Registration::binary)
6086                    .as_deref(),
6087            )
6088        });
6089        if let Some(state) = &binary
6090            && state.is_error()
6091        {
6092            problems.push(StatusProblem {
6093                subject: "binary",
6094                detail: state.to_string(),
6095            });
6096        }
6097
6098        match (&record, &registration) {
6099            (Some(_), None) => problems.push(StatusProblem {
6100                subject: "registration",
6101                detail: "this host has a service record but no service manager knows the \
6102                         registration. Run `service install` again; it deletes nothing."
6103                    .to_string(),
6104            }),
6105            // Not reported when the record was refused rather than absent: the
6106            // problem above already names the real state, and "there is no
6107            // install record" would be a second, wrong diagnosis of the same
6108            // file, with a `service uninstall` in its remedy.
6109            (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
6110                subject: "record",
6111                detail: format!(
6112                    "{} holds a registration for this service but there is no install record, so \
6113                     the path it was installed from and the directories it was installed against \
6114                     are unknown. Run `service uninstall` and `service install`.",
6115                    found.manager
6116                ),
6117            }),
6118            (None, Some(_)) => {}
6119            (Some(record), Some(found)) => {
6120                if record.start_mode != found.start_mode {
6121                    problems.push(StatusProblem {
6122                        subject: "start mode",
6123                        detail: format!(
6124                            "the record says {} and {} holds a {} registration. Switch the start \
6125                             mode again to make them agree.",
6126                            record.start_mode, found.manager, found.start_mode
6127                        ),
6128                    });
6129                }
6130                if record.start_mode == StartMode::Boot && !found.starts_automatically {
6131                    problems.push(StatusProblem {
6132                        subject: "start mode",
6133                        detail: format!(
6134                            "{} holds the registration but will not start it by itself, so this \
6135                             host does not resume work after a reboot.",
6136                            found.manager
6137                        ),
6138                    });
6139                }
6140                if let Some(actual) = found.restart_delay {
6141                    let expected = record.restart().effective_delay(found.manager);
6142                    if actual != expected {
6143                        problems.push(StatusProblem {
6144                            subject: "restart policy",
6145                            detail: format!(
6146                                "the record says the service restarts after {}s and {} reports \
6147                                 {}s. Something has edited the registration since it was \
6148                                 installed.",
6149                                expected.as_secs(),
6150                                found.manager,
6151                                actual.as_secs()
6152                            ),
6153                        });
6154                    } else if expected != record.restart().delay() {
6155                        // Not a fault. Task Scheduler expresses this in whole
6156                        // minutes, so the delay in force is longer than the one
6157                        // asked for -- never shorter, which is the direction
6158                        // the requirement cares about.
6159                        notes.push(format!(
6160                            "{} expresses the restart delay in whole minutes, so the {}s asked \
6161                             for is enforced as {}s. The service therefore never restarts faster \
6162                             than the configured bound.",
6163                            found.manager,
6164                            record.restart().delay().as_secs(),
6165                            expected.as_secs()
6166                        ));
6167                    }
6168                }
6169                if found.starts_automatically && !found.running {
6170                    problems.push(StatusProblem {
6171                        subject: "runtime",
6172                        detail: format!(
6173                            "{} holds an automatic registration, but the daemon is stopped. Run \
6174                             `runner-manager service install --start-at {}`; if the service \
6175                             manager denies replacement, retry from an elevated terminal. If it \
6176                             stops again, inspect {}.",
6177                            found.manager,
6178                            found.start_mode,
6179                            log_file.display()
6180                        ),
6181                    });
6182                }
6183            }
6184            (None, None) => {}
6185        }
6186
6187        // The two facts here really are independent, which is what `f1`'s
6188        // `status.rs` says this check needs and could not have there: the scope
6189        // is derived from the mode the **record** carries, and it is compared
6190        // against the mode the **service manager** actually holds.
6191        let store = record.as_ref().and_then(|record| {
6192            let registered_mode = registration
6193                .as_ref()
6194                .map_or(record.start_mode, |found| found.start_mode);
6195            crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
6196                .ok()
6197                .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
6198        });
6199        if let Some(store) = &store
6200            && !store.agrees_with_start_mode()
6201        {
6202            problems.push(StatusProblem {
6203                subject: "secret store",
6204                detail: format!(
6205                    "{store}. Run `auth login` again so the token is stored where the registered \
6206                     start mode can read it."
6207                ),
6208            });
6209        }
6210
6211        if last_github_contact.is_none() {
6212            notes.push(
6213                "GitHub has not been reached successfully since this host's state directory was \
6214                 created."
6215                    .to_string(),
6216            );
6217        }
6218
6219        // The privileged inspection output. Read-only, tolerant of an account
6220        // that may not read the descriptor at all, and never a `problem`: a
6221        // root this account cannot inspect is a true statement about this
6222        // account's rights, not a fault in the registration. What refuses an
6223        // install is the preflight in `runner_root_access`, which runs as the
6224        // installing account and has the authority to know.
6225        let runner_root = crate::runner_root::default_runner_root(paths)
6226            .ok()
6227            .map(|root| {
6228                let path = root.as_path().to_path_buf();
6229                let report = crate::runner_root_access::report(&path);
6230                (path, report)
6231            });
6232        // Reported, and deliberately not a `problem`. The Definition of Done
6233        // asks for a broad root to be "reported and fail the security
6234        // preflight", and the security preflight is `install`'s -- which runs
6235        // as the installing account and refuses outright. `service status` runs
6236        // as whoever typed it, is expected to be readable on a host with
6237        // nothing installed at all, and drives an exit code; turning a
6238        // directory that predates this feature into a non-zero exit for a
6239        // machine that has never installed the service would report a fault
6240        // that is not this registration's.
6241        if let Some((
6242            path,
6243            RootAccessReport::Present {
6244                broad_write: true, ..
6245            },
6246        )) = &runner_root
6247        {
6248            notes.push(format!(
6249                "the platform default runner root {} can be written by ordinary local users, so \
6250                 it is not a safe place to run jobs. `service install` refuses it rather than \
6251                 tightening it, because the contents of a directory anybody could write cannot \
6252                 be trusted: remove or empty it, or choose another root with `runner-manager \
6253                 host set-runtime-root --path <PATH>`.",
6254                path.display()
6255            ));
6256        }
6257
6258        // The surface the agent has nowhere else to reach. A root that refuses
6259        // a launch does so before any attempt row exists, and the daemon's log
6260        // redacts the paths out of the sentence, so without this the operator
6261        // sees `runner_start_failed reason=other` once per poll and nothing
6262        // that names the directory or the fix.
6263        //
6264        // A **note** and not a problem, by the same rule the broad-write root
6265        // above obeys: `service status` runs as whoever typed it, is expected
6266        // to be readable on a host with nothing installed at all, and drives an
6267        // exit code. A problem here would fail that exit code on three hosts
6268        // that are not broken -- one whose root was fixed but which has had no
6269        // queued job since, one whose service was uninstalled without clearing
6270        // `state/`, and one that only ever ran the agent in the foreground --
6271        // and the printed remedy (`service uninstall && service install`) does
6272        // not touch `state/`, so following it would not clear the error either.
6273        // A note is read by the same operator at the same moment and traps
6274        // nobody in a loop.
6275        //
6276        // A read failure is reported and not swallowed, for the reason
6277        // `runner_root_refusals` documents: absence means "every policy is
6278        // placing runners", and answering a parse failure with that would be
6279        // the wrong answer to the question this row exists for.
6280        match runner_root_refusals(paths) {
6281            Ok(refusals) => {
6282                for refusal in refusals {
6283                    notes.push(format!(
6284                        "policy {} started no runner: its runner root {} refused the launch \
6285                         ({}), last at {}. {} This clears when that policy next places a \
6286                         runner.",
6287                        refusal.policy,
6288                        refusal.root,
6289                        refusal.kind,
6290                        refusal.at.to_rfc3339(),
6291                        refusal.detail,
6292                    ));
6293                }
6294            }
6295            Err(error) => notes.push(format!(
6296                "whether the agent could use its runner roots could not be read: {error}"
6297            )),
6298        }
6299
6300        Self {
6301            identity,
6302            record,
6303            registration,
6304            binary,
6305            log_file,
6306            store,
6307            last_github_contact,
6308            runner_root,
6309            credential_rejected_since: None,
6310            launches_blocked: None,
6311            unattended_gap: None,
6312            foreign: None,
6313            problems,
6314            notes,
6315        }
6316    }
6317
6318    /// Marks a status read from another installation's directories as that
6319    /// installation's. See [`ForeignService`].
6320    ///
6321    /// Its problems were written for its owner, and their remedies -- `service
6322    /// install`, `auth login`, re-registering a drifted definition -- act on
6323    /// the directories of whoever runs them. Printed to this account, which
6324    /// reaches here precisely by following "run it elevated", they would
6325    /// re-register the machine's service against this account's data root.
6326    /// So each keeps its diagnosis and loses its remedy, and says where the
6327    /// remedy has to be run instead.
6328    fn with_foreign(mut self, foreign: ForeignService) -> Self {
6329        let act_as = foreign.act_as();
6330        for problem in &mut self.problems {
6331            problem.detail = format!(
6332                "{}. Act on it {act_as}: a remedy run from this account acts on this account's \
6333                 own directories, not the service's.",
6334                diagnosis_only(&problem.detail)
6335            );
6336        }
6337        if let Some(note) = foreign.unused_local_note() {
6338            self.notes.push(note);
6339        }
6340        self.foreign = Some(foreign);
6341        self
6342    }
6343
6344    /// A registration made from another data root whose records this account
6345    /// cannot read: what the service manager reports, and nothing else.
6346    ///
6347    /// The only problems are ones the service manager alone settles -- an
6348    /// automatic registration that is stopped, a boot registration that will
6349    /// not start by itself -- and their remedy is to look as the owner, never
6350    /// to reinstall from here.
6351    fn foreign_unread(
6352        identity: ServiceIdentity,
6353        registration: Registration,
6354        foreign: ForeignService,
6355    ) -> Self {
6356        let mut problems = Vec::new();
6357        let look = foreign.where_to_look();
6358        if registration.starts_automatically && !registration.running {
6359            problems.push(StatusProblem {
6360                subject: "runtime",
6361                detail: format!(
6362                    "{} holds an automatic registration for the service installed from {}, but \
6363                     it is stopped. Run `runner-manager service status` {look} to see why; do not \
6364                     reinstall it from this account.",
6365                    registration.manager,
6366                    foreign.installed_by(),
6367                ),
6368            });
6369        }
6370        if registration.start_mode == StartMode::Boot && !registration.starts_automatically {
6371            problems.push(StatusProblem {
6372                subject: "start mode",
6373                detail: format!(
6374                    "{} holds the boot registration installed from {} but will not start it by \
6375                     itself. Run `runner-manager service status` {look}.",
6376                    registration.manager,
6377                    foreign.installed_by(),
6378                ),
6379            });
6380        }
6381        let mut notes = Vec::new();
6382        if let Some(reason) = &foreign.unreadable {
6383            notes.push(format!(
6384                "its health is not known here: {reason}. Run `runner-manager service status` \
6385                 {look} to see it."
6386            ));
6387        }
6388        notes.extend(foreign.unused_local_note());
6389        Self {
6390            identity,
6391            record: None,
6392            log_file: foreign.directories.log_file(),
6393            registration: Some(registration),
6394            binary: None,
6395            store: None,
6396            last_github_contact: None,
6397            runner_root: None,
6398            credential_rejected_since: None,
6399            launches_blocked: None,
6400            unattended_gap: None,
6401            foreign: Some(foreign),
6402            problems,
6403            notes,
6404        }
6405    }
6406
6407    /// The other installation this machine's service belongs to, when it is
6408    /// not this account's. See [`ForeignService`].
6409    #[must_use]
6410    pub const fn foreign(&self) -> Option<&ForeignService> {
6411        self.foreign.as_ref()
6412    }
6413
6414    /// Whether this is another installation's service whose own records this
6415    /// account could not read, so only the service manager's facts are here.
6416    #[must_use]
6417    pub fn is_unread(&self) -> bool {
6418        self.foreign.as_ref().is_some_and(ForeignService::is_unread)
6419    }
6420
6421    /// The one-word-or-so verdict `service status` ends with.
6422    #[must_use]
6423    pub fn verdict(&self) -> String {
6424        if !self.is_healthy() {
6425            return "NOT healthy".to_owned();
6426        }
6427        match &self.foreign {
6428            Some(foreign) if foreign.is_unread() => format!(
6429                "{}; installed from {}, so its health is read {}",
6430                if self.is_running() {
6431                    "running"
6432                } else {
6433                    "not running"
6434                },
6435                foreign.installed_by(),
6436                foreign.where_to_look()
6437            ),
6438            _ => "healthy".to_owned(),
6439        }
6440    }
6441
6442    /// Reports a definition this build would render differently.
6443    #[must_use]
6444    pub fn with_definition_drift(mut self, drift: Option<DefinitionDrift>) -> Self {
6445        if let Some(drift) = drift {
6446            if drift.manager == DefinitionKind::LaunchdPlist {
6447                // A restart, not `service install`: the running daemon holds the
6448                // single-instance lock, which refuses an install, and a daemon
6449                // that starts with no runner repairs the plist itself.
6450                let restart = match drift.start_mode {
6451                    StartMode::Boot => format!(
6452                        "sudo launchctl kickstart -k system/{}",
6453                        self.identity.launchd_label()
6454                    ),
6455                    StartMode::Login => format!(
6456                        "launchctl kickstart -k gui/$(id -u)/{}",
6457                        self.identity.launchd_label()
6458                    ),
6459                };
6460                self.problems.push(StatusProblem {
6461                    subject: "definition",
6462                    detail: format!(
6463                        "{} was written by runner-manager {} with a `ProcessType` other than \
6464                         `{LAUNCHD_PROCESS_TYPE}`, which launchd applies to every runner the \
6465                         daemon starts, so their jobs run below normal priority. The daemon \
6466                         rewrites it and reloads itself when it starts with no runner, which it \
6467                         does after every upgrade; to do it now, restart the service while no \
6468                         job is running: `{restart}`. Configuration and credentials are kept.",
6469                        drift.path.display(),
6470                        drift.installed_by_version,
6471                    ),
6472                });
6473                return self;
6474            }
6475            let repair = match drift.start_mode {
6476                StartMode::Boot => format!(
6477                    "`sudo systemctl stop runner-manager && {}` (for a managed WSL host, run \
6478                     `runner-manager wsl install --distribution <name>` from Windows)",
6479                    sudo_command("service install --start-at boot")
6480                ),
6481                StartMode::Login => "`systemctl --user stop runner-manager && runner-manager \
6482                                     service install --start-at login`"
6483                    .to_owned(),
6484            };
6485            self.problems.push(StatusProblem {
6486                subject: "definition",
6487                detail: format!(
6488                    "{} was written by runner-manager {} and differs from what {} renders, so \
6489                     the daemon runs under outdated settings (for example a credential frozen \
6490                     at service start). Re-register it with {repair}; configuration and \
6491                     credentials are kept.",
6492                    drift.path.display(),
6493                    drift.installed_by_version,
6494                    env!("CARGO_PKG_VERSION"),
6495                ),
6496            });
6497        }
6498        self
6499    }
6500
6501    /// Reports that GitHub is rejecting the daemon's credential.
6502    #[must_use]
6503    pub fn with_credential_rejection(mut self, since: Option<DateTime<Utc>>) -> Self {
6504        if let Some(since) = since {
6505            self.credential_rejected_since = Some(since);
6506            self.problems.push(StatusProblem {
6507                subject: "github credential",
6508                detail: format!(
6509                    "GitHub has rejected the service's credential since {}, so the service \
6510                     cannot see queued jobs or start runners. Run `runner-manager auth login` \
6511                     (for a managed WSL host, `runner-manager --host wsl:<name> auth login` \
6512                     from Windows).",
6513                    since.to_rfc3339()
6514                ),
6515            });
6516        }
6517        self
6518    }
6519
6520    /// Since when GitHub has rejected the service's credential, if it has.
6521    #[must_use]
6522    pub const fn credential_rejected_since(&self) -> Option<DateTime<Utc>> {
6523        self.credential_rejected_since
6524    }
6525
6526    /// Reports that the daemon starts no runner because its launches are
6527    /// blocked. See [`crate::launch_health`].
6528    #[must_use]
6529    pub fn with_launches_blocked(
6530        mut self,
6531        blocked: Option<crate::launch_health::LaunchesBlocked>,
6532    ) -> Self {
6533        if let Some(blocked) = &blocked {
6534            self.problems.push(StatusProblem {
6535                subject: LAUNCHES_BLOCKED_SUBJECT,
6536                detail: format!(
6537                    "the service has started no runner {blocked}. Fix: {}.",
6538                    blocked.remedy
6539                ),
6540            });
6541        }
6542        self.launches_blocked = blocked;
6543        self
6544    }
6545
6546    /// Why the service starts no runner, when its launches are blocked.
6547    #[must_use]
6548    pub const fn launches_blocked(&self) -> Option<&crate::launch_health::LaunchesBlocked> {
6549        self.launches_blocked.as_ref()
6550    }
6551
6552    /// Reports that the daemon cannot read its own stored credential, and the
6553    /// exact command that ends it. See [`record_credential_unreadable`].
6554    #[must_use]
6555    pub fn with_credential_unreadable(mut self, since: Option<DateTime<Utc>>) -> Self {
6556        if let Some(since) = since {
6557            let mode = self.start_mode().unwrap_or_default();
6558            self.problems.push(StatusProblem {
6559                subject: CREDENTIAL_UNREADABLE_SUBJECT,
6560                detail: format!(
6561                    "the service has been unable to read its stored GitHub credential since {}, \
6562                     so it starts no runner; the diagnostic log has the store's own answer.{} \
6563                     Sign in again: {}.",
6564                    since.to_rfc3339(),
6565                    if cfg!(target_os = "macos") {
6566                        " That is what a keychain item written by a different build looks like."
6567                    } else {
6568                        ""
6569                    },
6570                    sign_in_instruction(mode)
6571                ),
6572            });
6573        }
6574        self
6575    }
6576
6577    /// What this host's default runner root grants, and to whom.
6578    ///
6579    /// `None` when the platform default could not even be resolved. The
6580    /// descriptor inside has been through
6581    /// [`crate::runner_root_access::redact`], so it names the well-known
6582    /// trustees and says "an account" for everything else — no more identity
6583    /// than the `account` line above it already prints.
6584    #[must_use]
6585    pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
6586        self.runner_root
6587            .as_ref()
6588            .map(|(path, report)| (path.as_path(), report))
6589    }
6590
6591    /// Whether a registration exists at all.
6592    #[must_use]
6593    pub const fn is_installed(&self) -> bool {
6594        self.registration.is_some() || self.record.is_some()
6595    }
6596
6597    /// Whether the daemon is running now.
6598    #[must_use]
6599    pub fn is_running(&self) -> bool {
6600        self.registration
6601            .as_ref()
6602            .is_some_and(|registration| registration.running)
6603    }
6604
6605    /// **False whenever anything is wrong**, including a stale binary path.
6606    #[must_use]
6607    pub fn is_healthy(&self) -> bool {
6608        self.problems.is_empty()
6609    }
6610
6611    /// Everything that is wrong.
6612    #[must_use]
6613    pub fn problems(&self) -> &[StatusProblem] {
6614        &self.problems
6615    }
6616
6617    /// True statements that are not faults — a login-mode registration not
6618    /// running unattended, or an agent that has not yet reached GitHub.
6619    #[must_use]
6620    pub fn notes(&self) -> &[String] {
6621        &self.notes
6622    }
6623
6624    /// Reports that the daemon starts no runner because a required host check
6625    /// fails from its own session. That is the service not doing its job, so
6626    /// it is a problem: a host whose daemon refused every runner used to read
6627    /// `verdict healthy`.
6628    #[must_use]
6629    pub fn with_host_unfit(mut self, unfit: Option<crate::host_fitness::HostUnfitRecord>) -> Self {
6630        if let Some(unfit) = unfit {
6631            self.problems.push(StatusProblem {
6632                subject: HOST_UNFIT_SUBJECT,
6633                detail: format!(
6634                    "the service has started no runner since {}, because a required host check \
6635                     fails from its own session: {}",
6636                    unfit.since.to_rfc3339(),
6637                    unfit.describe()
6638                ),
6639            });
6640        }
6641        self
6642    }
6643
6644    /// Reports a daemon that is running and has stopped making progress. See
6645    /// [`crate::daemon_heartbeat`]: launchd, systemd and the SCM all report
6646    /// such a daemon running, so this used to read `verdict healthy`.
6647    #[must_use]
6648    pub fn with_stall(mut self, stall: Option<String>) -> Self {
6649        if let Some(detail) = stall {
6650            self.problems.push(StatusProblem {
6651                subject: DAEMON_STALLED_SUBJECT,
6652                detail,
6653            });
6654        }
6655        self
6656    }
6657
6658    /// Records whether this registration comes back by itself after an
6659    /// unattended restart, as a note. See [`unattended_gap`].
6660    #[must_use]
6661    pub fn with_unattended_gap(mut self, gap: Option<UnattendedGap>) -> Self {
6662        if let Some(gap) = &gap {
6663            self.notes.push(gap.reason.clone());
6664        }
6665        self.unattended_gap = gap;
6666        self
6667    }
6668
6669    /// Why this registration does not come back by itself after an
6670    /// unattended restart, when it does not.
6671    #[must_use]
6672    pub const fn unattended_gap(&self) -> Option<&UnattendedGap> {
6673        self.unattended_gap.as_ref()
6674    }
6675
6676    /// The service manager's command that restarts this registration, for its
6677    /// start mode (boot when unknown). See [`ServiceIdentity::restart_command`].
6678    #[must_use]
6679    pub fn restart_command(&self) -> String {
6680        self.identity
6681            .restart_command(self.start_mode().unwrap_or(StartMode::Boot))
6682    }
6683
6684    /// The recorded start mode. Journey 5 step 4. For another installation's
6685    /// service whose record this account cannot read, the mode the service
6686    /// manager's domain implies.
6687    #[must_use]
6688    pub fn start_mode(&self) -> Option<StartMode> {
6689        self.record
6690            .as_ref()
6691            .map(|record| record.start_mode)
6692            .or_else(|| {
6693                self.registration
6694                    .as_ref()
6695                    .filter(|_| self.is_unread())
6696                    .map(|registration| registration.start_mode)
6697            })
6698    }
6699
6700    /// The binary the service runs: the recorded one, or for another
6701    /// installation's service whose record this account cannot read, the one
6702    /// the service manager names.
6703    #[must_use]
6704    pub fn service_binary(&self) -> Option<PathBuf> {
6705        self.record
6706            .as_ref()
6707            .map(|record| record.binary.clone())
6708            .or_else(|| {
6709                self.registration
6710                    .as_ref()
6711                    .filter(|_| self.is_unread())
6712                    .and_then(Registration::binary)
6713            })
6714    }
6715
6716    /// The resolved absolute binary path and what became of it. Journey 5
6717    /// step 4 and `05-infrastructure.md` item 6.
6718    #[must_use]
6719    pub const fn binary(&self) -> Option<&BinaryPath> {
6720        self.binary.as_ref()
6721    }
6722
6723    /// The diagnostic log path. `05-infrastructure.md` item 4.
6724    #[must_use]
6725    pub fn log_file(&self) -> &Path {
6726        &self.log_file
6727    }
6728
6729    /// The last successful GitHub contact. Journey 5 step 4.
6730    #[must_use]
6731    pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
6732        self.last_github_contact
6733    }
6734
6735    /// Which store the daemon reads, and whether that agrees with the
6736    /// registration.
6737    #[must_use]
6738    pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
6739        self.store.as_ref()
6740    }
6741
6742    /// The record `install` wrote.
6743    #[must_use]
6744    pub const fn record(&self) -> Option<&InstallRecord> {
6745        self.record.as_ref()
6746    }
6747
6748    /// What the service manager says.
6749    #[must_use]
6750    pub const fn registration(&self) -> Option<&Registration> {
6751        self.registration.as_ref()
6752    }
6753}
6754
6755impl fmt::Display for ServiceStatus {
6756    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6757        writeln!(f, "Service: {}", self.identity)?;
6758        match (&self.record, &self.registration) {
6759            (None, None) => {
6760                writeln!(
6761                    f,
6762                    "  installed                 no. `service install` registers `{} {}`.",
6763                    SERVICE_NAME,
6764                    DAEMON_ARGUMENTS.join(" ")
6765                )?;
6766            }
6767            _ => {
6768                let manager = self
6769                    .registration
6770                    .as_ref()
6771                    .map(|registration| registration.manager.manager());
6772                writeln!(
6773                    f,
6774                    "  installed                 {}",
6775                    manager.unwrap_or("yes, but no service manager knows it")
6776                )?;
6777                writeln!(
6778                    f,
6779                    "  state                     {}",
6780                    if self.is_running() {
6781                        "running"
6782                    } else {
6783                        "not running"
6784                    }
6785                )?;
6786            }
6787        }
6788        if let Some(foreign) = &self.foreign {
6789            writeln!(
6790                f,
6791                "  installed from            {} -- another installation's, not this account's ({})",
6792                foreign.installed_by(),
6793                foreign.local_root.display()
6794            )?;
6795            // Without the record, the service manager's own facts.
6796            if foreign.is_unread()
6797                && let Some(registration) = &self.registration
6798            {
6799                writeln!(f, "  start mode                {}", registration.start_mode)?;
6800                if let Some(account) = &registration.account {
6801                    writeln!(f, "  account                   {account}")?;
6802                }
6803                if let Some(binary) = self.service_binary() {
6804                    writeln!(f, "  binary                    {}", binary.display())?;
6805                }
6806            }
6807        }
6808        if let Some(record) = &self.record {
6809            writeln!(f, "  start mode                {}", record.start_mode)?;
6810            let recorded = record.account.to_string();
6811            let account = registered_account(self.registration.as_ref(), &recorded);
6812            writeln!(f, "  account                   {account}")?;
6813            writeln!(f, "  restart on failure        {}", record.restart())?;
6814            writeln!(
6815                f,
6816                "  arguments                 {}",
6817                record.arguments.join(" ")
6818            )?;
6819        }
6820        if let Some(binary) = &self.binary {
6821            writeln!(f, "  binary                    {binary}")?;
6822        }
6823        writeln!(f, "  diagnostic log            {}", self.log_file.display())?;
6824        if let Some((path, report)) = &self.runner_root {
6825            // Named as the *default* rather than as "the runner root", because
6826            // it is only the effective one until an operator runs
6827            // `host set-runtime-root`. This crate cannot see that setting — it
6828            // lives in the application's store — so an unqualified label here
6829            // would contradict the `runner root … (host-configured)` row that
6830            // `status` and `host show` print from the value that is in force.
6831            writeln!(f, "  default runner root       {}", path.display())?;
6832            if *report != RootAccessReport::NotApplicable {
6833                writeln!(f, "  default root access       {report}")?;
6834            }
6835        }
6836        if let Some(store) = &self.store {
6837            writeln!(f, "  secret store              {store}")?;
6838        }
6839        // Not known, rather than "never", for a service whose records were not
6840        // read: its contact file is in a directory this account cannot open.
6841        if !self.is_unread() {
6842            writeln!(
6843                f,
6844                "  last GitHub contact       {}",
6845                match self.last_github_contact {
6846                    Some(at) => at.to_rfc3339(),
6847                    None => "never".to_string(),
6848                }
6849            )?;
6850        }
6851        for note in &self.notes {
6852            writeln!(f, "  note                      {note}")?;
6853        }
6854        for problem in &self.problems {
6855            writeln!(f, "  ERROR                     {problem}")?;
6856        }
6857        write!(f, "  verdict                   {}", self.verdict())
6858    }
6859}
6860
6861// ---------------------------------------------------------------------------
6862// The in-memory double
6863// ---------------------------------------------------------------------------
6864
6865/// A [`ControlFactory`] that registers nothing and remembers everything.
6866///
6867/// Public rather than `#[cfg(test)]` on purpose. `f3` builds three commands on
6868/// [`ServiceOperations`] and has to be able to test them without a service
6869/// manager, and a double that lived behind `#[cfg(test)]` here would be
6870/// invisible from another crate — which would leave `f3` either untested or
6871/// writing a second double that drifts from this one.
6872///
6873/// It is not a simulator. It stores what it was asked to register and reports it
6874/// back, which is exactly enough to exercise the logic that sits *above* a
6875/// service manager: the lock refusal, the record, the stale-path detection, the
6876/// start-mode switch, and the promise that uninstall deletes nothing else.
6877#[derive(Debug, Clone, Default)]
6878pub struct RecordingControls {
6879    state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
6880}
6881
6882#[derive(Debug, Default)]
6883struct RecordingState {
6884    registrations: BTreeMap<(StartMode, String), Registration>,
6885    definitions: BTreeMap<String, ServiceDefinition>,
6886    calls: Vec<String>,
6887    #[cfg(test)]
6888    install_failures: BTreeMap<StartMode, String>,
6889    #[cfg(test)]
6890    after_install: BTreeMap<StartMode, TestInstallSideEffect>,
6891}
6892
6893#[cfg(test)]
6894#[derive(Debug, Clone)]
6895enum TestInstallSideEffect {
6896    HideDirectory { directory: PathBuf, hidden: PathBuf },
6897}
6898
6899impl RecordingControls {
6900    /// A factory holding no registrations.
6901    #[must_use]
6902    pub fn new() -> Self {
6903        Self::default()
6904    }
6905
6906    /// Every call made through this factory, in order, as
6907    /// `"<operation> <name> (<mode>)"`.
6908    #[must_use]
6909    pub fn calls(&self) -> Vec<String> {
6910        self.state.lock().expect("not poisoned").calls.clone()
6911    }
6912
6913    /// Every registration currently held, with the domain it is in.
6914    #[must_use]
6915    pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
6916        self.state
6917            .lock()
6918            .expect("not poisoned")
6919            .registrations
6920            .iter()
6921            .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
6922            .collect()
6923    }
6924
6925    /// The definition applied for a registration, if it is held.
6926    #[must_use]
6927    pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
6928        self.state
6929            .lock()
6930            .expect("not poisoned")
6931            .definitions
6932            .get(name)
6933            .cloned()
6934    }
6935
6936    /// Edits a held registration, as something outside this product would.
6937    ///
6938    /// This is how the divergence and start-type problems are made reachable
6939    /// from a test: `sc config`, `launchctl` and `systemctl` can all change a
6940    /// registration after installation, and a status command that could not be
6941    /// shown detecting that would be a status command nobody had tested against
6942    /// the case it exists for.
6943    ///
6944    /// Does nothing when no registration of that name is held.
6945    pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
6946        let mut state = self.state.lock().expect("not poisoned");
6947        if let Some((_, registration)) = state
6948            .registrations
6949            .iter_mut()
6950            .find(|((_, held), _)| held == name)
6951        {
6952            edit(registration);
6953        }
6954    }
6955
6956    #[cfg(test)]
6957    fn fail_next_install(&self, mode: StartMode, detail: &str) {
6958        self.state
6959            .lock()
6960            .expect("not poisoned")
6961            .install_failures
6962            .insert(mode, detail.to_string());
6963    }
6964
6965    #[cfg(test)]
6966    fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
6967        self.state
6968            .lock()
6969            .expect("not poisoned")
6970            .after_install
6971            .insert(
6972                mode,
6973                TestInstallSideEffect::HideDirectory { directory, hidden },
6974            );
6975    }
6976}
6977
6978impl ControlFactory for RecordingControls {
6979    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6980        Ok(Box::new(RecordingControl {
6981            mode,
6982            state: std::sync::Arc::clone(&self.state),
6983        }))
6984    }
6985}
6986
6987#[derive(Debug)]
6988struct RecordingControl {
6989    mode: StartMode,
6990    state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
6991}
6992
6993impl RecordingControl {
6994    fn note(&self, operation: &str, name: &str) {
6995        self.state
6996            .lock()
6997            .expect("not poisoned")
6998            .calls
6999            .push(format!("{operation} {name} ({})", self.mode));
7000    }
7001}
7002
7003impl ServiceControl for RecordingControl {
7004    fn manager(&self) -> DefinitionKind {
7005        // The double reports the kind this host's real backend would, so a test
7006        // that asserts on the manager name asserts something true of the
7007        // platform it is running on.
7008        host_definition_kind(self.mode)
7009    }
7010
7011    fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7012        self.note("install", plan.identity().name());
7013        #[cfg(test)]
7014        if let Some(detail) = self
7015            .state
7016            .lock()
7017            .expect("not poisoned")
7018            .install_failures
7019            .remove(&self.mode)
7020        {
7021            return Err(ServiceError::Control {
7022                operation: "install",
7023                name: plan.identity().name().to_string(),
7024                manager: "recording control",
7025                detail,
7026            });
7027        }
7028        // The real definition, not a stub. Rendering is pure, so the double can
7029        // afford it -- and a stub would make `Installed::review` report that the
7030        // definition confirms nothing, which is exactly the shape of false
7031        // assurance a test double must never hand back.
7032        let definition = ServiceDefinition::for_host(plan)?;
7033        let mut state = self.state.lock().expect("not poisoned");
7034        state.registrations.insert(
7035            (self.mode, plan.identity().name().to_string()),
7036            Registration {
7037                manager: host_definition_kind(self.mode),
7038                start_mode: self.mode,
7039                command_line: plan.command_line(),
7040                account: (*plan.account() != ServiceAccount::InvokingUser)
7041                    .then(|| plan.account().as_str().to_string()),
7042                running: false,
7043                starts_automatically: true,
7044                restart_delay: Some(plan.restart().delay()),
7045            },
7046        );
7047        state
7048            .definitions
7049            .insert(plan.identity().name().to_string(), definition.clone());
7050        #[cfg(test)]
7051        let side_effect = state.after_install.remove(&self.mode);
7052        drop(state);
7053        #[cfg(test)]
7054        if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
7055            std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
7056            std::fs::write(&directory, b"blocks recreation")
7057                .expect("test fault can block record directory recreation");
7058        }
7059        Ok(definition)
7060    }
7061
7062    fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7063        self.note("uninstall", identity.name());
7064        let mut state = self.state.lock().expect("not poisoned");
7065        state.definitions.remove(identity.name());
7066        Ok(state
7067            .registrations
7068            .remove(&(self.mode, identity.name().to_string()))
7069            .is_some())
7070    }
7071
7072    fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7073        self.note("query", identity.name());
7074        Ok(self
7075            .state
7076            .lock()
7077            .expect("not poisoned")
7078            .registrations
7079            .get(&(self.mode, identity.name().to_string()))
7080            .cloned())
7081    }
7082
7083    fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7084        self.note("start", identity.name());
7085        let mut state = self.state.lock().expect("not poisoned");
7086        match state
7087            .registrations
7088            .get_mut(&(self.mode, identity.name().to_string()))
7089        {
7090            Some(registration) => {
7091                registration.running = true;
7092                Ok(())
7093            }
7094            None => Err(ServiceError::NotInstalled {
7095                name: identity.name().to_string(),
7096                operation: "start",
7097            }),
7098        }
7099    }
7100
7101    fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7102        self.note("stop", identity.name());
7103        let mut state = self.state.lock().expect("not poisoned");
7104        match state
7105            .registrations
7106            .get_mut(&(self.mode, identity.name().to_string()))
7107        {
7108            Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
7109            None => Err(ServiceError::NotInstalled {
7110                name: identity.name().to_string(),
7111                operation: "stop",
7112            }),
7113        }
7114    }
7115}
7116
7117impl ControlFactory for HostControls {
7118    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7119        sys::control(mode)
7120    }
7121
7122    fn installed_definition(&self, path: &Path) -> Option<String> {
7123        std::fs::read_to_string(path).ok()
7124    }
7125
7126    fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
7127        crate::unattended_login::probe()
7128    }
7129}
7130
7131/// The operator's home directory, when the platform reports one.
7132///
7133/// Only login-mode registrations need it — a LaunchAgent and a systemd user
7134/// unit both live under it — and it is resolved lazily so that a boot-mode
7135/// operation on an account with no profile is not refused for wanting something
7136/// it never uses.
7137#[must_use]
7138pub fn host_home() -> Option<PathBuf> {
7139    directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
7140}
7141
7142/// The Unix backends' name for the same thing.
7143#[cfg(unix)]
7144fn home_directory() -> Option<PathBuf> {
7145    host_home()
7146}
7147
7148/// Runs a command and returns its exit status with both streams as text.
7149///
7150/// The three Unix backends and the Windows Task Scheduler backend all drive a
7151/// stock command-line tool, and all four want the same three things back. The
7152/// tools are chosen for machine-readable, locale-independent output wherever one
7153/// exists — `systemctl is-active`, `launchctl print`, `schtasks /XML` — because
7154/// parsing a localised human-facing table is how a status command starts lying
7155/// on somebody else's machine.
7156fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
7157    let output = std::process::Command::new(program)
7158        .args(arguments)
7159        .output()?;
7160    Ok((
7161        output.status.success(),
7162        String::from_utf8_lossy(&output.stdout).into_owned(),
7163        String::from_utf8_lossy(&output.stderr).into_owned(),
7164    ))
7165}
7166
7167/// Which manager holds a given start-mode domain on the platform this binary
7168/// was built for.
7169#[must_use]
7170pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
7171    if cfg!(windows) {
7172        match mode {
7173            StartMode::Boot => DefinitionKind::WindowsService,
7174            StartMode::Login => DefinitionKind::WindowsScheduledTask,
7175        }
7176    } else if cfg!(target_os = "macos") {
7177        DefinitionKind::LaunchdPlist
7178    } else {
7179        DefinitionKind::SystemdUnit
7180    }
7181}
7182
7183/// Enables a launchd label after it has been bootstrapped.
7184///
7185/// `launchctl bootstrap` deliberately preserves a label's disabled bit. An
7186/// `enable` failure therefore means the new registration cannot satisfy its
7187/// start policy. Compensate by booting it out and removing the plist so a
7188/// failed install cannot look installed to `service status`.
7189#[cfg(any(target_os = "macos", test))]
7190fn enable_launchd_registration(
7191    mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
7192    domain: &str,
7193    service_target: &str,
7194    plist: &Path,
7195    name: &str,
7196    elevation_remedy: &'static str,
7197) -> Result<(), ServiceError> {
7198    let (enabled, cause) = launchctl(&[
7199        std::ffi::OsStr::new("enable"),
7200        std::ffi::OsStr::new(service_target),
7201    ]);
7202    if enabled {
7203        return Ok(());
7204    }
7205
7206    let (booted_out, bootout_detail) = launchctl(&[
7207        std::ffi::OsStr::new("bootout"),
7208        std::ffi::OsStr::new(service_target),
7209    ]);
7210    let removed = std::fs::remove_file(plist);
7211    if !booted_out || removed.is_err() {
7212        return Err(ServiceError::Rollback {
7213            operation: "enable launchd registration",
7214            name: name.to_string(),
7215            cause,
7216            rollback: format!(
7217                "launchctl bootout {domain}: {}; remove {}: {}",
7218                if booted_out {
7219                    "succeeded".to_string()
7220                } else {
7221                    bootout_detail
7222                },
7223                plist.display(),
7224                removed
7225                    .err()
7226                    .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
7227            ),
7228        });
7229    }
7230
7231    if cause.to_ascii_lowercase().contains("permission denied") {
7232        Err(ServiceError::NeedsElevation {
7233            operation: "enable",
7234            name: name.to_string(),
7235            detail: cause,
7236            remedy: elevation_remedy,
7237        })
7238    } else {
7239        Err(ServiceError::Control {
7240            operation: "enable",
7241            name: name.to_string(),
7242            manager: "launchd",
7243            detail: cause,
7244        })
7245    }
7246}
7247
7248// ---------------------------------------------------------------------------
7249// Windows
7250// ---------------------------------------------------------------------------
7251
7252/// A stop request delivered by the Windows Service Control Manager.
7253///
7254/// The application owns the drain policy; this platform boundary only turns
7255/// `SERVICE_CONTROL_STOP`/`SHUTDOWN` into an awaitable notification.
7256#[derive(Debug, Clone)]
7257pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
7258
7259impl ServiceShutdown {
7260    /// Waits until SCM asks the service to stop or shut down.
7261    pub async fn wait(mut self) {
7262        if !*self.0.borrow() {
7263            let _ = self.0.changed().await;
7264        }
7265    }
7266}
7267
7268/// Runs the production process as a real Windows service host.
7269///
7270/// `StartServiceCtrlDispatcher` must run in the service process's main thread,
7271/// so the callback is handed through a process-global slot to the entrypoint
7272/// invoked by SCM. The slot is single-use by design: one process hosts exactly
7273/// one own-process service.
7274#[cfg(windows)]
7275pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
7276where
7277    F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
7278{
7279    windows_host::run(Box::new(run))
7280}
7281
7282#[cfg(windows)]
7283mod windows_host {
7284    use std::ffi::OsString;
7285    use std::sync::{Arc, Mutex, OnceLock, mpsc};
7286    use std::time::Duration;
7287
7288    use windows_service::service::{
7289        ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
7290        ServiceType,
7291    };
7292    use windows_service::service_control_handler::{
7293        self, ServiceControlHandlerResult, ServiceStatusHandle,
7294    };
7295
7296    use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
7297
7298    type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
7299
7300    struct Invocation {
7301        run: Runner,
7302        result: mpsc::SyncSender<Result<u8, String>>,
7303    }
7304
7305    static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
7306
7307    windows_service::define_windows_service!(ffi_service_main, service_main);
7308
7309    pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
7310        let (result_tx, result_rx) = mpsc::sync_channel(1);
7311        let slot = INVOCATION.get_or_init(|| Mutex::new(None));
7312        let mut invocation = slot
7313            .lock()
7314            .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
7315        if invocation.is_some() {
7316            return Err(host_error(
7317                "prepare",
7318                "the service-host slot was already used",
7319            ));
7320        }
7321        *invocation = Some(Invocation {
7322            run,
7323            result: result_tx,
7324        });
7325        drop(invocation);
7326
7327        windows_service::service_dispatcher::start("", ffi_service_main)
7328            .map_err(|error| host_error("connect", &error.to_string()))?;
7329        result_rx
7330            .recv()
7331            .map_err(|error| host_error("finish", &error.to_string()))?
7332            .map_err(|detail| host_error("run", &detail))
7333    }
7334
7335    fn service_main(_arguments: Vec<OsString>) {
7336        let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
7337            return;
7338        };
7339        let result = run_service(invocation.run);
7340        let _ = invocation.result.send(result);
7341    }
7342
7343    fn run_service(run: Runner) -> Result<u8, String> {
7344        let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
7345        let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
7346        let handler_status = Arc::clone(&status);
7347        let handler = move |control| match control {
7348            ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
7349            ServiceControl::Stop | ServiceControl::Shutdown => {
7350                if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
7351                    let _ = handle.set_service_status(service_status(
7352                        ServiceState::StopPending,
7353                        ServiceControlAccept::empty(),
7354                        1,
7355                        Duration::from_secs(300),
7356                        0,
7357                    ));
7358                }
7359                let _ = stop_tx.send(true);
7360                ServiceControlHandlerResult::NoError
7361            }
7362            _ => ServiceControlHandlerResult::NotImplemented,
7363        };
7364        let handle = service_control_handler::register("", handler)
7365            .map_err(|error| format!("cannot register the service control handler: {error}"))?;
7366        *status
7367            .lock()
7368            .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
7369        handle
7370            .set_service_status(service_status(
7371                ServiceState::Running,
7372                ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
7373                0,
7374                Duration::default(),
7375                0,
7376            ))
7377            .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
7378
7379        let exit = run(ServiceShutdown(stop_rx));
7380        handle
7381            .set_service_status(service_status(
7382                ServiceState::Stopped,
7383                ServiceControlAccept::empty(),
7384                0,
7385                Duration::default(),
7386                u32::from(exit),
7387            ))
7388            .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
7389        Ok(exit)
7390    }
7391
7392    fn service_status(
7393        state: ServiceState,
7394        accepted: ServiceControlAccept,
7395        checkpoint: u32,
7396        wait_hint: Duration,
7397        exit: u32,
7398    ) -> ServiceStatus {
7399        ServiceStatus {
7400            service_type: ServiceType::OWN_PROCESS,
7401            current_state: state,
7402            controls_accepted: accepted,
7403            exit_code: if exit == 0 {
7404                ServiceExitCode::Win32(0)
7405            } else {
7406                ServiceExitCode::ServiceSpecific(exit)
7407            },
7408            checkpoint,
7409            wait_hint,
7410            process_id: None,
7411        }
7412    }
7413
7414    fn host_error(operation: &'static str, detail: &str) -> ServiceError {
7415        ServiceError::Control {
7416            operation,
7417            name: SERVICE_NAME.to_string(),
7418            manager: "the Windows Service Control Manager",
7419            detail: detail.to_string(),
7420        }
7421    }
7422}
7423
7424#[cfg(windows)]
7425mod sys {
7426    //! Two managers, because Windows has two answers.
7427    //!
7428    //! `--start-at boot` is a service in the Service Control Manager, which is
7429    //! the only Windows facility that starts something before anybody logs in.
7430    //! `--start-at login` is a Task Scheduler task with a logon trigger, which
7431    //! is the only Windows facility that starts something when somebody does.
7432    //! There is no single mechanism that does both: service trigger-start has no
7433    //! logon trigger, and a scheduled task cannot run before a session exists.
7434
7435    use std::ffi::{OsStr, OsString};
7436    use std::time::{Duration, Instant};
7437
7438    use runner_manager_domain::model::StartMode;
7439    use windows_service::service::{
7440        ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
7441        ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
7442        ServiceState, ServiceType,
7443    };
7444    use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
7445
7446    use super::{
7447        DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
7448        ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
7449    };
7450
7451    /// `ERROR_SERVICE_DOES_NOT_EXIST`. Not an error here: it is the answer to
7452    /// "is this registered".
7453    const SERVICE_DOES_NOT_EXIST: i32 = 1060;
7454    /// `ERROR_SERVICE_MARKED_FOR_DELETE`. `DeleteService` is asynchronous: the
7455    /// registration remains in this state until every open service handle is
7456    /// closed, and callers must not mistake that short window for a leak.
7457    const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
7458    /// `ERROR_ACCESS_DENIED`.
7459    const ACCESS_DENIED: i32 = 5;
7460    const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
7461    const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
7462
7463    const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
7464         choose \"Run as administrator\".";
7465
7466    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7467        Ok(match mode {
7468            StartMode::Boot => Box::new(ScmControl),
7469            StartMode::Login => Box::new(TaskControl),
7470        })
7471    }
7472
7473    // -- the Service Control Manager -----------------------------------------
7474
7475    #[derive(Debug)]
7476    struct ScmControl;
7477
7478    /// Turns a `windows-service` failure into this module's error, keeping the
7479    /// "needs elevation" case separate: an operator told "access is denied" and
7480    /// an operator told "run this elevated" are not equally well served.
7481    fn scm_error(
7482        operation: &'static str,
7483        name: &str,
7484        error: &windows_service::Error,
7485    ) -> ServiceError {
7486        let raw = match error {
7487            windows_service::Error::Winapi(io) => io.raw_os_error(),
7488            _ => None,
7489        };
7490        let detail = match error {
7491            windows_service::Error::Winapi(io) => io.to_string(),
7492            other => other.to_string(),
7493        };
7494        if raw == Some(ACCESS_DENIED) {
7495            return ServiceError::NeedsElevation {
7496                operation,
7497                name: name.to_string(),
7498                detail,
7499                remedy: ELEVATION_REMEDY,
7500            };
7501        }
7502        ServiceError::Control {
7503            operation,
7504            name: name.to_string(),
7505            manager: "the Windows Service Control Manager",
7506            detail,
7507        }
7508    }
7509
7510    fn open_manager(
7511        access: ServiceManagerAccess,
7512        operation: &'static str,
7513        name: &str,
7514    ) -> Result<ServiceManager, ServiceError> {
7515        ServiceManager::local_computer(None::<&OsStr>, access)
7516            .map_err(|error| scm_error(operation, name, &error))
7517    }
7518
7519    impl ServiceControl for ScmControl {
7520        fn manager(&self) -> DefinitionKind {
7521            DefinitionKind::WindowsService
7522        }
7523
7524        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7525            let spec = windows_service_spec(plan);
7526            let manager = open_manager(
7527                ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
7528                "install",
7529                &spec.name,
7530            )?;
7531            let info = ServiceInfo {
7532                name: OsString::from(&spec.name),
7533                display_name: OsString::from(&spec.display_name),
7534                // Never INTERACTIVE_PROCESS: see `windows_service_descriptor`.
7535                service_type: ServiceType::OWN_PROCESS,
7536                start_type: if spec.automatic_start {
7537                    ServiceStartType::AutoStart
7538                } else {
7539                    ServiceStartType::OnDemand
7540                },
7541                error_control: ServiceErrorControl::Normal,
7542                executable_path: plan.binary().to_path_buf(),
7543                launch_arguments: plan.arguments().to_vec(),
7544                dependencies: Vec::new(),
7545                // `None` is LocalSystem, which is the account `d2`'s DACL admits.
7546                account_name: spec.account.as_ref().map(OsString::from),
7547                // No password is ever taken, asked for, or stored. An account
7548                // that needed one would be an account this installer refuses.
7549                account_password: None,
7550            };
7551            let service = manager
7552                .create_service(
7553                    &info,
7554                    ServiceAccess::CHANGE_CONFIG
7555                        | ServiceAccess::QUERY_CONFIG
7556                        | ServiceAccess::QUERY_STATUS
7557                        | ServiceAccess::START
7558                        | ServiceAccess::STOP
7559                        | ServiceAccess::DELETE,
7560                )
7561                .map_err(|error| scm_error("install", &spec.name, &error))?;
7562            service
7563                .set_description(&spec.description)
7564                .map_err(|error| scm_error("describe", &spec.name, &error))?;
7565            service
7566                .update_failure_actions(ServiceFailureActions {
7567                    reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
7568                    reboot_msg: None,
7569                    command: None,
7570                    // Three identical restart actions rather than one: the
7571                    // Service Control Manager applies the first action to the
7572                    // first failure, the second to the second, and the last to
7573                    // every failure after that. One action would leave the
7574                    // second and later failures unhandled, which is a service
7575                    // that comes back once and then stays down.
7576                    actions: Some(vec![
7577                        ServiceAction {
7578                            action_type: ServiceActionType::Restart,
7579                            delay: spec.restart.delay(),
7580                        };
7581                        3
7582                    ]),
7583                })
7584                .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7585            // Without this the failure actions apply only to a crash, and a
7586            // daemon that exits non-zero is not a crash.
7587            service
7588                .set_failure_actions_on_non_crash_failures(true)
7589                .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7590            Ok(ServiceDefinition::windows_service(plan))
7591        }
7592
7593        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7594            let manager =
7595                open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
7596            let service = match manager.open_service(
7597                identity.name(),
7598                ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
7599            ) {
7600                Ok(service) => Some(service),
7601                Err(error) if is_missing(&error) => return Ok(false),
7602                // A previous caller has already asked SCM to delete this
7603                // registration. That is the desired state, not an uninstall
7604                // failure. Wait for SCM to release the name so a following
7605                // `service install` can use it immediately.
7606                Err(error) if is_marked_for_delete(&error) => None,
7607                Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
7608            };
7609            if let Some(service) = service {
7610                // A running service can be deleted, but it lingers until it stops.
7611                // Stopping first is what makes `uninstall` followed by `install`
7612                // work in one sitting, which is what the start-mode switch needs.
7613                if let Ok(status) = service.query_status()
7614                    && status.current_state != ServiceState::Stopped
7615                {
7616                    let _ = service.stop();
7617                }
7618                match service.delete() {
7619                    Ok(()) => {}
7620                    // DeleteService is idempotent from the operator's point of
7621                    // view. Another process can mark the service between our
7622                    // OpenService and DeleteService calls.
7623                    Err(error) if is_marked_for_delete(&error) => {}
7624                    Err(error) => {
7625                        return Err(scm_error("uninstall", identity.name(), &error));
7626                    }
7627                }
7628                drop(service);
7629            }
7630
7631            // `DeleteService` marks a registration for deletion and returns;
7632            // SCM removes it only after the last service handle closes. Drop
7633            // ours before polling, then wait for the observable postcondition
7634            // promised by `uninstall`: an immediate status check must not see
7635            // a registration that is merely on its way out. This also keeps a
7636            // stop/uninstall/install sequence deterministic on busy hosts.
7637            let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
7638                match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
7639                    Ok(service) => {
7640                        drop(service);
7641                        Ok(false)
7642                    }
7643                    Err(error) if is_missing(&error) => Ok(true),
7644                    Err(error) if is_marked_for_delete(&error) => Ok(false),
7645                    Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
7646                }
7647            })?;
7648            if !absent {
7649                return Err(ServiceError::Control {
7650                    operation: "verify uninstall of",
7651                    name: identity.name().to_string(),
7652                    manager: "the Windows Service Control Manager",
7653                    detail: format!(
7654                        "the registration was still visible {} seconds after DeleteService; \
7655                         retry `service uninstall` from an elevated prompt",
7656                        DELETE_TIMEOUT.as_secs()
7657                    ),
7658                });
7659            }
7660            Ok(true)
7661        }
7662
7663        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7664            let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
7665            let service = match manager.open_service(
7666                identity.name(),
7667                ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
7668            ) {
7669                Ok(service) => service,
7670                Err(error) if is_missing(&error) => return Ok(None),
7671                Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
7672            };
7673            let config = service
7674                .query_config()
7675                .map_err(|error| scm_error("inspect", identity.name(), &error))?;
7676            let status = service
7677                .query_status()
7678                .map_err(|error| scm_error("inspect", identity.name(), &error))?;
7679            let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
7680                actions
7681                    .actions
7682                    .and_then(|actions| actions.into_iter().next())
7683                    .filter(|action| action.action_type == ServiceActionType::Restart)
7684                    .map(|action| action.delay)
7685            });
7686            Ok(Some(Registration {
7687                manager: DefinitionKind::WindowsService,
7688                start_mode: StartMode::Boot,
7689                // `lpBinaryPathName` holds the executable *and* its arguments
7690                // as one string, which is why this module carries its own
7691                // parser rather than trusting `executable_path`'s name.
7692                command_line: config.executable_path.to_string_lossy().into_owned(),
7693                account: config
7694                    .account_name
7695                    .map(|account| super::windows_service_account(&account.to_string_lossy())),
7696                running: status.current_state == ServiceState::Running,
7697                starts_automatically: config.start_type == ServiceStartType::AutoStart,
7698                restart_delay,
7699            }))
7700        }
7701
7702        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7703            let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
7704            let service = manager
7705                .open_service(identity.name(), ServiceAccess::START)
7706                .map_err(|error| scm_error("start", identity.name(), &error))?;
7707            service
7708                .start::<&OsStr>(&[])
7709                .map_err(|error| scm_error("start", identity.name(), &error))
7710        }
7711
7712        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7713            let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
7714            let service = manager
7715                .open_service(
7716                    identity.name(),
7717                    ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
7718                )
7719                .map_err(|error| scm_error("stop", identity.name(), &error))?;
7720            let status = service
7721                .query_status()
7722                .map_err(|error| scm_error("stop", identity.name(), &error))?;
7723            if status.current_state == ServiceState::Stopped {
7724                return Ok(false);
7725            }
7726            service
7727                .stop()
7728                .map_err(|error| scm_error("stop", identity.name(), &error))?;
7729            Ok(true)
7730        }
7731    }
7732
7733    fn is_missing(error: &windows_service::Error) -> bool {
7734        matches!(error, windows_service::Error::Winapi(io)
7735            if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
7736    }
7737
7738    pub(super) fn is_marked_for_delete(error: &windows_service::Error) -> bool {
7739        matches!(error, windows_service::Error::Winapi(io)
7740            if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
7741    }
7742
7743    pub(super) fn wait_until_scm_absent(
7744        timeout: Duration,
7745        poll_interval: Duration,
7746        mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
7747    ) -> Result<bool, ServiceError> {
7748        let deadline = Instant::now() + timeout;
7749        loop {
7750            if probe_absent()? {
7751                return Ok(true);
7752            }
7753            if Instant::now() >= deadline {
7754                return Ok(false);
7755            }
7756            std::thread::sleep(poll_interval);
7757        }
7758    }
7759
7760    // -- Task Scheduler ------------------------------------------------------
7761
7762    #[derive(Debug)]
7763    struct TaskControl;
7764
7765    fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
7766        if detail.to_ascii_lowercase().contains("access is denied") {
7767            return ServiceError::NeedsElevation {
7768                operation,
7769                name: name.to_string(),
7770                detail,
7771                remedy: ELEVATION_REMEDY,
7772            };
7773        }
7774        ServiceError::Control {
7775            operation,
7776            name: name.to_string(),
7777            manager: "Windows Task Scheduler",
7778            detail,
7779        }
7780    }
7781
7782    fn schtasks(
7783        operation: &'static str,
7784        name: &str,
7785        arguments: &[&OsStr],
7786    ) -> Result<(bool, String), ServiceError> {
7787        match run("schtasks.exe", arguments) {
7788            Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
7789            Err(error) => Err(task_error(operation, name, error.to_string())),
7790        }
7791    }
7792
7793    /// Task Scheduler reads `/XML` files as UTF-16, so the document is written
7794    /// as UTF-16 little-endian with a byte-order mark rather than as UTF-8.
7795    fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
7796        let mut bytes = vec![0xFF, 0xFE];
7797        for unit in text.encode_utf16() {
7798            bytes.extend_from_slice(&unit.to_le_bytes());
7799        }
7800        std::fs::write(path, bytes)
7801    }
7802
7803    impl ServiceControl for TaskControl {
7804        fn manager(&self) -> DefinitionKind {
7805            DefinitionKind::WindowsScheduledTask
7806        }
7807
7808        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7809            let name = plan.identity().name().to_string();
7810            let principal = TaskPrincipal::current()?;
7811            let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
7812            let directory = tempfile::tempdir()
7813                .map_err(|error| task_error("install", &name, error.to_string()))?;
7814            let document = directory.path().join("task.xml");
7815            write_utf16(&document, definition.text())
7816                .map_err(|error| task_error("install", &name, error.to_string()))?;
7817            let (ok, message) = schtasks(
7818                "install",
7819                &name,
7820                &[
7821                    OsStr::new("/Create"),
7822                    OsStr::new("/TN"),
7823                    OsStr::new(&name),
7824                    OsStr::new("/XML"),
7825                    document.as_os_str(),
7826                ],
7827            )?;
7828            if !ok {
7829                return Err(task_error("install", &name, message));
7830            }
7831            Ok(definition)
7832        }
7833
7834        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7835            if self.query(identity)?.is_none() {
7836                return Ok(false);
7837            }
7838            let name = identity.name().to_string();
7839            let (ok, message) = schtasks(
7840                "uninstall",
7841                &name,
7842                &[
7843                    OsStr::new("/Delete"),
7844                    OsStr::new("/TN"),
7845                    OsStr::new(&name),
7846                    OsStr::new("/F"),
7847                ],
7848            )?;
7849            if !ok {
7850                return Err(task_error("uninstall", &name, message));
7851            }
7852            Ok(true)
7853        }
7854
7855        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7856            let name = identity.name().to_string();
7857            let (ok, document) = schtasks(
7858                "inspect",
7859                &name,
7860                &[
7861                    OsStr::new("/Query"),
7862                    OsStr::new("/TN"),
7863                    OsStr::new(&name),
7864                    OsStr::new("/XML"),
7865                    OsStr::new("ONE"),
7866                ],
7867            )?;
7868            if !ok {
7869                // `schtasks` reports a missing task and a broken Task Scheduler
7870                // the same way, with a non-zero exit; there is no distinct code.
7871                // Treating it as absence is the safe reading: the caller either
7872                // installs, which will then fail loudly, or reports "not
7873                // installed", which is what an operator with no task sees.
7874                return Ok(None);
7875            }
7876            let command = xml_value(&document, "Command").unwrap_or_default();
7877            let arguments = xml_value(&document, "Arguments").unwrap_or_default();
7878            let command_line = if arguments.is_empty() {
7879                super::quote_argument(&command)
7880            } else {
7881                format!("{} {arguments}", super::quote_argument(&command))
7882            };
7883            Ok(Some(Registration {
7884                manager: DefinitionKind::WindowsScheduledTask,
7885                start_mode: StartMode::Login,
7886                command_line,
7887                account: super::scheduled_task_account(xml_value(&document, "UserId")),
7888                running: task_is_running(&name),
7889                starts_automatically: super::windows_login_task_starts_automatically(&document),
7890                restart_delay: xml_value(&document, "Interval")
7891                    .as_deref()
7892                    .and_then(parse_iso8601),
7893            }))
7894        }
7895
7896        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7897            let name = identity.name().to_string();
7898            let (ok, message) = schtasks(
7899                "start",
7900                &name,
7901                &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
7902            )?;
7903            if ok {
7904                Ok(())
7905            } else {
7906                Err(task_error("start", &name, message))
7907            }
7908        }
7909
7910        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7911            let running = self
7912                .query(identity)?
7913                .is_some_and(|registration| registration.running);
7914            if !running {
7915                return Ok(false);
7916            }
7917            let name = identity.name().to_string();
7918            let (ok, message) = schtasks(
7919                "stop",
7920                &name,
7921                &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
7922            )?;
7923            if ok {
7924                Ok(true)
7925            } else {
7926                Err(task_error("stop", &name, message))
7927            }
7928        }
7929    }
7930
7931    /// Whether Task Scheduler reports the task as running.
7932    ///
7933    /// **This is the one value in this module read from localised output.**
7934    /// `schtasks /Query /FO CSV` prints a `Status` column in the machine's
7935    /// display language, and Task Scheduler exposes no locale-independent
7936    /// equivalent short of COM. On a non-English Windows this therefore reports
7937    /// `false` for a task that is in fact running.
7938    ///
7939    /// That is stated rather than hidden because of what does *not* depend on
7940    /// it: Journey 5's liveness question is answered by the last successful
7941    /// GitHub contact, which is a timestamp this product writes itself and no
7942    /// locale can move. Nothing in the Definition of Done rests on this
7943    /// boolean.
7944    fn task_is_running(name: &str) -> bool {
7945        let Ok((true, stdout, _)) = run(
7946            "schtasks.exe",
7947            &[
7948                OsStr::new("/Query"),
7949                OsStr::new("/TN"),
7950                OsStr::new(name),
7951                OsStr::new("/FO"),
7952                OsStr::new("CSV"),
7953                OsStr::new("/NH"),
7954            ],
7955        ) else {
7956            return false;
7957        };
7958        stdout
7959            .lines()
7960            .filter_map(|line| line.rsplit(',').next())
7961            .any(|status| {
7962                status
7963                    .trim()
7964                    .trim_matches('"')
7965                    .eq_ignore_ascii_case("running")
7966            })
7967    }
7968
7969    /// Parses the `PT<n>M` shape this module writes, and the `PT<n>S` shape
7970    /// Task Scheduler would accept if it took seconds. Anything richer -- days,
7971    /// hours, a combination -- is `None` rather than a guess.
7972    fn parse_iso8601(value: &str) -> Option<Duration> {
7973        let rest = value.strip_prefix("PT")?;
7974        if let Some(minutes) = rest.strip_suffix('M') {
7975            return minutes
7976                .parse::<u64>()
7977                .ok()
7978                .map(|minutes| Duration::from_secs(minutes * 60));
7979        }
7980        rest.strip_suffix('S')?
7981            .parse::<u64>()
7982            .ok()
7983            .map(Duration::from_secs)
7984    }
7985}
7986
7987// ---------------------------------------------------------------------------
7988// Unix: shared plumbing
7989// ---------------------------------------------------------------------------
7990
7991/// Writes a definition file, creating its directory, and says when the refusal
7992/// was a permissions one.
7993///
7994/// Both Unix backends write a file into a directory only `root` can write —
7995/// `/Library/LaunchDaemons` and `/etc/systemd/system` — and an operator who ran
7996/// `service install` without `sudo` deserves to be told that rather than handed
7997/// an `EACCES`.
7998#[cfg(unix)]
7999fn write_definition(
8000    operation: &'static str,
8001    name: &str,
8002    path: &Path,
8003    text: &str,
8004    remedy: &'static str,
8005) -> Result<(), ServiceError> {
8006    if let Some(parent) = path.parent()
8007        && let Err(error) = std::fs::create_dir_all(parent)
8008        && error.kind() != std::io::ErrorKind::AlreadyExists
8009    {
8010        return Err(definition_error(operation, name, error, remedy, parent));
8011    }
8012    std::fs::write(path, text)
8013        .map_err(|error| definition_error(operation, name, error, remedy, path))
8014}
8015
8016/// Replaces an installed definition with [`DefinitionDrift::rendered`].
8017///
8018/// Through a temporary file in the same directory and a rename, so a failure
8019/// at any point leaves the old definition in place rather than a truncated
8020/// one. The mode is set to `0644` before the rename, as `service install`
8021/// leaves it: launchd refuses a plist that others can write.
8022#[cfg(all(unix, any(target_os = "macos", test)))]
8023fn replace_definition(name: &str, drift: &DefinitionDrift) -> Result<(), ServiceError> {
8024    use std::io::Write as _;
8025
8026    let path = drift.path.as_path();
8027    let directory = path.parent().unwrap_or_else(|| Path::new("/"));
8028    let failed = |error: std::io::Error, at: &Path| {
8029        definition_error("rewrite", name, error, SUDO_REMEDY, at)
8030    };
8031    let mut staged =
8032        tempfile::NamedTempFile::new_in(directory).map_err(|error| failed(error, directory))?;
8033    staged
8034        .write_all(drift.rendered.as_bytes())
8035        .and_then(|()| staged.as_file().sync_all())
8036        .map_err(|error| failed(error, staged.path()))?;
8037    std::fs::set_permissions(
8038        staged.path(),
8039        <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o644),
8040    )
8041    .map_err(|error| failed(error, path))?;
8042    staged
8043        .persist(path)
8044        .map(drop)
8045        .map_err(|error| failed(error.error, path))
8046}
8047
8048#[cfg(unix)]
8049fn definition_error(
8050    operation: &'static str,
8051    name: &str,
8052    error: std::io::Error,
8053    remedy: &'static str,
8054    path: &Path,
8055) -> ServiceError {
8056    let detail = format!("{}: {error}", path.display());
8057    if error.kind() == std::io::ErrorKind::PermissionDenied {
8058        ServiceError::NeedsElevation {
8059            operation,
8060            name: name.to_string(),
8061            detail,
8062            remedy,
8063        }
8064    } else {
8065        ServiceError::Control {
8066            operation,
8067            name: name.to_string(),
8068            manager: "the local service manager",
8069            detail,
8070        }
8071    }
8072}
8073
8074/// `sudo` is the remedy on both Unixes, and the message says which command.
8075#[cfg(unix)]
8076const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
8077     `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
8078     agent not running until you sign in.";
8079
8080// ---------------------------------------------------------------------------
8081// macOS
8082// ---------------------------------------------------------------------------
8083
8084#[cfg(target_os = "macos")]
8085mod sys {
8086    //! One manager, two domains. `--start-at boot` is a LaunchDaemon in
8087    //! `/Library/LaunchDaemons`, loaded into launchd's `system` domain;
8088    //! `--start-at login` is a LaunchAgent in the operator's own
8089    //! `~/Library/LaunchAgents`, loaded into `gui/<uid>`.
8090    //!
8091    //! `launchctl print` is the only inspection command here whose output is
8092    //! parsed, and it is English-only on every macOS release — launchd has no
8093    //! localisation for it. The registration's *existence* is decided by the
8094    //! plist file rather than by that output, so a launchd that changed its
8095    //! wording would cost this module the running/not-running line and nothing
8096    //! else.
8097
8098    use std::ffi::OsStr;
8099    use std::path::PathBuf;
8100    use std::time::Duration;
8101
8102    use runner_manager_domain::model::StartMode;
8103
8104    use super::{
8105        DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
8106        SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
8107        enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
8108        write_definition, xml_value,
8109    };
8110
8111    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
8112        Ok(Box::new(LaunchdControl { mode }))
8113    }
8114
8115    #[derive(Debug)]
8116    struct LaunchdControl {
8117        mode: StartMode,
8118    }
8119
8120    impl LaunchdControl {
8121        /// launchd's domain target for this start mode.
8122        fn domain(&self) -> String {
8123            match self.mode {
8124                StartMode::Boot => "system".to_string(),
8125                // SAFETY: `getuid` reads the calling process's real user id and
8126                // cannot fail; it takes no arguments and touches no memory.
8127                StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
8128            }
8129        }
8130
8131        fn service_target(&self, identity: &ServiceIdentity) -> String {
8132            format!("{}/{}", self.domain(), identity.launchd_label())
8133        }
8134
8135        /// Where this domain's plist lives, when the domain has a home to put
8136        /// it in.
8137        fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
8138            let file = format!("{}.plist", identity.launchd_label());
8139            match self.mode {
8140                StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
8141                StartMode::Login => {
8142                    home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
8143                }
8144            }
8145        }
8146
8147        fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
8148            ServiceError::Control {
8149                operation,
8150                name: name.to_string(),
8151                manager: "launchd",
8152                detail,
8153            }
8154        }
8155
8156        fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
8157            match run("launchctl", arguments) {
8158                Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
8159                Err(error) => (false, error.to_string()),
8160            }
8161        }
8162    }
8163
8164    impl ServiceControl for LaunchdControl {
8165        fn manager(&self) -> DefinitionKind {
8166            DefinitionKind::LaunchdPlist
8167        }
8168
8169        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8170            let name = plan.identity().name().to_string();
8171            let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
8172            let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
8173                return Err(self.failed(
8174                    "install",
8175                    &name,
8176                    "this account has no home directory, so there is nowhere to put a \
8177                     LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
8178                     /Library/LaunchDaemons."
8179                        .to_string(),
8180                ));
8181            };
8182            write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8183            let target = self.domain();
8184            let (ok, message) = self.launchctl(&[
8185                OsStr::new("bootstrap"),
8186                OsStr::new(&target),
8187                path.as_os_str(),
8188            ]);
8189            if !ok {
8190                // Leave nothing behind: a plist launchd refused is a file that
8191                // would make the next `service status` claim a registration
8192                // that does not exist.
8193                let _ = std::fs::remove_file(&path);
8194                if message.to_ascii_lowercase().contains("permission denied") {
8195                    return Err(ServiceError::NeedsElevation {
8196                        operation: "install",
8197                        name,
8198                        detail: message,
8199                        remedy: SUDO_REMEDY,
8200                    });
8201                }
8202                return Err(self.failed("install", &name, message));
8203            }
8204            // A previously disabled label stays disabled through a bootstrap,
8205            // which is a service that is installed and will never start.
8206            let service_target = self.service_target(plan.identity());
8207            enable_launchd_registration(
8208                |arguments| self.launchctl(arguments),
8209                &target,
8210                &service_target,
8211                &path,
8212                &name,
8213                SUDO_REMEDY,
8214            )?;
8215            Ok(definition)
8216        }
8217
8218        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8219            let Some(path) = self.plist_path(identity) else {
8220                return Ok(false);
8221            };
8222            if !path.exists() {
8223                return Ok(false);
8224            }
8225            let target = self.service_target(identity);
8226            let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
8227            if !ok
8228                && !message.to_ascii_lowercase().contains("no such process")
8229                && !message.contains("113")
8230            {
8231                if message.to_ascii_lowercase().contains("permission denied") {
8232                    return Err(ServiceError::NeedsElevation {
8233                        operation: "uninstall",
8234                        name: identity.name().to_string(),
8235                        detail: message,
8236                        remedy: SUDO_REMEDY,
8237                    });
8238                }
8239                return Err(self.failed("uninstall", identity.name(), message));
8240            }
8241            // Only the definition. `05-infrastructure.md` item 5: nothing else
8242            // on this host is touched here or anywhere below it.
8243            std::fs::remove_file(&path).map_err(|error| {
8244                super::definition_error(
8245                    "uninstall",
8246                    identity.name(),
8247                    error,
8248                    SUDO_REMEDY,
8249                    path.as_path(),
8250                )
8251            })?;
8252            Ok(true)
8253        }
8254
8255        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8256            let Some(path) = self.plist_path(identity) else {
8257                return Ok(None);
8258            };
8259            let Ok(document) = std::fs::read_to_string(&path) else {
8260                return Ok(None);
8261            };
8262            let target = self.service_target(identity);
8263            let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
8264            Ok(Some(Registration {
8265                manager: DefinitionKind::LaunchdPlist,
8266                start_mode: self.mode,
8267                command_line: program_arguments(&document),
8268                // A LaunchDaemon without `UserName` runs as root; a LaunchAgent
8269                // runs as whoever signs in, which names no one in particular.
8270                account: plist_string_value(&document, "UserName").or_else(|| {
8271                    (self.mode == StartMode::Boot)
8272                        .then(|| super::ServiceAccount::Root.as_str().to_string())
8273                }),
8274                running: loaded && printed.contains("state = running"),
8275                starts_automatically: document.contains("<key>RunAtLoad</key>")
8276                    && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
8277                restart_delay: xml_value(
8278                    super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
8279                    "integer",
8280                )
8281                .and_then(|value| value.parse::<u64>().ok())
8282                .map(Duration::from_secs),
8283            }))
8284        }
8285
8286        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8287            let target = self.service_target(identity);
8288            let (ok, message) = self.launchctl(&[
8289                OsStr::new("kickstart"),
8290                OsStr::new("-k"),
8291                OsStr::new(&target),
8292            ]);
8293            if ok {
8294                Ok(())
8295            } else {
8296                Err(self.failed("start", identity.name(), message))
8297            }
8298        }
8299
8300        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8301            let running = self
8302                .query(identity)?
8303                .is_some_and(|registration| registration.running);
8304            if !running {
8305                return Ok(false);
8306            }
8307            let target = self.service_target(identity);
8308            let (ok, message) = self.launchctl(&[
8309                OsStr::new("kill"),
8310                OsStr::new("SIGTERM"),
8311                OsStr::new(&target),
8312            ]);
8313            if ok {
8314                Ok(true)
8315            } else {
8316                Err(self.failed("stop", identity.name(), message))
8317            }
8318        }
8319    }
8320
8321    /// Boots the job out and bootstraps it again from `plist`, from a shell
8322    /// that outlives this process.
8323    ///
8324    /// `setsid` puts the shell in a session and process group of its own. On a
8325    /// bootout launchd sends SIGTERM to the job's whole process group, the
8326    /// process that asked for the bootout included, and spares a child in
8327    /// another session (measured on macOS 27). Without `setsid` the helper
8328    /// survived that only by luck: one run in four left the job unloaded. The
8329    /// work runs in a background subshell, so the outer shell exits at once and
8330    /// is reaped here rather than left behind. `bootout` blocks until the daemon
8331    /// has exited; the bootstrap is retried for a minute because launchd
8332    /// refuses it while the old job is still being torn down. When the job is
8333    /// still loaded after a failed bootout, every bootstrap fails and the
8334    /// running daemon carries on as it was.
8335    pub(super) fn reload_detached(
8336        identity: &ServiceIdentity,
8337        mode: StartMode,
8338        plist: &std::path::Path,
8339    ) -> Result<(), ServiceError> {
8340        use std::os::unix::process::CommandExt;
8341
8342        const SCRIPT: &str = r#"(
8343  sleep 1
8344  /bin/launchctl bootout "$1"
8345  tries=0
8346  until /bin/launchctl bootstrap "$2" "$3"; do
8347    tries=$((tries + 1))
8348    if [ "$tries" -ge 60 ]; then
8349      echo "runner-manager: launchd did not load $3 again; run: $4" >&2
8350      exit 1
8351    fi
8352    sleep 1
8353  done
8354) </dev/null &"#;
8355
8356        let control = LaunchdControl { mode };
8357        // Once the job is booted out nothing holds the single-instance lock,
8358        // so a reinstall in the same start mode is the remedy.
8359        let reinstall = match mode {
8360            StartMode::Boot => "sudo runner-manager service install --start-at boot",
8361            StartMode::Login => "runner-manager service install --start-at login",
8362        };
8363        let mut command = std::process::Command::new("/bin/sh");
8364        command
8365            .args(["-c", SCRIPT, "runner-manager-reload"])
8366            .arg(control.service_target(identity))
8367            .arg(control.domain())
8368            .arg(plist)
8369            .arg(reinstall)
8370            .stdin(std::process::Stdio::null());
8371        // SAFETY: one async-signal-safe system call between `fork` and `exec`,
8372        // touching no memory, allocator or lock.
8373        unsafe {
8374            command.pre_exec(|| {
8375                if libc::setsid() == -1 {
8376                    return Err(std::io::Error::last_os_error());
8377                }
8378                Ok(())
8379            });
8380        }
8381        let status = command.status().map_err(|error| {
8382            control.failed("reload", identity.name(), format!("/bin/sh: {error}"))
8383        })?;
8384        if status.success() {
8385            Ok(())
8386        } else {
8387            Err(control.failed(
8388                "reload",
8389                identity.name(),
8390                format!("the reload helper exited with {status}"),
8391            ))
8392        }
8393    }
8394
8395    /// Rebuilds the command line from a plist's `ProgramArguments` array.
8396    fn program_arguments(document: &str) -> String {
8397        let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
8398            return String::new();
8399        };
8400        let Some(end) = rest.find("</array>") else {
8401            return String::new();
8402        };
8403        let mut out = Vec::new();
8404        let mut cursor = &rest[..end];
8405        while let Some(open) = cursor.find("<string>") {
8406            let after = &cursor[open + "<string>".len()..];
8407            let Some(close) = after.find("</string>") else {
8408                break;
8409            };
8410            out.push(quote_argument(&super::xml_unescape(&after[..close])));
8411            cursor = &after[close..];
8412        }
8413        out.join(" ")
8414    }
8415}
8416
8417// ---------------------------------------------------------------------------
8418// Linux and the other Unixes
8419// ---------------------------------------------------------------------------
8420
8421#[cfg(all(unix, not(target_os = "macos")))]
8422mod sys {
8423    //! One manager, two domains. `--start-at boot` is a system unit in
8424    //! `/etc/systemd/system`, wanted by `multi-user.target`; `--start-at login`
8425    //! is a user unit in `~/.config/systemd/user`, wanted by `default.target`.
8426    //!
8427    //! Every inspection here goes through `systemctl cat` and `systemctl show`,
8428    //! whose output is machine-readable `key=value` text rather than a
8429    //! sentence, so nothing in this module depends on the machine's display
8430    //! language.
8431
8432    use std::ffi::OsStr;
8433    use std::path::PathBuf;
8434
8435    use runner_manager_domain::model::StartMode;
8436
8437    use super::{
8438        DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
8439        SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
8440        home_directory, ini_directives, run, systemd_exec_command_line, systemd_timespan,
8441        write_definition,
8442    };
8443
8444    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
8445        Ok(Box::new(SystemdControl { mode }))
8446    }
8447
8448    #[derive(Debug)]
8449    struct SystemdControl {
8450        mode: StartMode,
8451    }
8452
8453    impl SystemdControl {
8454        fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
8455            let file = identity.systemd_unit();
8456            match self.mode {
8457                StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
8458                StartMode::Login => {
8459                    home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
8460                }
8461            }
8462        }
8463
8464        /// `systemctl`, with `--user` for the login domain.
8465        fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
8466            let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
8467            if self.mode == StartMode::Login {
8468                all.push(OsStr::new("--user"));
8469            }
8470            all.extend(arguments.iter().map(OsStr::new));
8471            match run("systemctl", &all) {
8472                Ok((ok, stdout, stderr)) => (
8473                    ok,
8474                    if stdout.trim().is_empty() {
8475                        stderr
8476                    } else {
8477                        stdout
8478                    },
8479                ),
8480                Err(error) => (false, error.to_string()),
8481            }
8482        }
8483
8484        fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
8485            ServiceError::Control {
8486                operation,
8487                name: name.to_string(),
8488                manager: "systemd",
8489                detail,
8490            }
8491        }
8492    }
8493
8494    impl ServiceControl for SystemdControl {
8495        fn manager(&self) -> DefinitionKind {
8496            DefinitionKind::SystemdUnit
8497        }
8498
8499        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8500            use std::os::unix::fs::MetadataExt as _;
8501
8502            let name = plan.identity().name().to_string();
8503            if self.mode == StartMode::Boot {
8504                // The credential store's directory as well as the four: the
8505                // service rewrites the credential there when GitHub rotates it.
8506                let refusals = super::permitted_paths(DefinitionKind::SystemdUnit, plan)
8507                    .iter()
8508                    .filter_map(|path| {
8509                        let path = std::path::Path::new(path);
8510                        let owner = std::fs::metadata(path).ok().map(|metadata| metadata.uid());
8511                        super::boot_directory_refusal(path, owner)
8512                    })
8513                    .collect::<Vec<_>>();
8514                if !refusals.is_empty() {
8515                    return Err(self.failed(
8516                        "install",
8517                        &name,
8518                        format!(
8519                            "{}. Run `{}` without --data-dir, which uses {} and {}; a \
8520                             directory there that root does not own is handed back with \
8521                             `sudo chown -R root:root <directory>`",
8522                            refusals.join("; "),
8523                            super::sudo_command("service install --start-at boot"),
8524                            crate::paths::SYSTEM_DATA_ROOT,
8525                            crate::paths::SYSTEM_LOGS_DIR,
8526                        ),
8527                    ));
8528                }
8529            }
8530            // The account the daemon starts its runners as. Created here, from
8531            // the elevated install, since the service itself can create no
8532            // account; a daemon that finds none keeps its runners as root.
8533            if self.mode == StartMode::Boot
8534                && let Err(detail) = crate::runner_account::RunnerAccount::ensure()
8535            {
8536                return Err(self.failed("install", &name, detail));
8537            }
8538            let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
8539            let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
8540                return Err(self.failed(
8541                    "install",
8542                    &name,
8543                    "this account has no home directory, so there is nowhere to put a systemd \
8544                     user unit. Use --start-at boot, which installs a system unit under \
8545                     /etc/systemd/system."
8546                        .to_string(),
8547                ));
8548            };
8549            write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8550            let unit = plan.identity().systemd_unit();
8551            let (reloaded, message) = self.systemctl(&["daemon-reload"]);
8552            if !reloaded {
8553                let _ = std::fs::remove_file(&path);
8554                return Err(self.failed("install", &name, message));
8555            }
8556            let (enabled, message) = self.systemctl(&["enable", &unit]);
8557            if !enabled {
8558                let _ = std::fs::remove_file(&path);
8559                let _ = self.systemctl(&["daemon-reload"]);
8560                return Err(self.failed("install", &name, message));
8561            }
8562            Ok(definition)
8563        }
8564
8565        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8566            let Some(path) = self.unit_path(identity) else {
8567                return Ok(false);
8568            };
8569            if !path.exists() {
8570                return Ok(false);
8571            }
8572            let unit = identity.systemd_unit();
8573            // `--now` stops it as well as disabling it. A failure here is not
8574            // fatal: the unit file is about to go, and refusing to remove it
8575            // because `systemctl` disliked something would leave the host with
8576            // a unit nothing manages.
8577            let _ = self.systemctl(&["disable", "--now", &unit]);
8578            std::fs::remove_file(&path).map_err(|error| {
8579                super::definition_error(
8580                    "uninstall",
8581                    identity.name(),
8582                    error,
8583                    SUDO_REMEDY,
8584                    path.as_path(),
8585                )
8586            })?;
8587            let _ = self.systemctl(&["daemon-reload"]);
8588            Ok(true)
8589        }
8590
8591        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8592            let Some(path) = self.unit_path(identity) else {
8593                return Ok(None);
8594            };
8595            if !path.exists() {
8596                return Ok(None);
8597            }
8598            let unit = identity.systemd_unit();
8599            // `cat` includes administrator drop-ins in systemd's order. Reading
8600            // only our base file hides User/ExecStart/RestartSec overrides, so
8601            // the file is read only when `systemctl` cannot answer.
8602            let effective = match self.systemctl(&["cat", &unit]) {
8603                (true, text) => text,
8604                _ => match std::fs::read_to_string(&path) {
8605                    Ok(document) => document,
8606                    Err(_) => return Ok(None),
8607                },
8608            };
8609            let directives = ini_directives(&effective, "Service");
8610            // One `show` answers what `is-active` and `is-enabled` each did.
8611            let (_, shown) = self.systemctl(&[
8612                "show",
8613                "--property=ActiveState",
8614                "--property=UnitFileState",
8615                &unit,
8616            ]);
8617            let state = |key: &str| {
8618                shown
8619                    .lines()
8620                    .find_map(|line| line.strip_prefix(key)?.strip_prefix('='))
8621                    .map(str::trim)
8622            };
8623            Ok(Some(Registration {
8624                manager: DefinitionKind::SystemdUnit,
8625                start_mode: self.mode,
8626                command_line: directives
8627                    .get("ExecStart")
8628                    .map(|value| systemd_exec_command_line(value))
8629                    .unwrap_or_default(),
8630                // A system unit without `User=` runs as root; a user unit runs
8631                // as whoever owns the session, which names no one in particular.
8632                account: directives
8633                    .get("User")
8634                    .filter(|user| !user.is_empty())
8635                    .cloned()
8636                    .or_else(|| {
8637                        (self.mode == StartMode::Boot)
8638                            .then(|| super::ServiceAccount::Root.as_str().to_string())
8639                    }),
8640                running: state("ActiveState") == Some("active"),
8641                starts_automatically: state("UnitFileState") == Some("enabled"),
8642                restart_delay: directives
8643                    .get("RestartSec")
8644                    .and_then(|value| systemd_timespan(value)),
8645            }))
8646        }
8647
8648        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8649            let unit = identity.systemd_unit();
8650            let (ok, message) = self.systemctl(&["start", &unit]);
8651            if ok {
8652                Ok(())
8653            } else {
8654                Err(self.failed("start", identity.name(), message))
8655            }
8656        }
8657
8658        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8659            let running = self
8660                .query(identity)?
8661                .is_some_and(|registration| registration.running);
8662            if !running {
8663                return Ok(false);
8664            }
8665            let unit = identity.systemd_unit();
8666            let (ok, message) = self.systemctl(&["stop", &unit]);
8667            if ok {
8668                Ok(true)
8669            } else {
8670                Err(self.failed("stop", identity.name(), message))
8671            }
8672        }
8673    }
8674}
8675
8676/// What the macOS tests that load real launch agents share.
8677#[cfg(all(test, target_os = "macos"))]
8678pub(crate) mod launchd_fixture {
8679    use std::path::{Path, PathBuf};
8680
8681    /// Opts a macOS host into the tests that load launch agents into the
8682    /// caller's GUI session. CI's macOS leg sets it, and
8683    /// `privileged_tests_are_wired_into_ci.rs` asserts that it still does.
8684    const ACCEPTANCE: &str = "RUNNER_MANAGER_LAUNCHD_ACCEPTANCE";
8685
8686    /// Whether this run may load launch agents. Says so when it may not, since
8687    /// the test then passes having measured nothing.
8688    pub(crate) fn enabled() -> bool {
8689        let enabled = std::env::var_os(ACCEPTANCE).is_some_and(|value| value == "1");
8690        if !enabled {
8691            eprintln!("skipped: set {ACCEPTANCE}=1 to load test launch agents");
8692        }
8693        enabled
8694    }
8695
8696    /// A copy of the running test binary in `directory`, for a launchd job to
8697    /// run as its program.
8698    ///
8699    /// A copy, because a launchd job whose program lives on an external volume
8700    /// waits on a privacy approval nobody gives before `main` runs (measured
8701    /// with a target directory under `/Volumes`), and the temporary directory
8702    /// is on the boot volume.
8703    pub(crate) fn job_binary(directory: &Path) -> PathBuf {
8704        let binary = directory.join("launch-agent-job");
8705        std::fs::copy(std::env::current_exe().expect("test binary"), &binary)
8706            .expect("test binary copied");
8707        binary
8708    }
8709
8710    /// A launch agent loaded into the caller's GUI domain, booted out on drop.
8711    pub(crate) struct LoadedAgent(String);
8712
8713    impl LoadedAgent {
8714        /// Bootstraps `plist`, whose `Label` is `label`, and fails the test when
8715        /// launchd refuses it.
8716        pub(crate) fn bootstrap(label: &str, plist: &Path) -> Self {
8717            // SAFETY: `getuid` reads the calling process's real user id and
8718            // cannot fail.
8719            let domain = format!("gui/{}", unsafe { libc::getuid() });
8720            // Armed before the bootstrap, so a half-loaded job is removed too.
8721            let loaded = Self(format!("{domain}/{label}"));
8722            let bootstrap = std::process::Command::new("launchctl")
8723                .arg("bootstrap")
8724                .arg(&domain)
8725                .arg(plist)
8726                .output()
8727                .expect("launchctl runs");
8728            assert!(
8729                bootstrap.status.success(),
8730                "launchctl bootstrap {domain} refused {label}, so nothing was measured: {}",
8731                String::from_utf8_lossy(&bootstrap.stderr)
8732            );
8733            loaded
8734        }
8735
8736        /// `gui/<uid>/<label>`.
8737        pub(crate) fn target(&self) -> &str {
8738            &self.0
8739        }
8740    }
8741
8742    impl Drop for LoadedAgent {
8743        fn drop(&mut self) {
8744            let _ = std::process::Command::new("launchctl")
8745                .args(["bootout", &self.0])
8746                .output();
8747        }
8748    }
8749}
8750
8751#[cfg(test)]
8752mod tests {
8753    use super::*;
8754
8755    use std::collections::BTreeMap;
8756
8757    #[test]
8758    fn task_scheduler_omitting_default_enabled_still_means_automatic() {
8759        assert!(windows_login_task_starts_automatically(
8760            "<Task><Triggers><LogonTrigger></LogonTrigger></Triggers></Task>"
8761        ));
8762        assert!(windows_login_task_starts_automatically(
8763            "<Task><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Task>"
8764        ));
8765        assert!(!windows_login_task_starts_automatically(
8766            "<Task><LogonTrigger><Enabled>false</Enabled></LogonTrigger></Task>"
8767        ));
8768        assert!(!windows_login_task_starts_automatically(
8769            "<Task><BootTrigger></BootTrigger></Task>"
8770        ));
8771    }
8772
8773    // -----------------------------------------------------------------------
8774    // Fixtures
8775    // -----------------------------------------------------------------------
8776
8777    /// A plan against paths that exist on no platform, so that every renderer
8778    /// can be asserted on every leg of the CI matrix.
8779    fn linux_plan(mode: StartMode) -> InstallPlan {
8780        InstallPlan::unchecked(
8781            ServiceIdentity::product(),
8782            mode,
8783            "/opt/runner-manager/bin/runner-manager",
8784            ServiceDirectories {
8785                config: PathBuf::from("/var/lib/runner-manager/config"),
8786                state: PathBuf::from("/var/lib/runner-manager/state"),
8787                runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
8788                logs: PathBuf::from("/var/log/runner-manager"),
8789            },
8790        )
8791        .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
8792    }
8793
8794    fn windows_plan(mode: StartMode) -> InstallPlan {
8795        InstallPlan::unchecked(
8796            ServiceIdentity::product(),
8797            mode,
8798            "C:\\Program Files\\runner-manager\\runner-manager.exe",
8799            ServiceDirectories {
8800                config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
8801                state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
8802                runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
8803                logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
8804            },
8805        )
8806    }
8807
8808    /// Replaces one fragment of a rendered definition, and **fails the test if
8809    /// the fragment was not there**.
8810    ///
8811    /// Without the assertion a renderer change would quietly turn every
8812    /// "widened definition is rejected" test below into a test that reviews the
8813    /// unmodified definition and passes for the wrong reason. This is the
8814    /// single guard that keeps that class of vacuity out of this file.
8815    fn edited(text: &str, from: &str, to: &str) -> String {
8816        assert!(
8817            text.contains(from),
8818            "the rendered definition does not contain `{from}`, so this test would assert \
8819             nothing about a widened one"
8820        );
8821        text.replace(from, to)
8822    }
8823
8824    /// Every file under `root`, keyed by its path relative to `root`.
8825    fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
8826        fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
8827            let Ok(entries) = std::fs::read_dir(directory) else {
8828                return;
8829            };
8830            for entry in entries.flatten() {
8831                let path = entry.path();
8832                if path.is_dir() {
8833                    walk(&path, out);
8834                } else if let Ok(bytes) = std::fs::read(&path) {
8835                    out.insert(path, bytes);
8836                }
8837            }
8838        }
8839        let mut out = BTreeMap::new();
8840        for root in roots {
8841            walk(root, &mut out);
8842        }
8843        out
8844    }
8845
8846    struct Host {
8847        _root: tempfile::TempDir,
8848        paths: AppPaths,
8849        binary: PathBuf,
8850        /// A runner root inside this host's own temporary tree.
8851        ///
8852        /// Without it every `install` in this module would create and
8853        /// re-permission the **real** `%SystemDrive%\rman` on the machine
8854        /// running the tests, and would fail outright on any machine where that
8855        /// directory already exists with the access control `C:\` gives it.
8856        /// A sibling of the four application-data directories rather than a
8857        /// child of one, so `b1`'s overlap check has nothing to object to.
8858        runner_root: LocalAbsolutePath,
8859        controls: RecordingControls,
8860    }
8861
8862    impl Host {
8863        fn new() -> Self {
8864            let root = tempfile::tempdir().expect("a temporary directory");
8865            let paths = AppPaths::rooted_at(root.path());
8866            paths.create_all().expect("the four directories");
8867            let binary = root.path().join(if cfg!(windows) {
8868                "runner-manager.exe"
8869            } else {
8870                "runner-manager"
8871            });
8872            std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
8873            let runner_root = LocalAbsolutePath::new(
8874                root.path()
8875                    .join("runner-root")
8876                    .to_str()
8877                    .expect("a unicode temporary path"),
8878            )
8879            .expect("a local absolute path");
8880            Self {
8881                _root: root,
8882                paths,
8883                binary,
8884                runner_root,
8885                controls: RecordingControls::new(),
8886            }
8887        }
8888
8889        fn operations(&self) -> ServiceOperations {
8890            ServiceOperations::with_controls(
8891                self.paths.clone(),
8892                ServiceIdentity::product(),
8893                std::sync::Arc::new(self.controls.clone()),
8894            )
8895            .with_runner_root(self.runner_root.clone())
8896        }
8897
8898        fn request(&self, mode: StartMode) -> InstallRequest {
8899            InstallRequest::new(mode).for_binary(&self.binary)
8900        }
8901    }
8902
8903    #[cfg(windows)]
8904    #[test]
8905    fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
8906        let probes = std::cell::Cell::new(0);
8907        let absent =
8908            super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
8909                let next = probes.get() + 1;
8910                probes.set(next);
8911                Ok(next == 3)
8912            })
8913            .expect("the simulated SCM probe succeeds");
8914
8915        assert!(absent);
8916        assert_eq!(
8917            probes.get(),
8918            3,
8919            "uninstall must recheck after transient presence instead of treating it as a leak"
8920        );
8921    }
8922
8923    #[cfg(windows)]
8924    #[test]
8925    fn windows_recognises_an_already_pending_service_deletion() {
8926        let error = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(1072));
8927
8928        assert!(super::sys::is_marked_for_delete(&error));
8929    }
8930
8931    #[test]
8932    fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
8933        let root = tempfile::tempdir().expect("a temporary directory");
8934        let plist = root.path().join("fixture.plist");
8935        std::fs::write(&plist, b"fixture").expect("a plist fixture");
8936        let calls = std::cell::RefCell::new(Vec::new());
8937
8938        let error = enable_launchd_registration(
8939            |arguments| {
8940                let call = arguments
8941                    .iter()
8942                    .map(|argument| argument.to_string_lossy().into_owned())
8943                    .collect::<Vec<_>>();
8944                let operation = call[0].clone();
8945                calls.borrow_mut().push(call);
8946                if operation == "enable" {
8947                    (false, "label remains disabled".to_string())
8948                } else {
8949                    (true, String::new())
8950                }
8951            },
8952            "system",
8953            "system/com.openai.runner-manager-selftest",
8954            &plist,
8955            "runner-manager-selftest",
8956            "rerun with administrative rights",
8957        )
8958        .expect_err("enable failure must fail the install");
8959
8960        assert!(
8961            matches!(
8962                error,
8963                ServiceError::Control {
8964                    operation: "enable",
8965                    ..
8966                }
8967            ),
8968            "{error}"
8969        );
8970        assert_eq!(calls.borrow().len(), 2);
8971        assert_eq!(calls.borrow()[0][0], "enable");
8972        assert_eq!(calls.borrow()[1][0], "bootout");
8973        assert!(!plist.exists(), "rollback must remove the plist");
8974    }
8975
8976    // -----------------------------------------------------------------------
8977    // Quoting, and reading a command line back
8978    // -----------------------------------------------------------------------
8979
8980    #[test]
8981    fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
8982        let plan = windows_plan(StartMode::Boot);
8983        let command_line = plan.command_line();
8984        assert!(
8985            command_line.starts_with('"'),
8986            "a path with a space must be quoted, got {command_line}"
8987        );
8988        assert_eq!(
8989            executable_from_command_line(&command_line).as_deref(),
8990            Some(plan.binary())
8991        );
8992    }
8993
8994    #[test]
8995    fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
8996        let plan = linux_plan(StartMode::Boot);
8997        let command_line = plan.command_line();
8998        assert!(!command_line.starts_with('"'), "got {command_line}");
8999        assert_eq!(
9000            executable_from_command_line(&command_line).as_deref(),
9001            Some(plan.binary())
9002        );
9003    }
9004
9005    #[test]
9006    fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
9007        // Not a path anybody has, but it is the input that separates a real
9008        // implementation of Windows' quoting rules from a `split_whitespace`.
9009        let awkward = r#"C:\odd "name"\rm.exe"#;
9010        let quoted = quote_argument(awkward);
9011        assert_eq!(
9012            executable_from_command_line(&format!("{quoted} daemon run"))
9013                .as_deref()
9014                .map(Path::to_string_lossy)
9015                .as_deref(),
9016            Some(awkward)
9017        );
9018    }
9019
9020    #[test]
9021    fn an_empty_command_line_has_no_executable() {
9022        assert_eq!(executable_from_command_line("   "), None);
9023        assert_eq!(executable_from_command_line(""), None);
9024    }
9025
9026    #[test]
9027    fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
9028        let awkward = r#"DOMAIN\R&D <team> "ops""#;
9029        assert_eq!(
9030            xml_escape(awkward),
9031            "DOMAIN\\R&amp;D &lt;team&gt; &quot;ops&quot;"
9032        );
9033        assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
9034    }
9035
9036    // -----------------------------------------------------------------------
9037    // The restart policy is bounded at both ends
9038    // -----------------------------------------------------------------------
9039
9040    #[test]
9041    fn a_restart_delay_under_the_floor_is_refused() {
9042        let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
9043            .expect_err("half a second is under the one-second floor");
9044        assert!(
9045            matches!(error, ServiceError::RestartDelay { .. }),
9046            "{error}"
9047        );
9048    }
9049
9050    #[test]
9051    fn a_restart_delay_over_the_ceiling_is_refused() {
9052        let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
9053            .expect_err("an hour is over the five-minute ceiling");
9054        assert!(
9055            matches!(error, ServiceError::RestartDelay { .. }),
9056            "{error}"
9057        );
9058    }
9059
9060    #[test]
9061    fn a_reset_window_no_longer_than_the_delay_is_refused() {
9062        let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
9063            .expect_err("a window equal to the delay can never elapse between restarts");
9064        assert!(
9065            matches!(error, ServiceError::RestartResetWindow { .. }),
9066            "{error}"
9067        );
9068    }
9069
9070    #[test]
9071    fn a_delay_inside_the_bound_is_accepted() {
9072        let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
9073            .expect("twenty seconds is inside the bound");
9074        assert_eq!(policy.delay(), Duration::from_secs(20));
9075        assert_eq!(policy.reset_after(), Duration::from_secs(300));
9076    }
9077
9078    // -----------------------------------------------------------------------
9079    // Identity
9080    // -----------------------------------------------------------------------
9081
9082    #[test]
9083    fn a_fixture_identity_can_never_be_the_product_identity() {
9084        let fixture = ServiceIdentity::fixture("abc123");
9085        assert!(fixture.is_fixture());
9086        assert!(!ServiceIdentity::product().is_fixture());
9087        assert_ne!(fixture.name(), ServiceIdentity::product().name());
9088        assert_ne!(
9089            fixture.launchd_label(),
9090            ServiceIdentity::product().launchd_label()
9091        );
9092        assert_ne!(
9093            fixture.systemd_unit(),
9094            ServiceIdentity::product().systemd_unit()
9095        );
9096    }
9097
9098    #[test]
9099    fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
9100        let fixture = ServiceIdentity::fixture("A b/c:\\d");
9101        assert!(
9102            fixture
9103                .name()
9104                .chars()
9105                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
9106            "got {}",
9107            fixture.name()
9108        );
9109    }
9110
9111    // -----------------------------------------------------------------------
9112    // The systemd unit
9113    // -----------------------------------------------------------------------
9114
9115    #[test]
9116    fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
9117        let unit = systemd_unit(&linux_plan(StartMode::Boot));
9118        assert!(unit.contains("KillMode=process\n"), "{unit}");
9119        assert!(unit.contains("Restart=on-failure\n"), "{unit}");
9120        assert!(unit.contains("RestartSec=15\n"), "{unit}");
9121        assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
9122        assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
9123        assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
9124    }
9125
9126    #[test]
9127    fn the_boot_unit_reads_the_live_store_instead_of_a_frozen_systemd_copy() {
9128        let unit = systemd_unit(&linux_plan(StartMode::Boot));
9129        assert!(
9130            !unit.contains("LoadCredential="),
9131            "a startup snapshot would shadow every rotated credential until restart:\n{unit}"
9132        );
9133    }
9134
9135    #[test]
9136    fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
9137        let unit = systemd_unit(&linux_plan(StartMode::Login));
9138        assert!(
9139            !unit.contains("LoadCredential="),
9140            "a user unit must not name a root-owned credential file, got:\n{unit}"
9141        );
9142        assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
9143    }
9144
9145    #[test]
9146    fn the_unit_can_atomically_replace_the_credential_and_write_only_required_directories() {
9147        let plan = linux_plan(StartMode::Boot);
9148        let unit = systemd_unit(&plan);
9149        let directives = ini_directives(&unit, "Service");
9150        let listed = split_quoted(
9151            directives
9152                .get("ReadWritePaths")
9153                .expect("the unit names its writable paths"),
9154        );
9155        assert_eq!(listed.len(), 5, "{listed:?}");
9156        for path in plan.directories().all() {
9157            assert!(
9158                listed.iter().any(|entry| entry == &path.to_string_lossy()),
9159                "{} is missing from {listed:?}",
9160                path.display()
9161            );
9162        }
9163        assert!(
9164            listed
9165                .iter()
9166                .any(|entry| entry == "/var/lib/runner-manager/secrets"),
9167            "atomic credential replacement needs its parent directory in {listed:?}"
9168        );
9169    }
9170
9171    #[test]
9172    fn the_unit_records_the_absolute_binary_path() {
9173        let plan = linux_plan(StartMode::Boot);
9174        let unit = systemd_unit(&plan);
9175        assert!(
9176            unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
9177            "{unit}"
9178        );
9179    }
9180
9181    // -----------------------------------------------------------------------
9182    // The launchd property list
9183    // -----------------------------------------------------------------------
9184
9185    #[test]
9186    fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
9187        let plist = launchd_plist(&linux_plan(StartMode::Boot));
9188        // A bare `KeepAlive` would also restart a job the operator stopped
9189        // deliberately, which turns `service stop` into a fight with launchd.
9190        assert!(
9191            plist.contains(
9192                "<key>KeepAlive</key>\n  <dict>\n    <key>SuccessfulExit</key>\n    <false/>\n"
9193            ),
9194            "{plist}"
9195        );
9196        assert!(
9197            plist.contains("<key>ThrottleInterval</key>\n  <integer>15</integer>"),
9198            "{plist}"
9199        );
9200    }
9201
9202    #[test]
9203    fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
9204        let daemon = launchd_plist(&linux_plan(StartMode::Boot));
9205        assert_eq!(
9206            plist_string_value(&daemon, "UserName").as_deref(),
9207            Some("root")
9208        );
9209        assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
9210
9211        let agent = launchd_plist(&linux_plan(StartMode::Login));
9212        assert_eq!(
9213            plist_string_value(&agent, "UserName"),
9214            None,
9215            "a LaunchAgent already runs as the operator:\n{agent}"
9216        );
9217    }
9218
9219    #[test]
9220    fn the_plist_runs_the_daemon_and_its_runners_as_an_interactive_job() {
9221        for mode in [StartMode::Boot, StartMode::Login] {
9222            let plan = linux_plan(mode);
9223            let plist = launchd_plist(&plan);
9224            assert_eq!(
9225                plist_string_value(&plist, "ProcessType").as_deref(),
9226                Some("Interactive"),
9227                "launchd applies ProcessType to every runner the daemon starts:\n{plist}"
9228            );
9229            let review = review_least_privilege(
9230                &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, plist),
9231                &plan,
9232            );
9233            assert!(
9234                review
9235                    .controls()
9236                    .iter()
9237                    .any(|control| control == "ProcessType=Interactive"),
9238                "{review}"
9239            );
9240            assert!(
9241                !review
9242                    .findings()
9243                    .iter()
9244                    .any(|finding| finding.subject == "ProcessType"),
9245                "{review}"
9246            );
9247        }
9248    }
9249
9250    /// The value 0.4.31 and earlier installed, and launchd's default, both
9251    /// throttle every runner. That is a shortfall the update repairs, not an
9252    /// excess: a scheduling class grants no authority.
9253    #[test]
9254    fn a_plist_that_throttles_its_runners_is_a_shortfall() {
9255        let plan = linux_plan(StartMode::Login);
9256        let rendered = launchd_plist(&plan);
9257        let interactive = "<key>ProcessType</key>\n  <string>Interactive</string>\n";
9258        for (label, throttled) in [
9259            (
9260                "Background",
9261                edited(
9262                    &rendered,
9263                    interactive,
9264                    "<key>ProcessType</key>\n  <string>Background</string>\n",
9265                ),
9266            ),
9267            ("absent", edited(&rendered, interactive, "")),
9268        ] {
9269            let review = review_least_privilege(
9270                &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, throttled),
9271                &plan,
9272            );
9273            assert!(review.is_least_privilege(), "{label}: {review}");
9274            assert!(
9275                review.findings().iter().any(|finding| {
9276                    finding.kind == FindingKind::Shortfall
9277                        && finding.subject == "ProcessType"
9278                        && finding.detail.contains(label)
9279                }),
9280                "{label}: {review}"
9281            );
9282        }
9283    }
9284
9285    #[test]
9286    fn a_plist_with_an_old_process_type_is_drift_and_an_operator_edit_is_not() {
9287        let host = Host::new();
9288        let identity = ServiceIdentity::product();
9289        let plan = InstallPlan::resolve(
9290            identity.clone(),
9291            &host.request(StartMode::Login),
9292            ServiceDirectories::of(&host.paths),
9293        )
9294        .expect("a resolvable plan");
9295        let installed = launchd_plist(&plan);
9296        let definition =
9297            ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, installed.clone());
9298        let mut record = InstallRecord::of(&plan, &definition, Utc::now());
9299        record.installed_by_version = "0.4.31".into();
9300        let path = PathBuf::from("/Users/op/Library/LaunchAgents/runner-manager.plist");
9301
9302        assert_eq!(
9303            launchd_definition_drift(&identity, &record, path.clone(), &installed),
9304            None
9305        );
9306        let edited_by_operator = edited(
9307            &installed,
9308            "</dict>\n</plist>\n",
9309            "  <key>Nice</key>\n  <integer>0</integer>\n</dict>\n</plist>\n",
9310        );
9311        assert_eq!(
9312            launchd_definition_drift(&identity, &record, path.clone(), &edited_by_operator),
9313            None,
9314            "the daemon repairs this drift itself, so it must not key on edits it did not cause"
9315        );
9316
9317        let written_by_0_4_31 = edited(
9318            &installed,
9319            "<string>Interactive</string>",
9320            "<string>Background</string>",
9321        );
9322        let drift = launchd_definition_drift(&identity, &record, path, &written_by_0_4_31)
9323            .expect("a Background plist is drift");
9324        assert_eq!(drift.manager, DefinitionKind::LaunchdPlist);
9325        assert_eq!(drift.start_mode, StartMode::Login);
9326        assert_eq!(
9327            drift.rendered, installed,
9328            "the repair must write exactly what `service install` writes"
9329        );
9330
9331        let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
9332            .with_definition_drift(Some(drift));
9333        let problem = status
9334            .problems()
9335            .iter()
9336            .find(|problem| problem.subject == "definition")
9337            .expect("the drift is reported");
9338        // The remedy is a restart: a running daemon holds the lock that refuses
9339        // `service install`.
9340        assert!(
9341            problem.detail.contains("0.4.31")
9342                && problem.detail.contains(
9343                    "launchctl kickstart -k gui/$(id -u)/io.github.IvanMurzak.runner-manager`"
9344                )
9345                && !problem.detail.contains("service install")
9346                && !problem.detail.contains("systemctl")
9347                && !problem.detail.contains("sudo"),
9348            "{}",
9349            problem.detail
9350        );
9351    }
9352
9353    #[cfg(unix)]
9354    #[test]
9355    fn a_replaced_definition_is_the_rendering_and_readable_by_launchd() {
9356        use std::os::unix::fs::PermissionsExt as _;
9357
9358        let root = tempfile::tempdir().expect("a temporary directory");
9359        let path = root
9360            .path()
9361            .join("io.github.IvanMurzak.runner-manager.plist");
9362        std::fs::write(&path, "<string>Background</string>").expect("old plist");
9363        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
9364        let drift = DefinitionDrift {
9365            path: path.clone(),
9366            installed_by_version: "0.4.31".into(),
9367            rendered: launchd_plist(&linux_plan(StartMode::Login)),
9368            start_mode: StartMode::Login,
9369            manager: DefinitionKind::LaunchdPlist,
9370        };
9371
9372        replace_definition("runner-manager", &drift).expect("replaced");
9373
9374        assert_eq!(std::fs::read_to_string(&path).unwrap(), drift.rendered);
9375        assert_eq!(
9376            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
9377            0o644
9378        );
9379        assert_eq!(
9380            std::fs::read_dir(root.path()).unwrap().count(),
9381            1,
9382            "no staging file is left beside the plist"
9383        );
9384    }
9385
9386    /// The job of [`a_job_reloads_itself_from_its_rewritten_plist`], run by
9387    /// launchd and by nothing else.
9388    ///
9389    /// It does what the daemon does when it starts: when its own plist (kept
9390    /// in its working directory) does not carry [`LAUNCHD_PROCESS_TYPE`], it
9391    /// rewrites the plist and asks for the reload from inside the job that the
9392    /// reload boots out. Either way it then stays up, to be booted out by the
9393    /// helper or, once reloaded, by the test.
9394    #[cfg(target_os = "macos")]
9395    #[test]
9396    #[ignore = "run only by launchd, as the job of the detached-reload test"]
9397    fn detached_reload_job() {
9398        let label = std::env::var("XPC_SERVICE_NAME").unwrap_or_default();
9399        let tag = label
9400            .split_once("-selftest-")
9401            .map(|(_, tag)| tag.to_string())
9402            .unwrap_or_else(|| panic!("run only by launchd for the reload test, not {label:?}"));
9403        let identity = ServiceIdentity::fixture(&tag);
9404        assert_eq!(identity.launchd_label(), label);
9405        let path = std::env::current_dir()
9406            .expect("working directory")
9407            .join(format!("{label}.plist"));
9408        let installed = std::fs::read_to_string(&path).expect("own plist");
9409        if plist_string_value(&installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE) {
9410            let drift = DefinitionDrift {
9411                path: path.clone(),
9412                installed_by_version: "0.4.31".into(),
9413                rendered: installed.replace(
9414                    "<string>Background</string>",
9415                    &format!("<string>{LAUNCHD_PROCESS_TYPE}</string>"),
9416                ),
9417                start_mode: StartMode::Login,
9418                manager: DefinitionKind::LaunchdPlist,
9419            };
9420            replace_definition(identity.name(), &drift).expect("plist replaced");
9421            super::sys::reload_detached(&identity, StartMode::Login, &path)
9422                .expect("helper started");
9423        }
9424        std::thread::sleep(Duration::from_secs(120));
9425    }
9426
9427    /// A job that rewrites its own plist and asks for the reload comes back
9428    /// under the rewritten plist.
9429    ///
9430    /// The request comes from inside the job, as the daemon's does, so the
9431    /// helper has to outlive the bootout of the very job that started it.
9432    /// launchd sends SIGTERM to the job's whole process group, so a helper that
9433    /// stayed in it would sometimes die before its bootstrap and leave the job
9434    /// unloaded. Measured with `setsid` removed, that failed one run in four
9435    /// here: this test catches it, though not on every run.
9436    #[cfg(target_os = "macos")]
9437    #[test]
9438    fn a_job_reloads_itself_from_its_rewritten_plist() {
9439        if !super::launchd_fixture::enabled() {
9440            return;
9441        }
9442        let root = tempfile::tempdir().expect("a temporary directory");
9443        let identity = ServiceIdentity::fixture(&format!("reload-{}", std::process::id()));
9444        let label = identity.launchd_label();
9445        let path = root.path().join(format!("{label}.plist"));
9446        let binary = super::launchd_fixture::job_binary(root.path());
9447        let mut plist = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
9448        plist.push_str("<plist version=\"1.0\">\n<dict>\n");
9449        plist.push_str(&plist_string("Label", &label));
9450        plist.push_str("  <key>ProgramArguments</key>\n  <array>\n");
9451        for argument in [
9452            binary.to_string_lossy().as_ref(),
9453            "--ignored",
9454            "--exact",
9455            "service::tests::detached_reload_job",
9456            "--test-threads=1",
9457        ] {
9458            plist.push_str(&format!("    <string>{}</string>\n", xml_escape(argument)));
9459        }
9460        plist.push_str("  </array>\n  <key>RunAtLoad</key>\n  <true/>\n");
9461        plist.push_str(&plist_string(
9462            "WorkingDirectory",
9463            &root.path().to_string_lossy(),
9464        ));
9465        plist.push_str(&plist_string("ProcessType", "Background"));
9466        plist.push_str("</dict>\n</plist>\n");
9467        std::fs::write(&path, plist).expect("plist written");
9468
9469        let loaded = super::launchd_fixture::LoadedAgent::bootstrap(&label, &path);
9470        let spawn_type = || {
9471            let printed = std::process::Command::new("launchctl")
9472                .args(["print", loaded.target()])
9473                .output()
9474                .expect("launchctl runs");
9475            String::from_utf8_lossy(&printed.stdout)
9476                .lines()
9477                .find_map(|line| {
9478                    line.trim()
9479                        .strip_prefix("spawn type = ")
9480                        .map(str::to_string)
9481                })
9482        };
9483        assert_eq!(spawn_type().as_deref(), Some("background (5)"));
9484
9485        let deadline = std::time::Instant::now() + Duration::from_secs(90);
9486        while spawn_type().as_deref() != Some("interactive (4)") {
9487            assert!(
9488                std::time::Instant::now() < deadline,
9489                "the job did not come back under its rewritten plist; launchd reports {:?}",
9490                spawn_type()
9491            );
9492            std::thread::sleep(Duration::from_millis(250));
9493        }
9494        let rewritten = std::fs::read_to_string(&path).expect("plist readable");
9495        assert_eq!(
9496            plist_string_value(&rewritten, "ProcessType").as_deref(),
9497            Some(LAUNCHD_PROCESS_TYPE)
9498        );
9499    }
9500
9501    #[test]
9502    fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
9503        let plist = launchd_plist(&linux_plan(StartMode::Boot));
9504        assert!(
9505            plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
9506            "{plist}"
9507        );
9508        assert!(plist.contains("<string>daemon</string>"), "{plist}");
9509        assert!(plist.contains("<string>run</string>"), "{plist}");
9510    }
9511
9512    #[test]
9513    fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
9514        assert_eq!(
9515            ServiceIdentity::product().launchd_label(),
9516            "io.github.IvanMurzak.runner-manager"
9517        );
9518    }
9519
9520    // -----------------------------------------------------------------------
9521    // The Task Scheduler document
9522    // -----------------------------------------------------------------------
9523
9524    #[test]
9525    fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
9526        let xml = windows_scheduled_task_xml(
9527            &windows_plan(StartMode::Login),
9528            &TaskPrincipal::named("HOST\\operator"),
9529        );
9530        assert!(xml.contains("<LogonTrigger>"), "{xml}");
9531        assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
9532        assert!(
9533            xml.contains("<LogonType>InteractiveToken</LogonType>"),
9534            "{xml}"
9535        );
9536        assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
9537        assert!(
9538            xml.contains("<Interval>PT1M</Interval>"),
9539            "Task Scheduler takes whole minutes only, and rejects the registration outright \
9540             for anything finer:\n{xml}"
9541        );
9542    }
9543
9544    #[test]
9545    fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
9546        // Measured against the real Task Scheduler, which answers `PT15S` with
9547        // "The task XML contains a value which is incorrectly formatted or out
9548        // of range". Rounding *up* is what keeps "does not restart-loop faster
9549        // than that bound" true on this manager.
9550        for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
9551            let policy =
9552                RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
9553                    .expect("inside the supported range");
9554            assert_eq!(
9555                policy
9556                    .effective_delay(DefinitionKind::WindowsScheduledTask)
9557                    .as_secs(),
9558                enforced,
9559                "a {asked}s delay must be enforced as {enforced}s"
9560            );
9561            assert!(
9562                policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
9563                "rounding must never shorten the bound"
9564            );
9565        }
9566    }
9567
9568    #[test]
9569    fn every_other_manager_enforces_the_delay_exactly_as_configured() {
9570        let policy = RestartPolicy::default();
9571        for kind in [
9572            DefinitionKind::WindowsService,
9573            DefinitionKind::LaunchdPlist,
9574            DefinitionKind::SystemdUnit,
9575        ] {
9576            assert_eq!(
9577                policy.effective_delay(kind),
9578                policy.delay(),
9579                "{kind:?} takes seconds and enforces exactly what it is given"
9580            );
9581        }
9582    }
9583
9584    #[test]
9585    fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
9586        let host = Host::new();
9587        let operations = host.operations();
9588        operations
9589            .install(&host.request(StartMode::Login))
9590            .expect("an install at login");
9591        // What a real Task Scheduler reports back for a 15-second policy.
9592        host.controls.edit("runner-manager", |registration| {
9593            registration.manager = DefinitionKind::WindowsScheduledTask;
9594            registration.restart_delay = Some(Duration::from_secs(60));
9595        });
9596
9597        let status = operations.status().expect("a status");
9598        assert!(
9599            status.is_healthy(),
9600            "minute granularity is the manager's, not a mis-registration: {status}"
9601        );
9602        assert!(
9603            status
9604                .notes()
9605                .iter()
9606                .any(|note| note.contains("whole minutes")),
9607            "but the operator must be told why 15 became 60: {status}"
9608        );
9609
9610        // The discriminator: a delay that is neither the configured one nor its
9611        // rounding is still a fault.
9612        host.controls.edit("runner-manager", |registration| {
9613            registration.restart_delay = Some(Duration::from_secs(1));
9614        });
9615        assert!(
9616            !operations.status().expect("a status").is_healthy(),
9617            "a one-second delay is not what any manager was asked for"
9618        );
9619    }
9620
9621    #[test]
9622    fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
9623        let plan = windows_plan(StartMode::Login);
9624        let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
9625        assert_eq!(
9626            xml_value(&xml, "Command").as_deref(),
9627            Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
9628            "{xml}"
9629        );
9630        assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
9631    }
9632
9633    #[test]
9634    fn an_account_name_holding_xml_punctuation_is_escaped() {
9635        let xml = windows_scheduled_task_xml(
9636            &windows_plan(StartMode::Login),
9637            &TaskPrincipal::named("R&D\\ops"),
9638        );
9639        assert!(xml.contains("<UserId>R&amp;D\\ops</UserId>"), "{xml}");
9640        assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
9641    }
9642
9643    // -----------------------------------------------------------------------
9644    // The Windows service descriptor
9645    // -----------------------------------------------------------------------
9646
9647    #[test]
9648    fn the_service_starts_automatically_under_the_account_the_store_admits() {
9649        let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
9650        let directives = ini_directives(&text, "windows-service");
9651        assert_eq!(
9652            directives.get("StartType").map(String::as_str),
9653            Some("AutoStart")
9654        );
9655        assert_eq!(
9656            directives.get("Account").map(String::as_str),
9657            Some("NT AUTHORITY\\SYSTEM")
9658        );
9659        assert_eq!(
9660            directives.get("ServiceType").map(String::as_str),
9661            Some("OWN_PROCESS")
9662        );
9663        assert_eq!(
9664            directives
9665                .get("FailureActionRestartDelaySecs")
9666                .map(String::as_str),
9667            Some("15")
9668        );
9669        assert_eq!(
9670            directives
9671                .get("FailureActionsOnNonCrashFailures")
9672                .map(String::as_str),
9673            Some("true"),
9674            "without this flag a non-zero exit is not a failure the manager restarts"
9675        );
9676    }
9677
9678    #[test]
9679    fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
9680        let spec = windows_service_spec(&windows_plan(StartMode::Boot));
9681        assert_eq!(spec.account, None);
9682        assert!(spec.automatic_start);
9683        assert!(
9684            spec.command_line.contains("daemon run"),
9685            "{}",
9686            spec.command_line
9687        );
9688    }
9689
9690    // -----------------------------------------------------------------------
9691    // Where each definition is installed
9692    // -----------------------------------------------------------------------
9693
9694    #[test]
9695    fn each_definition_goes_where_its_platform_expects_it() {
9696        let home = PathBuf::from("/home/op");
9697        assert_eq!(
9698            ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
9699            Some(Path::new(
9700                "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
9701            ))
9702        );
9703        assert_eq!(
9704            ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
9705            Some(Path::new(
9706                "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
9707            ))
9708        );
9709        assert_eq!(
9710            ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
9711            Some(Path::new("/etc/systemd/system/runner-manager.service"))
9712        );
9713        assert_eq!(
9714            ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
9715            Some(Path::new(
9716                "/home/op/.config/systemd/user/runner-manager.service"
9717            ))
9718        );
9719        assert_eq!(
9720            ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
9721            None,
9722            "the Service Control Manager has no file"
9723        );
9724    }
9725
9726    #[test]
9727    fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
9728        assert_eq!(
9729            ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
9730            None
9731        );
9732        assert_eq!(
9733            ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
9734            None
9735        );
9736    }
9737
9738    // -----------------------------------------------------------------------
9739    // The least-privilege review: the passing case, then every failing one
9740    // -----------------------------------------------------------------------
9741
9742    #[test]
9743    fn the_rendered_definitions_are_all_least_privilege() {
9744        let linux = linux_plan(StartMode::Boot);
9745        let windows = windows_plan(StartMode::Boot);
9746        for (definition, plan) in [
9747            (ServiceDefinition::systemd(&linux, None), &linux),
9748            (ServiceDefinition::launchd(&linux, None), &linux),
9749            (ServiceDefinition::windows_service(&windows), &windows),
9750        ] {
9751            let review = review_least_privilege(&definition, plan);
9752            assert!(
9753                review.is_least_privilege(),
9754                "{:?} should be least privilege, got:\n{review}",
9755                definition.kind()
9756            );
9757            assert!(
9758                !review.controls().is_empty(),
9759                "a review that confirms nothing proves nothing: {:?}",
9760                definition.kind()
9761            );
9762        }
9763    }
9764
9765    #[test]
9766    fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
9767        let plan = windows_plan(StartMode::Login);
9768        let definition =
9769            ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
9770        let review = review_least_privilege(&definition, &plan);
9771        assert!(review.is_least_privilege(), "{review}");
9772        assert!(
9773            review
9774                .controls()
9775                .iter()
9776                .any(|control| control.contains("LeastPrivilege")),
9777            "{review}"
9778        );
9779    }
9780
9781    #[test]
9782    fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
9783        let plan = linux_plan(StartMode::Boot);
9784        let rendered = systemd_unit(&plan);
9785        let widened = edited(
9786            &rendered,
9787            "ReadWritePaths=/var/lib/runner-manager/config",
9788            "ReadWritePaths=/etc /var/lib/runner-manager/config",
9789        );
9790        let review = review_least_privilege(
9791            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
9792            &plan,
9793        );
9794        assert!(!review.is_least_privilege(), "{review}");
9795        assert!(
9796            review
9797                .excesses()
9798                .iter()
9799                .any(|finding| finding.detail.contains("/etc")),
9800            "the review must name the directory it objects to: {review}"
9801        );
9802    }
9803
9804    #[test]
9805    fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
9806        let plan = linux_plan(StartMode::Boot);
9807        let rendered = systemd_unit(&plan);
9808        let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
9809        let review = review_least_privilege(
9810            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
9811            &plan,
9812        );
9813        assert!(!review.is_least_privilege(), "{review}");
9814        assert!(
9815            review
9816                .excesses()
9817                .iter()
9818                .any(|finding| finding.subject == "NoNewPrivileges"),
9819            "{review}"
9820        );
9821    }
9822
9823    /// Whether reviewing `unit` against `plan` reports `kind` for `subject`.
9824    fn reports(unit: &str, plan: &InstallPlan, subject: &str, kind: FindingKind) -> bool {
9825        review_least_privilege(
9826            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, unit.to_string()),
9827            plan,
9828        )
9829        .findings()
9830        .iter()
9831        .any(|finding| finding.subject == subject && finding.kind == kind)
9832    }
9833
9834    #[test]
9835    fn no_unit_breaks_tar_by_answering_openat2_with_enosys() {
9836        for mode in [StartMode::Boot, StartMode::Login] {
9837            let unit = systemd_unit(&linux_plan(mode));
9838            assert!(
9839                !unit.contains("RestrictSUIDSGID"),
9840                "RestrictSUIDSGID fails every tar extraction in a job: {unit}"
9841            );
9842        }
9843    }
9844
9845    #[test]
9846    fn only_a_boot_daemon_keeps_capabilities_and_boot_units_hide_home_trees() {
9847        let boot = systemd_unit(&linux_plan(StartMode::Boot));
9848        let directives = ini_directives(&boot, "Service");
9849        assert_eq!(directives["CapabilityBoundingSet"], BOOT_CAPABILITIES);
9850        assert_eq!(directives["AmbientCapabilities"], "");
9851        assert_eq!(directives["NoNewPrivileges"], "yes");
9852        assert_eq!(directives["ProtectHome"], "tmpfs");
9853        assert!(!directives.contains_key("BindPaths"));
9854
9855        let login = systemd_unit(&linux_plan(StartMode::Login));
9856        let directives = ini_directives(&login, "Service");
9857        assert_eq!(directives["CapabilityBoundingSet"], "");
9858        assert!(!directives.contains_key("ProtectHome"));
9859
9860        let unprotected = boot.replace("ProtectHome=tmpfs\n", "");
9861        assert!(reports(
9862            &unprotected,
9863            &linux_plan(StartMode::Boot),
9864            "ProtectHome",
9865            FindingKind::Excess
9866        ));
9867        let boot_set = format!("CapabilityBoundingSet={BOOT_CAPABILITIES}\n");
9868        let inverted = boot.replace(&boot_set, "CapabilityBoundingSet=~CAP_SYS_ADMIN\n");
9869        assert!(reports(
9870            &inverted,
9871            &linux_plan(StartMode::Boot),
9872            "CapabilityBoundingSet",
9873            FindingKind::Excess
9874        ));
9875        let widened = boot.replace(
9876            &boot_set,
9877            &format!("CapabilityBoundingSet={BOOT_CAPABILITIES} CAP_SYS_ADMIN\n"),
9878        );
9879        assert!(reports(
9880            &widened,
9881            &linux_plan(StartMode::Boot),
9882            "CapabilityBoundingSet",
9883            FindingKind::Excess
9884        ));
9885        let narrowed = boot.replace(&boot_set, "CapabilityBoundingSet=CAP_SETUID\n");
9886        assert!(reports(
9887            &narrowed,
9888            &linux_plan(StartMode::Boot),
9889            "CapabilityBoundingSet",
9890            FindingKind::Shortfall
9891        ));
9892    }
9893
9894    #[test]
9895    fn status_reports_the_registered_account_and_falls_back_to_the_record() {
9896        let recorded = ServiceAccount::for_start_mode(StartMode::Boot).to_string();
9897        for (registered, shown) in [(Some("ivan"), "ivan"), (None, recorded.as_str())] {
9898            let host = Host::new();
9899            let operations = host.operations();
9900            operations.install(&host.request(StartMode::Boot)).unwrap();
9901            host.controls.edit("runner-manager", |registration| {
9902                registration.account = registered.map(str::to_string);
9903            });
9904            let status = operations.status().unwrap().to_string();
9905            assert!(
9906                status.contains(&format!("account                   {shown}\n")),
9907                "{status}"
9908            );
9909        }
9910    }
9911
9912    #[cfg(target_os = "linux")]
9913    #[test]
9914    fn a_boot_install_refuses_a_unit_a_drop_in_runs_as_another_account() {
9915        let host = Host::new();
9916        let operations = host.operations();
9917        operations.install(&host.request(StartMode::Boot)).unwrap();
9918        host.controls.edit("runner-manager", |registration| {
9919            registration.account = Some("ivan".to_string());
9920        });
9921        let refused = operations
9922            .install(&host.request(StartMode::Boot))
9923            .expect_err("a unit running as ivan cannot use root's tree");
9924        let message = refused.to_string();
9925        assert!(message.contains("run as ivan"), "{message}");
9926        assert!(message.contains("Remove `User=`"), "{message}");
9927    }
9928
9929    #[test]
9930    fn manager_account_spellings_match_the_record() {
9931        assert_eq!(
9932            windows_service_account("LocalSystem"),
9933            ServiceAccount::LocalSystem.as_str()
9934        );
9935        assert_eq!(windows_service_account(".\\svc-runner"), ".\\svc-runner");
9936        assert_eq!(
9937            scheduled_task_account(Some("S-1-5-21-1-2-3-1001".into())),
9938            None
9939        );
9940        assert_eq!(
9941            scheduled_task_account(Some("HOST\\op".into())),
9942            Some("HOST\\op".to_string())
9943        );
9944    }
9945
9946    #[test]
9947    fn exec_start_prefixes_are_not_part_of_the_binary() {
9948        for (value, expected) in [
9949            ("/opt/rm daemon run", "/opt/rm daemon run"),
9950            ("-/opt/rm daemon run", "/opt/rm daemon run"),
9951            ("+!/opt/rm daemon run", "/opt/rm daemon run"),
9952            ("!!/opt/rm daemon run", "/opt/rm daemon run"),
9953            ("@/opt/rm runner-manager daemon run", "/opt/rm daemon run"),
9954            ("-@\"/opt/my rm\" rm daemon", "\"/opt/my rm\" daemon"),
9955        ] {
9956            assert_eq!(systemd_exec_command_line(value), expected, "{value}");
9957        }
9958        assert_eq!(
9959            executable_from_command_line(&systemd_exec_command_line("-/opt/rm daemon")),
9960            Some(PathBuf::from("/opt/rm"))
9961        );
9962    }
9963
9964    #[test]
9965    fn restart_sec_accepts_systemd_time_spans() {
9966        for (value, expected) in [
9967            ("15", Duration::from_secs(15)),
9968            ("15s", Duration::from_secs(15)),
9969            ("1min", Duration::from_secs(60)),
9970            ("1min 30s", Duration::from_secs(90)),
9971            ("1min30s", Duration::from_secs(90)),
9972            ("2h", Duration::from_secs(7_200)),
9973            ("500ms", Duration::from_millis(500)),
9974            ("1.5s", Duration::from_millis(1_500)),
9975        ] {
9976            assert_eq!(systemd_timespan(value), Some(expected), "{value}");
9977        }
9978        for value in ["", "infinity", "soon", "5 parsecs", "s"] {
9979            assert_eq!(systemd_timespan(value), None, "{value}");
9980        }
9981    }
9982
9983    #[cfg(unix)]
9984    #[test]
9985    fn the_system_tree_is_reported_as_roots_installation_under_var_lib() {
9986        let system = ServiceDirectories::of(&AppPaths::system());
9987        assert_eq!(system.root(), Path::new("/var/lib/runner-manager"));
9988        assert_eq!(
9989            data_root_owner(&system.root(), Some(Path::new("/home/op"))).as_deref(),
9990            Some("root")
9991        );
9992    }
9993
9994    #[test]
9995    fn a_boot_service_refuses_directories_it_could_not_open() {
9996        let var = Path::new("/var/lib/runner-manager/state");
9997        assert_eq!(boot_directory_refusal(var, Some(0)), None);
9998        let refused = boot_directory_refusal(var, Some(1000)).expect("another owner");
9999        assert!(refused.contains("uid 1000"), "{refused}");
10000        assert!(boot_directory_refusal(var, None).is_some());
10001        for hidden in ["/home/op/.config/runner-manager", "/root/.local/state/rm"] {
10002            let refused =
10003                boot_directory_refusal(Path::new(hidden), Some(0)).expect("a hidden tree");
10004            assert!(refused.contains("cannot see"), "{refused}");
10005        }
10006    }
10007
10008    #[cfg(unix)]
10009    #[test]
10010    fn a_moved_tree_keeps_files_modes_and_links_but_not_the_skipped_entry() {
10011        use std::os::unix::fs::PermissionsExt as _;
10012
10013        let from = tempfile::tempdir().unwrap();
10014        let to = tempfile::tempdir().unwrap();
10015        let target = to.path().join("state");
10016        std::fs::create_dir_all(from.path().join("journal/2026")).unwrap();
10017        std::fs::write(from.path().join("journal/2026/attempt"), b"entry").unwrap();
10018        std::fs::write(from.path().join("secret"), b"x").unwrap();
10019        std::fs::set_permissions(
10020            from.path().join("secret"),
10021            std::fs::Permissions::from_mode(0o600),
10022        )
10023        .unwrap();
10024        std::os::unix::fs::symlink("journal", from.path().join("latest")).unwrap();
10025        std::fs::create_dir(from.path().join("bin")).unwrap();
10026
10027        copy_tree(from.path(), &target, Some("bin")).unwrap();
10028
10029        assert_eq!(
10030            std::fs::read(target.join("journal/2026/attempt")).unwrap(),
10031            b"entry"
10032        );
10033        let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
10034        assert_eq!(mode(&target.join("secret")), 0o600);
10035        assert_eq!(mode(&target.join("journal")), 0o700);
10036        assert_eq!(
10037            std::fs::read_link(target.join("latest")).unwrap(),
10038            Path::new("journal")
10039        );
10040        assert!(!target.join("bin").exists());
10041
10042        // An occupied target is set aside whole, not merged into or deleted.
10043        std::fs::write(target.join("stale.sqlite3-wal"), b"stale").unwrap();
10044        set_aside(&target, "20261110T000000Z").unwrap();
10045        assert!(!target.exists());
10046        let aside = to.path().join("state.before-move-20261110T000000Z");
10047        assert_eq!(
10048            std::fs::read(aside.join("stale.sqlite3-wal")).unwrap(),
10049            b"stale"
10050        );
10051        copy_tree(from.path(), &target, Some("bin")).unwrap();
10052        assert!(!target.join("stale.sqlite3-wal").exists());
10053        set_aside(&to.path().join("absent"), "x").unwrap();
10054    }
10055
10056    #[test]
10057    fn systemd_drop_ins_replace_base_service_directives() {
10058        let base = systemd_unit(&linux_plan(StartMode::Boot));
10059        let combined = format!(
10060            "# /etc/systemd/system/runner-manager.service\n{base}\n\
10061             # /etc/systemd/system/runner-manager.service.d/account.conf\n\
10062             [Service]\nUser=ivan\nRestartSec=30\nExecStart=\n\
10063             ExecStart=/opt/runner-manager daemon run\n"
10064        );
10065        let directives = ini_directives(&combined, "Service");
10066        assert_eq!(directives["User"], "ivan");
10067        assert_eq!(directives["RestartSec"], "30");
10068        assert_eq!(directives["ExecStart"], "/opt/runner-manager daemon run");
10069        assert_eq!(directives["ProtectSystem"], "strict");
10070    }
10071
10072    #[test]
10073    fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
10074        let plan = linux_plan(StartMode::Login);
10075        let rendered = systemd_unit(&plan);
10076        let widened = edited(
10077            &rendered,
10078            "CapabilityBoundingSet=\n",
10079            "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
10080        );
10081        let review = review_least_privilege(
10082            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10083            &plan,
10084        );
10085        assert!(!review.is_least_privilege(), "{review}");
10086        assert!(
10087            review
10088                .excesses()
10089                .iter()
10090                .any(|finding| finding.subject == "CapabilityBoundingSet"),
10091            "{review}"
10092        );
10093    }
10094
10095    #[test]
10096    fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
10097        let plan = linux_plan(StartMode::Boot);
10098        let rendered = systemd_unit(&plan);
10099        let widened = edited(
10100            &rendered,
10101            "[Install]",
10102            "ListenStream=127.0.0.1:9000\n\n[Install]",
10103        );
10104        let review = review_least_privilege(
10105            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10106            &plan,
10107        );
10108        assert!(
10109            !review.is_least_privilege(),
10110            "07-security.md rule 2 forbids any inbound surface: {review}"
10111        );
10112    }
10113
10114    #[test]
10115    fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
10116        let plan = linux_plan(StartMode::Boot);
10117        let rendered = systemd_unit(&plan);
10118        let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
10119        let review = review_least_privilege(
10120            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
10121            &plan,
10122        );
10123        assert!(
10124            review.is_least_privilege(),
10125            "too little authority is not an excess: {review}"
10126        );
10127        assert!(
10128            review
10129                .findings()
10130                .iter()
10131                .any(|finding| finding.kind == FindingKind::Shortfall
10132                    && finding.detail.contains("runtime")),
10133            "{review}"
10134        );
10135    }
10136
10137    #[test]
10138    fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
10139        let plan = linux_plan(StartMode::Login);
10140        let rendered = launchd_plist(&plan);
10141        let widened = edited(
10142            &rendered,
10143            "<key>ProcessType</key>",
10144            "<key>UserName</key>\n  <string>root</string>\n  <key>ProcessType</key>",
10145        );
10146        let review = review_least_privilege(
10147            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10148            &plan,
10149        );
10150        assert!(!review.is_least_privilege(), "{review}");
10151        assert!(
10152            review
10153                .excesses()
10154                .iter()
10155                .any(|finding| finding.subject == "UserName"),
10156            "{review}"
10157        );
10158    }
10159
10160    #[test]
10161    fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
10162        let plan = linux_plan(StartMode::Boot);
10163        let rendered = launchd_plist(&plan);
10164        let widened = edited(
10165            &rendered,
10166            "<key>SessionCreate</key>\n  <false/>",
10167            "<key>SessionCreate</key>\n  <true/>",
10168        );
10169        let review = review_least_privilege(
10170            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10171            &plan,
10172        );
10173        assert!(!review.is_least_privilege(), "{review}");
10174        assert!(
10175            review
10176                .excesses()
10177                .iter()
10178                .any(|finding| finding.subject == "SessionCreate"),
10179            "{review}"
10180        );
10181    }
10182
10183    #[test]
10184    fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
10185        let plan = linux_plan(StartMode::Boot);
10186        let rendered = launchd_plist(&plan);
10187        let widened = edited(
10188            &rendered,
10189            "<key>ProcessType</key>",
10190            "<key>MachServices</key>\n  <dict/>\n  <key>ProcessType</key>",
10191        );
10192        let review = review_least_privilege(
10193            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10194            &plan,
10195        );
10196        assert!(!review.is_least_privilege(), "{review}");
10197    }
10198
10199    #[test]
10200    fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
10201        let plan = windows_plan(StartMode::Login);
10202        let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
10203        let widened = edited(
10204            &rendered,
10205            "<RunLevel>LeastPrivilege</RunLevel>",
10206            "<RunLevel>HighestAvailable</RunLevel>",
10207        );
10208        let review = review_least_privilege(
10209            &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
10210            &plan,
10211        );
10212        assert!(!review.is_least_privilege(), "{review}");
10213        assert!(
10214            review
10215                .excesses()
10216                .iter()
10217                .any(|finding| finding.subject == "RunLevel"),
10218            "{review}"
10219        );
10220    }
10221
10222    #[test]
10223    fn a_task_that_would_store_a_password_is_not_least_privilege() {
10224        let plan = windows_plan(StartMode::Login);
10225        let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
10226        let widened = edited(
10227            &rendered,
10228            "<LogonType>InteractiveToken</LogonType>",
10229            "<LogonType>Password</LogonType>",
10230        );
10231        let review = review_least_privilege(
10232            &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
10233            &plan,
10234        );
10235        assert!(!review.is_least_privilege(), "{review}");
10236    }
10237
10238    #[test]
10239    fn an_interactive_windows_service_is_not_least_privilege() {
10240        let plan = windows_plan(StartMode::Boot);
10241        let rendered = windows_service_descriptor(&plan);
10242        let widened = edited(
10243            &rendered,
10244            "ServiceType=OWN_PROCESS",
10245            "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
10246        );
10247        let review = review_least_privilege(
10248            &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
10249            &plan,
10250        );
10251        assert!(!review.is_least_privilege(), "{review}");
10252        assert!(
10253            review
10254                .excesses()
10255                .iter()
10256                .any(|finding| finding.subject == "ServiceType"),
10257            "{review}"
10258        );
10259    }
10260
10261    #[test]
10262    fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
10263        let plan = windows_plan(StartMode::Boot);
10264        let rendered = windows_service_descriptor(&plan);
10265        let changed = edited(
10266            &rendered,
10267            "Account=NT AUTHORITY\\SYSTEM",
10268            "Account=NT AUTHORITY\\LocalService",
10269        );
10270        let review = review_least_privilege(
10271            &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
10272            &plan,
10273        );
10274        // LocalService is *less* privileged, so this is not an excess. It is
10275        // still wrong, because `d2`'s DACL names SY, BA and OW and nothing else:
10276        // the daemon would start and find no credential. Reporting it as a
10277        // shortfall rather than as an excess is the difference between a check
10278        // that understands the requirement and one that counts adjectives.
10279        assert!(review.is_least_privilege(), "{review}");
10280        assert!(
10281            review.findings().iter().any(|finding| {
10282                finding.kind == FindingKind::Shortfall && finding.subject == "Account"
10283            }),
10284            "{review}"
10285        );
10286    }
10287
10288    // -----------------------------------------------------------------------
10289    // The recorded absolute path, and what became of it
10290    // -----------------------------------------------------------------------
10291
10292    #[test]
10293    fn a_recorded_path_that_is_still_there_is_current() {
10294        let host = Host::new();
10295        let state = inspect_binary(&host.binary, Some(&host.binary));
10296        assert!(!state.is_error(), "{state}");
10297        assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
10298    }
10299
10300    #[test]
10301    fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
10302        let host = Host::new();
10303        // An `npm i -g` binary lives under the active Node installation's
10304        // global prefix. Switching Node versions moves the prefix, and the
10305        // recorded path stops existing while the registration survives.
10306        let recorded = host.binary.clone();
10307        let healthy = inspect_binary(&recorded, Some(&recorded));
10308        assert!(
10309            !healthy.is_error(),
10310            "the discriminator: before the binary moves, this must be healthy"
10311        );
10312
10313        std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
10314
10315        let state = inspect_binary(&recorded, Some(&recorded));
10316        assert!(state.is_error(), "{state}");
10317        assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
10318        assert!(
10319            state.to_string().contains("npm"),
10320            "the message must name the cause an operator will not otherwise connect: {state}"
10321        );
10322    }
10323
10324    #[test]
10325    fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
10326        let root = tempfile::tempdir().expect("a temporary directory");
10327        let state = inspect_binary(root.path(), None);
10328        assert!(state.is_error(), "{state}");
10329        assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
10330    }
10331
10332    #[test]
10333    fn a_registration_naming_a_different_binary_is_a_divergence() {
10334        let host = Host::new();
10335        let other = host.binary.with_file_name("something-else");
10336        let state = inspect_binary(&host.binary, Some(&other));
10337        assert!(state.is_error(), "{state}");
10338        assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
10339    }
10340
10341    #[test]
10342    fn absence_is_reported_before_divergence() {
10343        let host = Host::new();
10344        let recorded = host.binary.clone();
10345        std::fs::remove_file(&recorded).expect("removable");
10346        let other = recorded.with_file_name("something-else");
10347        // Both faults are true at once. The operator needs to hear about the
10348        // missing file, not about a disagreement between two paths of which one
10349        // does not exist.
10350        assert!(matches!(
10351            inspect_binary(&recorded, Some(&other)),
10352            BinaryPath::Missing { .. }
10353        ));
10354    }
10355
10356    // -----------------------------------------------------------------------
10357    // The record
10358    // -----------------------------------------------------------------------
10359
10360    #[test]
10361    fn the_record_round_trips_through_toml() {
10362        let host = Host::new();
10363        let plan = InstallPlan::resolve(
10364            ServiceIdentity::product(),
10365            &host.request(StartMode::Boot),
10366            ServiceDirectories::of(&host.paths),
10367        )
10368        .expect("a resolvable plan");
10369        let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
10370        let record = InstallRecord::of(&plan, &definition, Utc::now());
10371        record.write(&host.paths).expect("a writable record");
10372        let read = InstallRecord::read(&host.paths)
10373            .expect("a readable record")
10374            .expect("a record is there");
10375        assert_eq!(read, record);
10376        assert_eq!(read.binary, host.binary);
10377        assert!(read.binary.is_absolute());
10378    }
10379
10380    #[test]
10381    fn a_unit_written_by_an_older_build_is_reported_and_a_current_one_is_not() {
10382        let host = Host::new();
10383        let identity = ServiceIdentity::product();
10384        let plan = InstallPlan::resolve(
10385            identity.clone(),
10386            &host.request(StartMode::Boot),
10387            ServiceDirectories::of(&host.paths),
10388        )
10389        .expect("a resolvable plan");
10390        let installed = systemd_unit(&plan);
10391        let definition =
10392            ServiceDefinition::from_text(DefinitionKind::SystemdUnit, installed.clone());
10393        let mut record = InstallRecord::of(&plan, &definition, Utc::now());
10394        let path = PathBuf::from("/etc/systemd/system/runner-manager.service");
10395
10396        assert_eq!(
10397            systemd_definition_drift(&identity, &record, path.clone(), &installed),
10398            None,
10399            "the record must rebuild exactly the unit `service install` wrote"
10400        );
10401
10402        record.installed_by_version = "0.4.7".into();
10403        let frozen = installed.replace(
10404            "[Service]\n",
10405            "[Service]\nLoadCredential=runner-manager.user-access-token:/var/lib/runner-manager/secrets/user-access-token\n",
10406        );
10407        let drift = systemd_definition_drift(&identity, &record, path, &frozen)
10408            .expect("an outdated unit is drift");
10409        assert_eq!(drift.installed_by_version, "0.4.7");
10410        assert_eq!(drift.rendered, installed);
10411
10412        let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
10413            .with_definition_drift(Some(drift));
10414        assert!(!status.is_healthy());
10415        assert!(
10416            status
10417                .problems()
10418                .iter()
10419                .any(|problem| problem.subject == "definition" && problem.detail.contains("0.4.7")),
10420            "{status}"
10421        );
10422    }
10423
10424    #[test]
10425    fn a_rejected_credential_keeps_its_first_moment_and_makes_the_service_unhealthy() {
10426        let host = Host::new();
10427        assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
10428        let first = Utc::now() - chrono::Duration::hours(9);
10429        record_github_credential_rejected(&host.paths, first).unwrap();
10430        record_github_credential_rejected(&host.paths, Utc::now()).unwrap();
10431        let since = github_credential_rejected_since(&host.paths).unwrap();
10432        assert_eq!(since, Some(first));
10433
10434        let status = ServiceStatus::compose(
10435            ServiceIdentity::product(),
10436            None,
10437            None,
10438            None,
10439            None,
10440            &host.paths,
10441        )
10442        .with_credential_rejection(since);
10443        assert!(!status.is_healthy());
10444        assert_eq!(status.credential_rejected_since(), Some(first));
10445        assert!(status.to_string().contains("auth login"), "{status}");
10446
10447        clear_github_credential_rejection(&host.paths).unwrap();
10448        clear_github_credential_rejection(&host.paths).unwrap();
10449        assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
10450    }
10451
10452    /// A guest that started nothing for 13 days while this said `healthy`.
10453    #[test]
10454    fn blocked_launches_make_the_service_unhealthy_with_their_remedy() {
10455        use crate::launch_health::{
10456            BlockCause, Viewpoint, clear_launches_blocked, launches_blocked,
10457            record_launches_blocked,
10458        };
10459
10460        let host = Host::new();
10461        let compose = || {
10462            ServiceStatus::compose(
10463                ServiceIdentity::product(),
10464                None,
10465                None,
10466                None,
10467                None,
10468                &host.paths,
10469            )
10470            .with_launches_blocked(launches_blocked(&host.paths, Utc::now()))
10471        };
10472        assert!(
10473            !compose()
10474                .problems()
10475                .iter()
10476                .any(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
10477        );
10478        let blocked = BlockCause::FenceHeld {
10479            directory: PathBuf::from("launch-fence"),
10480            owner: None,
10481        }
10482        .blocked_since(Utc::now(), Viewpoint::Guest);
10483        record_launches_blocked(&host.paths, &blocked, Utc::now()).unwrap();
10484
10485        let status = compose();
10486        assert!(!status.is_healthy());
10487        assert_eq!(status.launches_blocked(), Some(&blocked));
10488        let problem = status
10489            .problems()
10490            .iter()
10491            .find(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
10492            .expect("reported under its own subject");
10493        assert!(problem.detail.contains("systemctl restart"), "{status}");
10494
10495        clear_launches_blocked(&host.paths).unwrap();
10496        assert_eq!(compose().launches_blocked(), None);
10497    }
10498
10499    /// The daemon's own "I cannot read my credential", which used to exist
10500    /// only in its log while `service status` said `healthy`.
10501    #[test]
10502    fn an_unreadable_credential_keeps_its_first_moment_and_names_the_exact_command() {
10503        let host = Host::new();
10504        assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
10505        let first = Utc::now() - chrono::Duration::minutes(40);
10506        record_credential_unreadable(&host.paths, first).unwrap();
10507        record_credential_unreadable(&host.paths, Utc::now()).unwrap();
10508        let since = credential_unreadable_since(&host.paths).unwrap();
10509        assert_eq!(since, Some(first), "a restart loop must not move the start");
10510
10511        let status = ServiceStatus::compose(
10512            ServiceIdentity::product(),
10513            None,
10514            None,
10515            None,
10516            None,
10517            &host.paths,
10518        )
10519        .with_credential_unreadable(since);
10520        assert!(!status.is_healthy());
10521        let problem = status
10522            .problems()
10523            .iter()
10524            .find(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT)
10525            .expect("reported under its own subject");
10526        // No record, so the default start mode, whose exact command is named.
10527        assert!(
10528            problem
10529                .detail
10530                .contains(&sign_in_instruction(StartMode::default())),
10531            "{status}"
10532        );
10533
10534        clear_credential_unreadable(&host.paths).unwrap();
10535        clear_credential_unreadable(&host.paths).unwrap();
10536        assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
10537        let healthy = ServiceStatus::compose(
10538            ServiceIdentity::product(),
10539            None,
10540            None,
10541            None,
10542            None,
10543            &host.paths,
10544        )
10545        .with_credential_unreadable(None);
10546        assert!(
10547            !healthy
10548                .problems()
10549                .iter()
10550                .any(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT),
10551            "{healthy}"
10552        );
10553    }
10554
10555    #[test]
10556    fn a_sudo_command_names_a_binary_sudo_would_not_find() {
10557        assert_eq!(sudo_program(None), "runner-manager");
10558        assert_eq!(
10559            sudo_program(Some(Path::new("/usr/local/bin/runner-manager"))),
10560            "runner-manager"
10561        );
10562        assert_eq!(
10563            sudo_program(Some(Path::new(
10564                "/home/op/.nvm/versions/node/v26.13.0/lib/node_modules/@ivan-murzak/\
10565                 runner-manager-linux-x64/bin/runner-manager"
10566            ))),
10567            "/home/op/.nvm/versions/node/v26.13.0/lib/node_modules/@ivan-murzak/\
10568             runner-manager-linux-x64/bin/runner-manager"
10569        );
10570        assert_eq!(
10571            sudo_program(Some(Path::new("/home/o'p/my bin/runner-manager"))),
10572            "'/home/o'\\''p/my bin/runner-manager'"
10573        );
10574        assert_eq!(
10575            sudo_command("service status"),
10576            "sudo runner-manager service status"
10577        );
10578    }
10579
10580    #[test]
10581    fn the_sign_in_instruction_names_the_start_mode_it_writes() {
10582        assert!(sign_in_instruction(StartMode::Login).contains("auth login --start-at login`"));
10583        assert!(sign_in_instruction(StartMode::Boot).contains("auth login --start-at boot`"));
10584        assert_eq!(
10585            sign_in_instruction(StartMode::Boot).starts_with("`sudo "),
10586            cfg!(unix),
10587            "a machine-scoped store needs root on Unix"
10588        );
10589        assert_eq!(
10590            sign_in_instruction(StartMode::Login).contains("SSH"),
10591            cfg!(target_os = "macos"),
10592            "only the macOS login keychain is out of an SSH session's reach"
10593        );
10594    }
10595
10596    /// The record is readable by the account whose directory it is in.
10597    ///
10598    /// A boot-mode `service install` runs under `sudo`, and `NamedTempFile`
10599    /// creates at `0600`, so the record landed `root`-owned and unreadable by
10600    /// the operator — `service status` then failed on their own host with
10601    /// `Permission denied`. Ownership cannot be reproduced in an unprivileged
10602    /// test, but the mode that made ownership fatal can, and it is the half
10603    /// this code controls.
10604    #[cfg(unix)]
10605    #[test]
10606    fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
10607        use std::os::unix::fs::PermissionsExt as _;
10608
10609        let host = Host::new();
10610        let plan = InstallPlan::resolve(
10611            ServiceIdentity::product(),
10612            &host.request(StartMode::Boot),
10613            ServiceDirectories::of(&host.paths),
10614        )
10615        .expect("a resolvable plan");
10616        let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
10617        InstallRecord::of(&plan, &definition, Utc::now())
10618            .write(&host.paths)
10619            .expect("a writable record");
10620
10621        let mode = std::fs::metadata(InstallRecord::path(&host.paths))
10622            .expect("the record is there")
10623            .permissions()
10624            .mode()
10625            & 0o777;
10626        assert_eq!(
10627            mode, 0o644,
10628            "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
10629             service install` wrote, and `service status` fails on their own host. It holds no \
10630             credential and sits in a 0700 directory, so 0644 discloses nothing"
10631        );
10632    }
10633
10634    /// A record written before the service ran a copy of its own must still
10635    /// load. It reads as `None`, which is the truth about it: that registration
10636    /// names the package manager's file and cannot be upgraded under itself.
10637    #[test]
10638    fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
10639        let host = Host::new();
10640        let path = InstallRecord::path(&host.paths);
10641        std::fs::write(
10642            &path,
10643            format!(
10644                "schema_version = {RECORD_SCHEMA_VERSION}
10645service_name = \"runner-manager\"
10646                 manager = \"systemd\"
10647start_mode = \"boot\"
10648account = \"root\"
10649                 binary = \"/x\"
10650arguments = []
10651restart_delay_secs = 15
10652                 restart_reset_secs = 600
10653log_file = \"/x\"
10654                 installed_at = \"2026-01-01T00:00:00Z\"
10655installed_by_version = \"0.1.0\"
10656                 [directories]
10657config = \"/a\"
10658state = \"/b\"
10659runtime = \"/c\"
10660logs = \"/d\"
10661"
10662            ),
10663        )
10664        .expect("a writable record");
10665        let read = InstallRecord::read(&host.paths)
10666            .expect("a record missing an optional field is still readable")
10667            .expect("a record is there");
10668        assert_eq!(
10669            read.source_binary, None,
10670            "the legacy layout has no source, and must not invent one"
10671        );
10672    }
10673
10674    /// The field that makes an upgrade possible survives the write.
10675    #[test]
10676    fn a_registration_remembers_the_file_it_was_copied_from() {
10677        let host = Host::new();
10678        let source = host.binary.with_file_name("npm-installed-runner-manager");
10679        std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
10680        let plan = InstallPlan::resolve(
10681            ServiceIdentity::product(),
10682            &host.request(StartMode::Boot).copied_from(&source),
10683            ServiceDirectories::of(&host.paths),
10684        )
10685        .expect("a resolvable plan");
10686        let definition = ServiceDefinition::from_text(
10687            DefinitionKind::SystemdUnit,
10688            "[Service]
10689",
10690        );
10691        let record = InstallRecord::of(&plan, &definition, Utc::now());
10692        record.write(&host.paths).expect("a writable record");
10693
10694        let read = InstallRecord::read(&host.paths)
10695            .expect("a readable record")
10696            .expect("a record is there");
10697        assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
10698        assert_ne!(
10699            read.source_binary.as_deref(),
10700            Some(read.binary.as_path()),
10701            "the whole point is that the two are different files: one the service holds open,              one the package manager is free to replace"
10702        );
10703    }
10704
10705    #[test]
10706    fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
10707        let host = Host::new();
10708        let path = InstallRecord::path(&host.paths);
10709        std::fs::write(
10710            &path,
10711            format!(
10712                "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
10713                 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
10714                 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
10715                 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
10716                 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
10717                RECORD_SCHEMA_VERSION + 1
10718            ),
10719        )
10720        .expect("a writable record");
10721        let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
10722        assert!(
10723            matches!(error, ServiceError::RecordUnreadable { .. }),
10724            "{error}"
10725        );
10726        assert!(
10727            error.to_string().contains("service uninstall"),
10728            "the message must say how to recover: {error}"
10729        );
10730    }
10731
10732    #[test]
10733    fn no_record_is_not_an_error() {
10734        let host = Host::new();
10735        assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
10736        assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
10737    }
10738
10739    // -----------------------------------------------------------------------
10740    // The last successful GitHub contact
10741    // -----------------------------------------------------------------------
10742
10743    #[test]
10744    fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
10745        let host = Host::new();
10746        assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
10747    }
10748
10749    #[test]
10750    fn the_heartbeat_round_trips_to_the_second() {
10751        let host = Host::new();
10752        let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
10753            .expect("a valid timestamp")
10754            .with_timezone(&Utc);
10755        record_github_contact(&host.paths, at).expect("a writable heartbeat");
10756        assert_eq!(
10757            last_github_contact(&host.paths).expect("readable"),
10758            Some(at)
10759        );
10760    }
10761
10762    #[test]
10763    fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
10764        let host = Host::new();
10765        std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
10766        let error = last_github_contact(&host.paths)
10767            .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
10768        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
10769    }
10770
10771    /// The surface the three-hour outage did not have.
10772    ///
10773    /// A root that refuses a launch does so before any attempt row exists, and
10774    /// the daemon's log scrubs the paths out of the sentence, so this file is
10775    /// the only place the directory and the remediation can reach the operator.
10776    #[test]
10777    fn a_runner_root_refusal_round_trips_for_service_status() {
10778        let host = Host::new();
10779        let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
10780
10781        assert!(
10782            runner_root_refusals(&host.paths)
10783                .expect("readable")
10784                .is_empty(),
10785            "no record means every policy is placing runners"
10786        );
10787
10788        record_runner_root_refusal(
10789            &host.paths,
10790            "policy-a",
10791            at,
10792            "denied_by_privacy_policy",
10793            "/Volumes/NVME/runners",
10794            "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
10795        )
10796        .expect("a writable record");
10797
10798        let refusals = runner_root_refusals(&host.paths).expect("readable");
10799        assert_eq!(refusals.len(), 1);
10800        assert_eq!(refusals[0].policy, "policy-a");
10801        assert_eq!(refusals[0].at, at);
10802        assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
10803        assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
10804        assert!(
10805            refusals[0].detail.contains("/Volumes/NVME/runners")
10806                && refusals[0].detail.contains("Full Disk Access"),
10807            "the path and the remediation are the whole point of this file: {refusals:?}"
10808        );
10809    }
10810
10811    /// The regression that would put the outage straight back.
10812    ///
10813    /// A host runs several policies and they do not share a fate. When the
10814    /// record was one host-wide slot, the policy that placed a runner deleted
10815    /// the record of the policy that could not — on the same reconcile pass —
10816    /// and `service status` reported a healthy host that had started zero
10817    /// runners for an entire target.
10818    #[test]
10819    fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
10820        let host = Host::new();
10821        let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
10822        record_runner_root_refusal(
10823            &host.paths,
10824            "broken",
10825            at,
10826            "denied_by_privacy_policy",
10827            "/Volumes/NVME/runners",
10828            "detail",
10829        )
10830        .expect("a writable record");
10831        record_runner_root_refusal(
10832            &host.paths,
10833            "also-broken",
10834            at,
10835            "not_writable",
10836            "/srv/other",
10837            "detail",
10838        )
10839        .expect("a writable record");
10840
10841        // The policy with its own working root succeeds and clears only itself.
10842        clear_runner_root_refusal(&host.paths, "healthy")
10843            .expect("clearing an absent policy is fine");
10844        clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
10845
10846        let refusals = runner_root_refusals(&host.paths).expect("readable");
10847        assert_eq!(
10848            refusals
10849                .iter()
10850                .map(|r| r.policy.as_str())
10851                .collect::<Vec<_>>(),
10852            vec!["broken"],
10853            "the policy that is still refused must keep its record"
10854        );
10855    }
10856
10857    /// Clearing is what keeps a fixed host from reporting a stale fault, and the
10858    /// file goes when the last entry does.
10859    #[test]
10860    fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
10861        let host = Host::new();
10862        record_runner_root_refusal(
10863            &host.paths,
10864            "p",
10865            Utc::now(),
10866            "not_writable",
10867            "/srv/x",
10868            "detail",
10869        )
10870        .expect("a writable record");
10871
10872        clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
10873        assert!(
10874            !root_refusal_path(&host.paths).exists(),
10875            "a host with nothing refused leaves nothing behind"
10876        );
10877        clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
10878    }
10879
10880    /// Same rule as the heartbeat: absence means "every policy is placing
10881    /// runners", so a record that cannot be parsed must not be reported as one.
10882    #[test]
10883    fn a_malformed_refusal_is_an_error_and_not_silently_none() {
10884        let host = Host::new();
10885        std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
10886        let error = runner_root_refusals(&host.paths)
10887            .expect_err("an unparseable record is not the same as no record");
10888        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
10889    }
10890
10891    /// The half of this feature that turns the file into a diagnosis, and the
10892    /// half that had no test: `service status` must actually read it.
10893    ///
10894    /// A **note** and not a problem, deliberately. `service status` runs as
10895    /// whoever typed it, must stay readable on a host with nothing installed,
10896    /// and drives an exit code -- and nothing an operator can type clears this
10897    /// record, so a problem would fail that exit code on a host whose root was
10898    /// fixed but which has had no queued job since, with a printed remedy
10899    /// (`service uninstall && service install`) that does not touch `state/`.
10900    #[test]
10901    fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
10902        let host = Host::new();
10903        record_runner_root_refusal(
10904            &host.paths,
10905            "policy-a",
10906            DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
10907            "denied_by_privacy_policy",
10908            "/Volumes/NVME/runners",
10909            "Grant Full Disk Access to the program that runs the service",
10910        )
10911        .expect("a writable record");
10912
10913        let status = host.operations().status().expect("a readable status");
10914        let notes = status.notes().join("\n");
10915
10916        assert!(
10917            notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
10918            "the directory and the remediation the log had to scrub must appear here: {notes}"
10919        );
10920        assert!(
10921            notes.contains("policy-a"),
10922            "the operator has to know which target placed no runner: {notes}"
10923        );
10924        assert!(
10925            !status
10926                .problems()
10927                .iter()
10928                .any(|problem| problem.subject == "runner root"),
10929            "a record nothing an operator types can clear must not drive the exit code"
10930        );
10931    }
10932
10933    // -----------------------------------------------------------------------
10934    // -----------------------------------------------------------------------
10935    // Install
10936    // -----------------------------------------------------------------------
10937
10938    #[test]
10939    fn install_records_the_absolute_binary_path_and_the_four_directories() {
10940        let host = Host::new();
10941        let installed = host
10942            .operations()
10943            .install(&host.request(StartMode::Boot))
10944            .expect("an install against the recording controls");
10945
10946        assert_eq!(installed.record.binary, host.binary);
10947        assert!(installed.record.binary.is_absolute());
10948        assert_eq!(installed.record.start_mode, StartMode::Boot);
10949        assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
10950        assert_eq!(
10951            installed.record.directories,
10952            ServiceDirectories::of(&host.paths)
10953        );
10954        assert_eq!(
10955            installed.record.log_file,
10956            host.paths.logs_dir().join(LOG_FILE_STEM)
10957        );
10958        assert_eq!(installed.record.restart_delay_secs, 15);
10959
10960        let registrations = host.controls.registrations();
10961        assert_eq!(registrations.len(), 1);
10962        assert_eq!(registrations[0].0, StartMode::Boot);
10963        assert_eq!(registrations[0].1, "runner-manager");
10964    }
10965
10966    #[test]
10967    fn install_is_refused_while_the_single_instance_lock_is_held() {
10968        let host = Host::new();
10969        // The discriminator: with the lock free, the same call succeeds.
10970        {
10971            let operations = host.operations();
10972            operations
10973                .install(&host.request(StartMode::Boot))
10974                .expect("an install with the lock free");
10975            operations.uninstall().expect("a clean slate");
10976        }
10977
10978        let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
10979            .expect("this process takes the lock first");
10980
10981        let error = host
10982            .operations()
10983            .install(&host.request(StartMode::Boot))
10984            .expect_err("a second agent must not be registered while one is running");
10985        assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
10986        assert!(
10987            error.to_string().contains("already running"),
10988            "the message must be actionable: {error}"
10989        );
10990        assert!(
10991            host.controls.registrations().is_empty(),
10992            "a refused install must register nothing"
10993        );
10994        assert_eq!(
10995            InstallRecord::read(&host.paths).expect("readable"),
10996            None,
10997            "a refused install must write no record"
10998        );
10999    }
11000
11001    /// Installing over the same start mode replaces the registration.
11002    ///
11003    /// It used to be refused, and the refusal is what broke a real host: the
11004    /// caller has already swapped the binary the service runs by the time this
11005    /// is reached — that is the documented way to upgrade by hand — so refusing
11006    /// left a daemon running an unregistered binary and, on macOS, one the
11007    /// stored credential's keychain grant did not name.
11008    #[test]
11009    fn installing_over_the_same_start_mode_replaces_the_registration() {
11010        let host = Host::new();
11011        let operations = host.operations();
11012        operations
11013            .install(&host.request(StartMode::Boot))
11014            .expect("the first install");
11015
11016        let again = operations
11017            .install(&host.request(StartMode::Boot))
11018            .expect("an install over the same mode replaces rather than refusing");
11019
11020        assert!(
11021            again.replaced_existing,
11022            "the operator is told this replaced something rather than made it"
11023        );
11024        assert_eq!(
11025            host.controls.registrations().len(),
11026            1,
11027            "replacing must not leave two registrations behind"
11028        );
11029        assert_eq!(
11030            InstallRecord::read(&host.paths)
11031                .expect("readable")
11032                .expect("a record")
11033                .start_mode,
11034            StartMode::Boot
11035        );
11036    }
11037
11038    /// The *other* mode is still refused, because changing it moves the
11039    /// registration between two service managers and changes the account and
11040    /// the secret store with it. `set_start_mode` is what does that.
11041    #[test]
11042    fn installing_over_the_other_start_mode_is_refused() {
11043        let host = Host::new();
11044        let operations = host.operations();
11045        operations
11046            .install(&host.request(StartMode::Boot))
11047            .expect("the first install");
11048
11049        let error = operations
11050            .install(&host.request(StartMode::Login))
11051            .expect_err("a mode change is not an install");
11052        assert!(
11053            matches!(
11054                error,
11055                ServiceError::AlreadyInstalled {
11056                    existing: StartMode::Boot,
11057                    requested: StartMode::Login,
11058                    ..
11059                }
11060            ),
11061            "{error}"
11062        );
11063        assert!(
11064            !error
11065                .to_string()
11066                .contains("switch the start mode in place,"),
11067            "the old remedy named a capability no command offers; the terminal UI is where \
11068             the start mode moves: {error}"
11069        );
11070        assert_eq!(host.controls.registrations().len(), 1);
11071    }
11072
11073    #[test]
11074    fn install_rolls_back_the_registration_when_record_persistence_fails() {
11075        let host = Host::new();
11076        let record_path = InstallRecord::path(&host.paths);
11077        std::fs::create_dir(&record_path).expect("a directory blocks the record file");
11078
11079        let error = host
11080            .operations()
11081            .install(&host.request(StartMode::Boot))
11082            .expect_err("record persistence must fail");
11083
11084        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11085        assert!(
11086            host.controls.registrations().is_empty(),
11087            "a failed install must not leave a live unrecorded registration"
11088        );
11089        assert!(
11090            host.controls
11091                .calls()
11092                .iter()
11093                .any(|call| call == "uninstall runner-manager (boot)"),
11094            "the registration must be explicitly rolled back: {:?}",
11095            host.controls.calls()
11096        );
11097        assert!(
11098            !host.runner_root.as_path().exists(),
11099            "the rollback must take the runner root this install created with it; a directory \
11100             prepared for a registration that does not exist is litter, and on Windows it is \
11101             litter with a security descriptor"
11102        );
11103    }
11104
11105    // -----------------------------------------------------------------------
11106    // The runner root (b2)
11107    // -----------------------------------------------------------------------
11108
11109    #[test]
11110    fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
11111        use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
11112
11113        // The mapping `prepare_runner_root` reads, asserted against the same
11114        // function the registration's own principal comes from — which is the
11115        // point of deriving it there rather than restating it.
11116        assert_eq!(
11117            ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
11118            ServiceAccount::LocalSystem
11119        );
11120        assert_eq!(
11121            ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
11122            ServiceAccount::InvokingUser
11123        );
11124
11125        let boot = default_root_sddl(&RootAdmission::LocalSystem);
11126        assert!(!grants_broad_write(&boot), "{boot}");
11127        assert!(
11128            !boot.contains("S-1-5-21"),
11129            "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
11130        );
11131
11132        // A login task runs under a *filtered* token — `RunLevel` is
11133        // `LeastPrivilege`, see `windows_scheduled_task_xml` — in which
11134        // Administrators is deny-only. Without an ace of its own the account
11135        // the task runs as would be admitted by nothing at all.
11136        let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
11137        assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
11138        assert!(!grants_broad_write(&login), "{login}");
11139    }
11140
11141    #[test]
11142    fn an_install_reports_the_runner_root_it_prepared() {
11143        let host = Host::new();
11144        let installed = host
11145            .operations()
11146            .install(&host.request(StartMode::Boot))
11147            .expect("an install");
11148        let rendered = installed.runner_root.to_string();
11149        assert!(
11150            !rendered.contains("S-1-5-21"),
11151            "the report must add no identity to the output: {rendered}"
11152        );
11153        if cfg!(windows) {
11154            assert_eq!(
11155                installed.runner_root.path(),
11156                Some(host.runner_root.as_path())
11157            );
11158            assert!(
11159                host.runner_root.as_path().is_dir(),
11160                "the directory jobs would run in has to exist once the service is registered"
11161            );
11162        } else {
11163            assert_eq!(
11164                installed.runner_root,
11165                crate::runner_root_access::RootAccessSummary::NotApplicable,
11166                "macOS and Linux keep the runtime directory they have always used"
11167            );
11168        }
11169    }
11170
11171    #[test]
11172    fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
11173        let host = Host::new();
11174        let operations = host.operations();
11175        operations
11176            .install(&host.request(StartMode::Boot))
11177            .expect("an install at boot");
11178
11179        let change = operations
11180            .set_start_mode(StartMode::Login)
11181            .expect("a switch to login");
11182
11183        assert!(change.changed);
11184        if cfg!(windows) {
11185            assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
11186            assert!(
11187                host.runner_root.as_path().is_dir(),
11188                "the switch must not remove the directory it reconciled"
11189            );
11190        }
11191        // The operator is told, because the account that may write there has
11192        // moved with the mode.
11193        assert!(
11194            change.to_string().contains("runner root"),
11195            "{}",
11196            change.to_string()
11197        );
11198    }
11199
11200    #[test]
11201    fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
11202        let host = Host::new();
11203        let operations = host.operations();
11204        operations
11205            .install(&host.request(StartMode::Boot))
11206            .expect("an install at boot");
11207
11208        let change = operations
11209            .set_start_mode(StartMode::Boot)
11210            .expect("a switch to the mode already in force");
11211
11212        assert!(!change.changed);
11213        assert_eq!(
11214            change.runner_root,
11215            crate::runner_root_access::RootAccessSummary::NotApplicable,
11216            "nothing moves, so nothing about the root's access control has to; reconciling here \
11217             would turn a no-op command into one that can fail on a permission it does not need"
11218        );
11219    }
11220
11221    #[cfg(windows)]
11222    #[test]
11223    fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
11224        let host = Host::new();
11225        host.controls
11226            .fail_next_install(StartMode::Boot, "injected registration failure");
11227
11228        let error = host
11229            .operations()
11230            .install(&host.request(StartMode::Boot))
11231            .expect_err("the manager refuses the registration");
11232
11233        assert!(matches!(error, ServiceError::Control { .. }), "{error}");
11234        assert!(
11235            !host.runner_root.as_path().exists(),
11236            "the directory was created for a registration that does not exist"
11237        );
11238    }
11239
11240    #[cfg(windows)]
11241    #[test]
11242    fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
11243        let host = Host::new();
11244        // What a directory created below `C:\` with inheritance left on looks
11245        // like, built deliberately because a per-account `%TEMP%` never
11246        // produces one by accident.
11247        crate::runner_root_access::create_with_descriptor_for_tests(
11248            host.runner_root.as_path(),
11249            "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
11250        )
11251        .expect("a deliberately open runner root");
11252        let before = crate::runner_root_access::report(host.runner_root.as_path());
11253
11254        let error = host
11255            .operations()
11256            .install(&host.request(StartMode::Boot))
11257            .expect_err("an open runner root is refused");
11258
11259        assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
11260        assert!(
11261            error.to_string().contains("nothing was registered"),
11262            "{error}"
11263        );
11264        assert!(
11265            host.controls.registrations().is_empty(),
11266            "the refusal has to come before the platform is asked to register anything: {:?}",
11267            host.controls.calls()
11268        );
11269        assert_eq!(
11270            crate::runner_root_access::report(host.runner_root.as_path()),
11271            before,
11272            "an open directory is refused rather than tightened: its contents cannot be trusted, \
11273             so adopting it would be worse than declining it"
11274        );
11275    }
11276
11277    #[cfg(windows)]
11278    #[test]
11279    fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
11280        let host = Host::new();
11281        let operations = host.operations();
11282        operations
11283            .install(&host.request(StartMode::Boot))
11284            .expect("an install");
11285        assert!(host.runner_root.as_path().is_dir());
11286
11287        operations.uninstall().expect("an uninstall");
11288
11289        assert!(
11290            host.runner_root.as_path().is_dir(),
11291            "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
11292             runner root may hold an operator's retained workspaces."
11293        );
11294    }
11295
11296    #[test]
11297    fn install_reviews_what_it_registered() {
11298        let host = Host::new();
11299        let installed = host
11300            .operations()
11301            .install(&host.request(StartMode::Boot))
11302            .expect("an install");
11303        assert!(
11304            installed.review.is_least_privilege(),
11305            "{}",
11306            installed.review
11307        );
11308        assert!(
11309            !installed.review.controls().is_empty(),
11310            "a review that confirms nothing proves nothing: {}",
11311            installed.review
11312        );
11313        assert_eq!(
11314            installed.review.kind(),
11315            host_definition_kind(StartMode::Boot),
11316            "the review must be of the definition this host's manager was given"
11317        );
11318        assert!(
11319            !installed.review.account().justification().is_empty(),
11320            "a privileged account with no stated reason is an unreviewed one"
11321        );
11322    }
11323
11324    // -----------------------------------------------------------------------
11325    // Uninstall deletes nothing else
11326    // -----------------------------------------------------------------------
11327
11328    #[test]
11329    fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
11330        let host = Host::new();
11331        let operations = host.operations();
11332        operations
11333            .install(&host.request(StartMode::Boot))
11334            .expect("an install");
11335
11336        // The files `05-infrastructure.md` says must survive. Written *after*
11337        // the install so that the snapshot below is of a host in the state an
11338        // operator's would be in.
11339        let config = host.paths.config_dir();
11340        std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
11341        std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
11342        std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
11343        std::fs::write(
11344            host.paths
11345                .state_dir()
11346                .join("packages/2.330.0/runner.tar.gz"),
11347            b"cached package",
11348        )
11349        .expect("writable");
11350        std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
11351        std::fs::write(
11352            host.paths.state_dir().join("secrets/user-access-token"),
11353            b"a stand-in for the stored credential",
11354        )
11355        .expect("writable");
11356        std::fs::write(
11357            host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
11358            b"diagnostics",
11359        )
11360        .expect("writable");
11361
11362        let roots: Vec<PathBuf> = host
11363            .paths
11364            .all()
11365            .iter()
11366            .map(|(_, path)| (*path).to_path_buf())
11367            .collect();
11368        let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
11369        let before = snapshot(&roots);
11370
11371        // Non-vacuity: a comparison of two empty maps would pass whatever
11372        // `uninstall` did.
11373        assert!(
11374            before.len() >= 6,
11375            "the fixture must actually contain the files this test is about, got {before:#?}"
11376        );
11377        let record_path = InstallRecord::path(&host.paths);
11378        assert!(
11379            before.contains_key(&record_path),
11380            "the install record must be present before uninstall"
11381        );
11382
11383        let uninstalled = operations.uninstall().expect("an uninstall");
11384        assert!(uninstalled.removed_registration);
11385        assert!(uninstalled.removed_record);
11386
11387        let after = snapshot(&roots);
11388
11389        // Exactly one thing changed, and it is the registration's own record.
11390        let mut expected = before.clone();
11391        expected.remove(&record_path);
11392        assert_eq!(
11393            after, expected,
11394            "uninstall must remove its own record and nothing else"
11395        );
11396        assert!(
11397            !record_path.exists(),
11398            "the record itself must go, or `uninstall` did nothing at all"
11399        );
11400        assert!(
11401            uninstalled
11402                .preserved
11403                .iter()
11404                .all(|path| roots.contains(&path.as_path())),
11405            "the preserved list must name the four directories: {uninstalled}"
11406        );
11407    }
11408
11409    #[test]
11410    fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
11411        let host = Host::new();
11412        let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
11413        assert!(!uninstalled.removed_registration);
11414        assert!(!uninstalled.removed_record);
11415    }
11416
11417    #[test]
11418    fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
11419        let host = Host::new();
11420        let operations = host.operations();
11421        operations
11422            .install(&host.request(StartMode::Boot))
11423            .expect("an install");
11424        std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
11425
11426        let uninstalled = operations.uninstall().expect("an uninstall");
11427        assert!(
11428            uninstalled.removed_registration,
11429            "a lost record must not strand a registration"
11430        );
11431        assert!(host.controls.registrations().is_empty());
11432    }
11433
11434    // -----------------------------------------------------------------------
11435    // Switching start mode
11436    // -----------------------------------------------------------------------
11437
11438    #[test]
11439    fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
11440        let host = Host::new();
11441        let operations = host.operations();
11442        operations
11443            .install(&host.request(StartMode::Boot))
11444            .expect("an install at boot");
11445
11446        // The discriminator. If the switch re-resolved the binary the way
11447        // `install` does, it would either fail here or silently record the test
11448        // binary instead. Removing the file makes the difference visible.
11449        std::fs::remove_file(&host.binary).expect("the installed binary goes away");
11450
11451        let change = operations
11452            .set_start_mode(StartMode::Login)
11453            .expect("a switch that does not reinstall the product");
11454        assert!(change.changed);
11455        assert_eq!(change.from, StartMode::Boot);
11456        assert_eq!(change.to, StartMode::Login);
11457        assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
11458
11459        let record = InstallRecord::read(&host.paths)
11460            .expect("readable")
11461            .expect("a record");
11462        assert_eq!(record.start_mode, StartMode::Login);
11463        assert_eq!(
11464            record.binary, host.binary,
11465            "the recorded path must survive the switch untouched"
11466        );
11467
11468        let registrations = host.controls.registrations();
11469        assert_eq!(registrations.len(), 1, "{registrations:?}");
11470        assert_eq!(registrations[0].0, StartMode::Login);
11471        assert!(
11472            registrations[0]
11473                .2
11474                .command_line
11475                .contains(&host.binary.to_string_lossy().into_owned()),
11476            "{:?}",
11477            registrations[0].2
11478        );
11479    }
11480
11481    #[test]
11482    fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
11483        let host = Host::new();
11484        let operations = host.operations();
11485        operations
11486            .install(&host.request(StartMode::Boot))
11487            .expect("an install at boot");
11488        let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
11489        host.controls
11490            .fail_next_install(StartMode::Login, "injected target failure");
11491
11492        let error = operations
11493            .set_start_mode(StartMode::Login)
11494            .expect_err("the target manager refuses the install");
11495
11496        assert!(matches!(error, ServiceError::Control { .. }), "{error}");
11497        assert_eq!(
11498            std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
11499            record_before
11500        );
11501        let registrations = host.controls.registrations();
11502        assert_eq!(registrations.len(), 1, "{registrations:?}");
11503        assert_eq!(registrations[0].0, StartMode::Boot);
11504    }
11505
11506    #[test]
11507    fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
11508        let host = Host::new();
11509        let operations = host.operations();
11510        operations
11511            .install(&host.request(StartMode::Boot))
11512            .expect("an install at boot");
11513        let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
11514        let config = host.paths.config_dir().to_path_buf();
11515        let hidden = config.with_file_name("config-hidden-by-fault");
11516        host.controls.hide_directory_after_install(
11517            StartMode::Login,
11518            config.clone(),
11519            hidden.clone(),
11520        );
11521
11522        let error = operations
11523            .set_start_mode(StartMode::Login)
11524            .expect_err("the injected filesystem fault prevents persistence");
11525
11526        std::fs::remove_file(&config).expect("remove the injected blocker");
11527        std::fs::rename(&hidden, &config).expect("restore the record directory");
11528        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11529        assert_eq!(
11530            std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
11531            record_before
11532        );
11533        let registrations = host.controls.registrations();
11534        assert_eq!(registrations.len(), 1, "{registrations:?}");
11535        assert_eq!(registrations[0].0, StartMode::Boot);
11536        assert!(
11537            host.controls
11538                .calls()
11539                .iter()
11540                .any(|call| call == "uninstall runner-manager (login)"),
11541            "the target must be rolled back: {:?}",
11542            host.controls.calls()
11543        );
11544    }
11545
11546    #[test]
11547    fn switching_to_the_mode_already_in_force_registers_nothing_again() {
11548        let host = Host::new();
11549        let operations = host.operations();
11550        operations
11551            .install(&host.request(StartMode::Boot))
11552            .expect("an install");
11553        let before = host.controls.calls().len();
11554
11555        let change = operations
11556            .set_start_mode(StartMode::Boot)
11557            .expect("a no-op switch");
11558        assert!(!change.changed);
11559        assert_eq!(
11560            host.controls.calls().len(),
11561            before,
11562            "a no-op switch must not touch the service manager"
11563        );
11564    }
11565
11566    #[test]
11567    fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
11568        let host = Host::new();
11569        let error = host
11570            .operations()
11571            .set_start_mode(StartMode::Login)
11572            .expect_err("there is nothing to switch");
11573        assert!(
11574            matches!(error, ServiceError::NotInstalled { .. }),
11575            "{error}"
11576        );
11577    }
11578
11579    // -----------------------------------------------------------------------
11580    // Status
11581    // -----------------------------------------------------------------------
11582
11583    #[test]
11584    fn status_reports_the_four_facts_journey_five_asks_for() {
11585        let host = Host::new();
11586        let operations = host.operations();
11587        operations
11588            .install(&host.request(StartMode::Boot))
11589            .expect("an install");
11590        let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
11591            .expect("a valid timestamp")
11592            .with_timezone(&Utc);
11593        record_github_contact(&host.paths, at).expect("a heartbeat");
11594
11595        let status = operations.status().expect("a status");
11596        assert_eq!(status.start_mode(), Some(StartMode::Boot));
11597        assert_eq!(
11598            status.binary().map(BinaryPath::recorded),
11599            Some(host.binary.as_path())
11600        );
11601        assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
11602        assert_eq!(status.last_github_contact(), Some(at));
11603        assert!(status.is_installed());
11604        assert!(status.is_healthy(), "{status}");
11605
11606        let printed = status.to_string();
11607        for fragment in [
11608            "start mode",
11609            "diagnostic log",
11610            "last GitHub contact",
11611            "binary",
11612        ] {
11613            assert!(printed.contains(fragment), "{printed}");
11614        }
11615    }
11616
11617    /// A record this account may not read is reported, and `status` keeps
11618    /// going.
11619    ///
11620    /// It used to end the command with `Permission denied`, which is exactly
11621    /// what an operator met on a boot-mode host: `sudo service install` wrote
11622    /// the record `0600` and `root`-owned, and `service status` — a command
11623    /// that asks no privilege of anybody — then printed nothing at all about a
11624    /// registration the service manager could describe perfectly well.
11625    ///
11626    /// Ownership cannot be reproduced without privileges; the mode that made it
11627    /// fatal can.
11628    #[cfg(unix)]
11629    #[test]
11630    fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
11631        use std::os::unix::fs::PermissionsExt as _;
11632
11633        // `root` is not subject to the mode bits and would read the record
11634        // regardless, leaving the assertions below testing nothing.
11635        // SAFETY: `geteuid` takes no argument and cannot fail.
11636        if unsafe { libc::geteuid() } == 0 {
11637            return;
11638        }
11639
11640        let host = Host::new();
11641        let operations = host.operations();
11642        operations
11643            .install(&host.request(StartMode::Boot))
11644            .expect("an install");
11645        assert!(
11646            operations.status().expect("a status").is_healthy(),
11647            "the discriminator: healthy before the record is made unreadable"
11648        );
11649
11650        let record = InstallRecord::path(&host.paths);
11651        std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
11652            .expect("the mode is applied");
11653
11654        let status = operations
11655            .status()
11656            .expect("a record this account may not read is reported, not thrown");
11657        assert!(status.is_installed(), "{status}");
11658        assert!(!status.is_healthy(), "{status}");
11659
11660        let printed = status.to_string();
11661        assert!(
11662            printed.contains("this account may not read it"),
11663            "the operator is told which of the two states this is: {printed}"
11664        );
11665        assert!(
11666            !printed.contains("there is no install record"),
11667            "a record that is there and unreadable is not a record that is missing, and the \
11668             missing one's remedy starts with `service uninstall`: {printed}"
11669        );
11670
11671        // Left readable so the fixture's own cleanup is not the thing that
11672        // fails on a host where TempDir removal walks the tree.
11673        std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
11674            .expect("the mode is restored");
11675    }
11676
11677    #[test]
11678    fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
11679        let host = Host::new();
11680        let operations = host.operations();
11681        operations
11682            .install(&host.request(StartMode::Boot))
11683            .expect("an install");
11684
11685        // The discriminator: healthy first, so a `is_healthy` that always
11686        // returned false could not pass this test.
11687        assert!(
11688            operations.status().expect("a status").is_healthy(),
11689            "the freshly installed host must be healthy"
11690        );
11691
11692        std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
11693
11694        let status = operations.status().expect("a status");
11695        assert!(!status.is_healthy(), "{status}");
11696        assert!(
11697            status
11698                .problems()
11699                .iter()
11700                .any(|problem| problem.subject == "binary"),
11701            "{status}"
11702        );
11703        assert!(status.to_string().contains("STALE"), "{status}");
11704    }
11705
11706    #[test]
11707    fn status_reports_a_registration_that_would_not_start_at_boot() {
11708        let host = Host::new();
11709        let operations = host.operations();
11710        operations
11711            .install(&host.request(StartMode::Boot))
11712            .expect("an install");
11713        assert!(operations.status().expect("a status").is_healthy());
11714
11715        host.controls.edit("runner-manager", |registration| {
11716            registration.starts_automatically = false;
11717        });
11718
11719        let status = operations.status().expect("a status");
11720        assert!(!status.is_healthy(), "{status}");
11721        assert!(
11722            status
11723                .problems()
11724                .iter()
11725                .any(|problem| problem.detail.contains("after a reboot")),
11726            "{status}"
11727        );
11728    }
11729
11730    #[test]
11731    fn install_starts_the_registration_and_a_later_stop_is_unhealthy() {
11732        let host = Host::new();
11733        let operations = host.operations();
11734        operations
11735            .install(&host.request(StartMode::Login))
11736            .expect("install and immediate start");
11737
11738        assert!(
11739            operations.status().expect("running status").is_running(),
11740            "install must not wait for the next login trigger"
11741        );
11742        operations.stop().expect("stop the registration");
11743        let status = operations.status().expect("stopped status");
11744        assert!(!status.is_healthy(), "{status}");
11745        assert!(
11746            status
11747                .problems()
11748                .iter()
11749                .any(|problem| problem.subject == "runtime"),
11750            "{status}"
11751        );
11752        let rendered = status.to_string();
11753        assert!(
11754            rendered.contains("runner-manager service install --start-at login"),
11755            "{rendered}"
11756        );
11757        assert!(
11758            !rendered.contains("runner-manager service start"),
11759            "{rendered}"
11760        );
11761    }
11762
11763    #[test]
11764    fn status_reports_a_restart_policy_something_else_edited() {
11765        let host = Host::new();
11766        let operations = host.operations();
11767        operations
11768            .install(&host.request(StartMode::Boot))
11769            .expect("an install");
11770        assert!(operations.status().expect("a status").is_healthy());
11771
11772        host.controls.edit("runner-manager", |registration| {
11773            registration.restart_delay = Some(Duration::from_secs(1));
11774        });
11775
11776        let status = operations.status().expect("a status");
11777        assert!(!status.is_healthy(), "{status}");
11778        assert!(
11779            status
11780                .problems()
11781                .iter()
11782                .any(|problem| problem.subject == "restart policy"),
11783            "{status}"
11784        );
11785    }
11786
11787    #[test]
11788    fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
11789        let host = Host::new();
11790        let operations = host.operations();
11791        operations
11792            .install(&host.request(StartMode::Boot))
11793            .expect("an install");
11794        let other = host.binary.with_file_name("someone-elses.exe");
11795        std::fs::write(&other, b"x").expect("writable");
11796
11797        host.controls.edit("runner-manager", |registration| {
11798            registration.command_line = quote_argument(&other.to_string_lossy());
11799        });
11800
11801        let status = operations.status().expect("a status");
11802        assert!(!status.is_healthy(), "{status}");
11803        assert!(
11804            matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
11805            "{status}"
11806        );
11807    }
11808
11809    #[test]
11810    fn status_reports_a_record_no_service_manager_knows_about() {
11811        let host = Host::new();
11812        let operations = host.operations();
11813        operations
11814            .install(&host.request(StartMode::Boot))
11815            .expect("an install");
11816        // Something removed the registration behind this product's back.
11817        for mode in [StartMode::Boot, StartMode::Login] {
11818            host.controls
11819                .control(mode)
11820                .expect("a control")
11821                .uninstall(&ServiceIdentity::product())
11822                .expect("removed");
11823        }
11824
11825        let status = operations.status().expect("a status");
11826        assert!(!status.is_healthy(), "{status}");
11827        assert!(
11828            status
11829                .problems()
11830                .iter()
11831                .any(|problem| problem.subject == "registration"),
11832            "{status}"
11833        );
11834    }
11835
11836    #[test]
11837    fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
11838        let host = Host::new();
11839        let status = host.operations().status().expect("a status");
11840        assert!(!status.is_installed());
11841        assert!(
11842            status.is_healthy(),
11843            "a host that never installed the service has no fault to report: {status}"
11844        );
11845        assert!(status.to_string().contains("installed"), "{status}");
11846    }
11847
11848    #[test]
11849    fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
11850        let host = Host::new();
11851        let operations = host.operations();
11852        operations
11853            .install(&host.request(StartMode::Login))
11854            .expect("an install at login");
11855        let status = operations.status().expect("a status");
11856        assert!(
11857            status
11858                .notes()
11859                .iter()
11860                .any(|note| note.contains("does not run until the operator signs in")),
11861            "05-infrastructure.md requires `service status` to say so: {status}"
11862        );
11863    }
11864
11865    /// A Mac that signs its runner account in automatically does resume, and
11866    /// `service status` used to tell it otherwise.
11867    #[test]
11868    fn a_mac_with_automatic_login_as_the_service_account_resumes_after_a_reboot() {
11869        use crate::unattended_login::{AutoLogin, UnattendedLogin};
11870        let automatic = UnattendedLogin {
11871            auto_login: AutoLogin::As("ivan".into()),
11872            filevault_on: Some(false),
11873        };
11874        assert_eq!(
11875            unattended_gap(StartMode::Login, "ivan", Some(&automatic)),
11876            None
11877        );
11878
11879        let off = UnattendedLogin {
11880            auto_login: AutoLogin::Off,
11881            filevault_on: Some(false),
11882        };
11883        let gap = unattended_gap(StartMode::Login, "ivan", Some(&off)).expect("a gap");
11884        assert!(gap.reason.contains("automatic login is off"), "{gap:?}");
11885        assert!(gap.reason.contains("Automatically log in as"), "{gap:?}");
11886
11887        let locked = UnattendedLogin {
11888            auto_login: AutoLogin::As("ivan".into()),
11889            filevault_on: Some(true),
11890        };
11891        let gap = unattended_gap(StartMode::Boot, "root", Some(&locked)).expect("a gap");
11892        assert!(gap.reason.contains("FileVault is on"), "{gap:?}");
11893
11894        // An unanswered probe is no gap: `host doctor` says what it could not
11895        // read.
11896        let unread = UnattendedLogin {
11897            auto_login: AutoLogin::As("ivan".into()),
11898            filevault_on: None,
11899        };
11900        assert_eq!(
11901            unattended_gap(StartMode::Login, "ivan", Some(&unread)),
11902            None
11903        );
11904
11905        // Where nothing can be probed, a login registration still says so.
11906        let gap = unattended_gap(StartMode::Login, "ivan", None).expect("a gap");
11907        assert!(
11908            gap.reason
11909                .contains("does not run until the operator signs in")
11910        );
11911        assert_eq!(unattended_gap(StartMode::Boot, "root", None), None);
11912    }
11913
11914    /// A daemon blocked in a file system call is still "running" to the
11915    /// service manager; its heartbeat is what says it is stuck.
11916    #[test]
11917    fn a_daemon_that_stopped_beating_makes_the_service_unhealthy() {
11918        let host = Host::new();
11919        let operations = host.operations();
11920        operations
11921            .install(&host.request(StartMode::Login))
11922            .expect("an install at login");
11923        let stalled_problem = |operations: &ServiceOperations| {
11924            operations
11925                .status()
11926                .expect("a status")
11927                .problems()
11928                .iter()
11929                .find(|problem| problem.subject == DAEMON_STALLED_SUBJECT)
11930                .map(|problem| problem.detail.clone())
11931        };
11932        crate::daemon_heartbeat::beat(&operations.paths, Utc::now()).unwrap();
11933        assert_eq!(stalled_problem(&operations), None, "beating");
11934
11935        crate::daemon_heartbeat::beat(
11936            &operations.paths,
11937            Utc::now() - chrono::Duration::minutes(20),
11938        )
11939        .unwrap();
11940        let detail = stalled_problem(&operations).expect("a stall is a problem");
11941        assert!(detail.contains("has made no progress since"), "{detail}");
11942        // The restart is the service manager's command for this login-mode
11943        // registration: the product has no `service stop` or `service start`.
11944        let restart = ServiceIdentity::product().restart_command(StartMode::Login);
11945        assert!(detail.contains(&restart), "{detail}");
11946        assert!(!detail.contains("runner-manager service stop"), "{detail}");
11947        assert!(!detail.contains("runner-manager service start"), "{detail}");
11948    }
11949
11950    /// Each platform's restart is its service manager's own command for the
11951    /// registration's start mode, never a `runner-manager service` subcommand
11952    /// that does not exist.
11953    #[test]
11954    fn the_restart_command_is_the_service_managers_own() {
11955        let identity = ServiceIdentity::product();
11956        let boot = identity.restart_command(StartMode::Boot);
11957        let login = identity.restart_command(StartMode::Login);
11958        for command in [&boot, &login] {
11959            assert!(!command.contains("runner-manager service"), "{command}");
11960            assert!(command.contains(identity.name()), "{command}");
11961        }
11962        assert_ne!(boot, login);
11963        if cfg!(windows) {
11964            assert!(
11965                boot.starts_with("Restart-Service -Name runner-manager"),
11966                "{boot}"
11967            );
11968            assert!(
11969                login.contains("schtasks /Run /TN runner-manager"),
11970                "{login}"
11971            );
11972        } else if cfg!(target_os = "macos") {
11973            assert!(
11974                boot.starts_with("sudo launchctl kickstart -k system/"),
11975                "{boot}"
11976            );
11977            assert!(login.starts_with("launchctl kickstart -k gui/"), "{login}");
11978        } else {
11979            assert_eq!(boot, "sudo systemctl restart runner-manager.service");
11980            assert_eq!(login, "systemctl --user restart runner-manager.service");
11981        }
11982    }
11983
11984    /// A daemon refusing every runner is not a healthy service.
11985    #[test]
11986    fn a_daemon_that_finds_the_host_unfit_makes_the_service_unhealthy() {
11987        let host = Host::new();
11988        let operations = host.operations();
11989        operations
11990            .install(&host.request(StartMode::Login))
11991            .expect("an install at login");
11992        let paths = operations.paths.clone();
11993        crate::host_fitness::record_host_unfit(
11994            &paths,
11995            &[crate::host_fitness::UnfitFinding {
11996                id: "host.runner_root_responsive".into(),
11997                detail: "macOS has not let the service list /Volumes/NVME/runners".into(),
11998                remedy: Some("click Allow".into()),
11999            }],
12000            Utc::now(),
12001        )
12002        .unwrap();
12003        let status = operations.status().expect("a status");
12004        assert!(!status.is_healthy(), "{status}");
12005        let problem = status
12006            .problems()
12007            .iter()
12008            .find(|problem| problem.subject == HOST_UNFIT_SUBJECT)
12009            .expect("the refusal is a problem");
12010        assert!(
12011            problem.detail.contains("/Volumes/NVME/runners"),
12012            "{problem:?}"
12013        );
12014        assert!(problem.detail.contains("Fix: click Allow"), "{problem:?}");
12015
12016        // A record nobody has re-stamped for a while is a stopped daemon's,
12017        // not a refusal in force.
12018        crate::host_fitness::clear_host_unfit(&paths).unwrap();
12019        crate::host_fitness::record_host_unfit(
12020            &paths,
12021            &[],
12022            Utc::now() - chrono::Duration::hours(2),
12023        )
12024        .unwrap();
12025        let status = operations.status().expect("a status");
12026        assert!(
12027            !status
12028                .problems()
12029                .iter()
12030                .any(|problem| problem.subject == HOST_UNFIT_SUBJECT),
12031            "{status}"
12032        );
12033    }
12034
12035    // -----------------------------------------------------------------------
12036    // Start and stop
12037    // -----------------------------------------------------------------------
12038
12039    #[test]
12040    fn start_and_stop_reach_the_domain_that_holds_the_registration() {
12041        let host = Host::new();
12042        let operations = host.operations();
12043        operations
12044            .install(&host.request(StartMode::Login))
12045            .expect("an install at login");
12046        operations.start().expect("a start");
12047        assert!(operations.status().expect("a status").is_running());
12048        assert!(operations.stop().expect("a stop"));
12049        assert!(!operations.status().expect("a status").is_running());
12050    }
12051
12052    #[test]
12053    fn starting_a_host_with_no_registration_is_refused() {
12054        let host = Host::new();
12055        let error = host.operations().start().expect_err("nothing to start");
12056        assert!(
12057            matches!(error, ServiceError::NotInstalled { .. }),
12058            "{error}"
12059        );
12060    }
12061
12062    // -----------------------------------------------------------------------
12063    // The seam with `d2`
12064    // -----------------------------------------------------------------------
12065
12066    /// Requirement 2 is *"an account that can **read the secret store**"*, and
12067    /// on Windows what an account may read is decided by a DACL in a file this
12068    /// task does not own. Asserting the account name alone would be asserting
12069    /// this module against itself; this reads `d2`'s protection back off a real
12070    /// store and checks that it admits the account this installer registers.
12071    ///
12072    /// No privileges are needed. `d2` documents that a rooted machine-scoped
12073    /// store *"is protected and encrypted exactly as the standard one is"* —
12074    /// the Windows backend picks its DACL from the scope, not from the site —
12075    /// so a store in a temporary directory carries the access control the real
12076    /// one does.
12077    #[cfg(windows)]
12078    #[test]
12079    fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
12080        use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
12081
12082        let root = tempfile::tempdir().expect("a temporary directory");
12083        let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
12084            .expect("a rooted machine-scoped store");
12085        store
12086            .store(&secrecy::SecretString::from("a stand-in for the token"))
12087            .expect("the store accepts a value");
12088        let protection = store.protection().expect("the DACL can be read back");
12089
12090        assert!(
12091            protection.description().contains(";;;SY)"),
12092            "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
12093             read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
12094            protection.description()
12095        );
12096        assert_eq!(
12097            ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
12098            ServiceAccount::LocalSystem,
12099            "and that is the account this installer registers, which is why SY is what matters"
12100        );
12101        assert!(
12102            !protection.readable_by_other_local_users(),
12103            "the same DACL must still exclude ordinary local users: {}",
12104            protection.description()
12105        );
12106
12107        // The other half of the analysis in `docs/service-account.md`, and the
12108        // reason LocalService and NetworkService are not used: the DACL names
12109        // three trustees and neither of them is among them.
12110        for rejected in [";;;LS)", ";;;NS)"] {
12111            assert!(
12112                !protection.description().contains(rejected),
12113                "if the store ever admitted {rejected}, the least-privilege analysis in \
12114                 docs/service-account.md would need redoing: {}",
12115                protection.description()
12116            );
12117        }
12118    }
12119
12120    // -- another installation's service ---------------------------------------
12121
12122    /// A request that records `host`'s own four directories on the command
12123    /// line, the way `service install` registers one.
12124    fn request_with_directories(host: &Host, mode: StartMode) -> InstallRequest {
12125        let mut arguments: Vec<OsString> = DAEMON_ARGUMENTS.iter().map(OsString::from).collect();
12126        arguments.extend(ServiceDirectories::of(&host.paths).arguments());
12127        host.request(mode).with_arguments(arguments)
12128    }
12129
12130    /// The operations a second account on the same machine gets: its own
12131    /// directories, and the machine's one service manager.
12132    fn second_account(account: &Host, machine: &Host) -> ServiceOperations {
12133        ServiceOperations::with_controls(
12134            account.paths.clone(),
12135            ServiceIdentity::product(),
12136            std::sync::Arc::new(machine.controls.clone()),
12137        )
12138        .with_runner_root(account.runner_root.clone())
12139    }
12140
12141    /// The owner's boot service, and a second account holding the record of
12142    /// an earlier login install of its own -- the layout an owner reported on
12143    /// a Windows host with two local accounts.
12144    fn a_machine_with_a_stale_second_account() -> (Host, Host) {
12145        let owner = Host::new();
12146        owner
12147            .operations()
12148            .install(&request_with_directories(&owner, StartMode::Boot))
12149            .expect("the owner's boot install");
12150        let earlier = Host::new();
12151        earlier
12152            .operations()
12153            .install(&request_with_directories(&earlier, StartMode::Login))
12154            .expect("the second account's earlier login install");
12155        assert!(
12156            InstallRecord::read(&earlier.paths).unwrap().is_some(),
12157            "the earlier install left its record behind"
12158        );
12159        (owner, earlier)
12160    }
12161
12162    /// The advice that, followed from the second account, breaks or replaces
12163    /// the owner's healthy service.
12164    const DESTRUCTIVE_ADVICE: [&str; 4] = [
12165        "service uninstall",
12166        "service install`",
12167        "service install --",
12168        "auth login",
12169    ];
12170
12171    #[test]
12172    fn a_service_installed_from_another_data_root_is_reported_as_that_installations() {
12173        let (owner, earlier) = a_machine_with_a_stale_second_account();
12174
12175        let status = second_account(&earlier, &owner).status().expect("a status");
12176
12177        let foreign = status.foreign().expect("the service is the owner's");
12178        assert_eq!(foreign.directories, ServiceDirectories::of(&owner.paths));
12179        assert_eq!(foreign.root, ServiceDirectories::of(&owner.paths).root());
12180        assert!(foreign.unreadable.is_none(), "{status}");
12181        assert!(
12182            status.is_healthy(),
12183            "the owner's service is healthy, and a stale record elsewhere is not its fault: \
12184             {status}"
12185        );
12186        for subject in [
12187            "binary",
12188            "start mode",
12189            "secret store",
12190            "record",
12191            "registration",
12192        ] {
12193            assert!(
12194                !status
12195                    .problems()
12196                    .iter()
12197                    .any(|problem| problem.subject == subject),
12198                "{subject} compared the second account's record against the owner's \
12199                 registration: {status}"
12200            );
12201        }
12202        let rendered = status.to_string();
12203        for advice in DESTRUCTIVE_ADVICE {
12204            assert!(!rendered.contains(advice), "{advice}: {rendered}");
12205        }
12206        assert!(rendered.contains("another installation's"), "{rendered}");
12207        assert!(
12208            status
12209                .notes()
12210                .iter()
12211                .any(|note| note.contains("unused local configuration")
12212                    && note.contains("host forget-local")),
12213            "{status}"
12214        );
12215        // What the owner recorded, not what the second account did.
12216        assert_eq!(status.start_mode(), Some(StartMode::Boot));
12217    }
12218
12219    #[test]
12220    fn a_foreign_service_whose_records_cannot_be_read_reports_only_what_the_manager_says() {
12221        let (owner, earlier) = a_machine_with_a_stale_second_account();
12222        // Stands in for a profile this account may not open: there is nothing
12223        // to read where the registration points.
12224        InstallRecord::remove(&owner.paths).expect("the owner's record removed");
12225
12226        let operations = second_account(&earlier, &owner);
12227        let status = operations.status().expect("a status");
12228        let foreign = status.foreign().expect("the service is the owner's");
12229        assert!(foreign.unreadable.is_some(), "{status}");
12230        assert!(
12231            status.is_healthy(),
12232            "running, and nothing it could judge: {status}"
12233        );
12234        let rendered = status.to_string();
12235        for advice in DESTRUCTIVE_ADVICE {
12236            assert!(!rendered.contains(advice), "{advice}: {rendered}");
12237        }
12238        assert!(rendered.contains("running; installed from"), "{rendered}");
12239        assert!(!rendered.contains("last GitHub contact"), "{rendered}");
12240
12241        // Stopped, it is a problem -- with a place to look, not a reinstall.
12242        owner.operations().stop().expect("stopped");
12243        let stopped = operations.status().expect("a status");
12244        assert!(!stopped.is_healthy(), "{stopped}");
12245        let rendered = stopped.to_string();
12246        for advice in DESTRUCTIVE_ADVICE {
12247            assert!(!rendered.contains(advice), "{advice}: {rendered}");
12248        }
12249    }
12250
12251    #[test]
12252    fn this_accounts_own_registration_still_reports_every_mismatch() {
12253        let host = Host::new();
12254        let operations = host.operations();
12255        operations
12256            .install(&request_with_directories(&host, StartMode::Boot))
12257            .expect("an install");
12258        let other = host.binary.with_file_name("someone-elses");
12259        std::fs::write(&other, b"x").expect("writable");
12260        // Something edited the registration, keeping its directories.
12261        host.controls.edit("runner-manager", |registration| {
12262            let mut arguments = split_command_line(&registration.command_line);
12263            arguments[0] = other.to_string_lossy().into_owned();
12264            registration.command_line = arguments
12265                .iter()
12266                .map(|argument| quote_argument(argument))
12267                .collect::<Vec<_>>()
12268                .join(" ");
12269            registration.start_mode = StartMode::Login;
12270        });
12271
12272        let status = operations.status().expect("a status");
12273        assert!(status.foreign().is_none(), "{status}");
12274        assert!(
12275            matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
12276            "{status}"
12277        );
12278        assert!(
12279            status
12280                .problems()
12281                .iter()
12282                .any(|problem| problem.subject == "start mode"),
12283            "{status}"
12284        );
12285        assert!(status.to_string().contains("service uninstall"), "{status}");
12286    }
12287
12288    #[test]
12289    fn directories_read_back_from_a_command_line_with_spaces() {
12290        let root = Path::new("/Users/someone/Library/Application Support/runner manager");
12291        let directories = ServiceDirectories {
12292            config: root.join("config"),
12293            state: root.join("data").join("state"),
12294            runtime: root.join("data").join("runtime"),
12295            logs: root.join("data").join("logs"),
12296        };
12297        let mut command_line = quote_argument("/usr/local/bin/runner-manager");
12298        for argument in ["daemon", "run"]
12299            .map(OsString::from)
12300            .into_iter()
12301            .chain(directories.arguments())
12302        {
12303            command_line.push(' ');
12304            command_line.push_str(&quote_argument(&argument.to_string_lossy()));
12305        }
12306        assert_eq!(
12307            ServiceDirectories::from_command_line(&command_line),
12308            Some(directories.clone())
12309        );
12310        assert_eq!(directories.root(), root);
12311        assert_eq!(
12312            ServiceDirectories::from_command_line("runner-manager daemon run"),
12313            None,
12314            "a registration that names no directories cannot be judged foreign"
12315        );
12316    }
12317
12318    #[test]
12319    fn the_owner_of_a_data_root_is_read_from_its_path() {
12320        let users = Path::new(if cfg!(windows) { r"C:\Users" } else { "/home" });
12321        let home = users.join("ivand");
12322        assert_eq!(
12323            data_root_owner(&users.join("yuriv").join("data"), Some(&home)).as_deref(),
12324            Some("yuriv")
12325        );
12326        assert_eq!(
12327            data_root_owner(
12328                Path::new(if cfg!(windows) { r"D:\rm" } else { "/opt/rm" }),
12329                Some(&home)
12330            ),
12331            None
12332        );
12333        if cfg!(windows) {
12334            assert_eq!(
12335                data_root_owner(
12336                    Path::new(r"C:\Windows\System32\config\systemprofile\AppData"),
12337                    Some(&home)
12338                )
12339                .as_deref(),
12340                Some("LocalSystem")
12341            );
12342        } else {
12343            assert_eq!(
12344                data_root_owner(Path::new("/root/.config/runner-manager"), Some(&home)).as_deref(),
12345                Some("root")
12346            );
12347        }
12348    }
12349
12350    #[test]
12351    fn forget_local_archives_the_unused_record_and_never_touches_the_service_root() {
12352        let (owner, earlier) = a_machine_with_a_stale_second_account();
12353        let owner_before = snapshot(&[owner._root.path()]);
12354        let config = earlier.paths.config_dir().to_path_buf();
12355        let record = std::fs::read(InstallRecord::path(&earlier.paths)).unwrap();
12356
12357        let forgotten = second_account(&earlier, &owner)
12358            .forget_local(Utc::now())
12359            .expect("archived");
12360
12361        assert_eq!(
12362            snapshot(&[owner._root.path()]),
12363            owner_before,
12364            "the owner's directories must not change by one byte"
12365        );
12366        let archive = forgotten.archive.expect("something was archived");
12367        assert!(archive.starts_with(&config), "{}", archive.display());
12368        assert_eq!(
12369            std::fs::read(archive.join(RECORD_FILE)).unwrap(),
12370            record,
12371            "moved, not rewritten or deleted"
12372        );
12373        assert!(InstallRecord::read(&earlier.paths).unwrap().is_none());
12374        assert_eq!(
12375            forgotten.service_root,
12376            Some(ServiceDirectories::of(&owner.paths).root())
12377        );
12378        // Afterwards the second account's status has no unused record to note.
12379        let status = second_account(&earlier, &owner).status().expect("status");
12380        assert!(
12381            !status
12382                .notes()
12383                .iter()
12384                .any(|note| note.contains("unused local configuration")),
12385            "{status}"
12386        );
12387    }
12388
12389    #[test]
12390    fn forget_local_refuses_a_configuration_a_service_runs_against() {
12391        let (owner, earlier) = a_machine_with_a_stale_second_account();
12392        let before = snapshot(&[owner._root.path()]);
12393        let refused = owner.operations().forget_local(Utc::now());
12394        assert!(
12395            matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
12396            "{refused:?}"
12397        );
12398        assert_eq!(snapshot(&[owner._root.path()]), before);
12399
12400        // Nor while an agent holds the second account's own lock.
12401        let _agent = HostLock::try_acquire(&earlier.paths, LockKind::SingleInstance)
12402            .expect("the agent's lock");
12403        let refused = second_account(&earlier, &owner).forget_local(Utc::now());
12404        assert!(
12405            matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
12406            "{refused:?}"
12407        );
12408        assert!(InstallRecord::read(&earlier.paths).unwrap().is_some());
12409    }
12410
12411    #[test]
12412    fn a_readable_foreign_service_keeps_its_diagnoses_and_loses_its_remedies() {
12413        let (owner, earlier) = a_machine_with_a_stale_second_account();
12414        // The owner's service has every problem whose remedy acts on the
12415        // directories of whoever runs it.
12416        owner.operations().stop().expect("stopped");
12417        record_github_credential_rejected(&owner.paths, Utc::now()).expect("rejected");
12418        record_credential_unreadable(&owner.paths, Utc::now()).expect("unreadable");
12419
12420        let status = second_account(&earlier, &owner).status().expect("a status");
12421        assert!(status.foreign().is_some_and(|foreign| !foreign.is_unread()));
12422        let subjects: Vec<_> = status.problems().iter().map(|p| p.subject).collect();
12423        for subject in [
12424            "runtime",
12425            "github credential",
12426            CREDENTIAL_UNREADABLE_SUBJECT,
12427        ] {
12428            assert!(subjects.contains(&subject), "{subject}: {status}");
12429        }
12430        let rendered = status.to_string();
12431        for advice in DESTRUCTIVE_ADVICE {
12432            assert!(!rendered.contains(advice), "{advice}: {rendered}");
12433        }
12434        for problem in status.problems() {
12435            assert!(problem.detail.contains("Act on it"), "{problem}");
12436        }
12437    }
12438
12439    #[test]
12440    fn no_command_of_this_account_changes_another_installations_service() {
12441        let (owner, earlier) = a_machine_with_a_stale_second_account();
12442        let operations = second_account(&earlier, &owner);
12443        let foreign = |result: Result<(), ServiceError>| {
12444            assert!(
12445                matches!(result, Err(ServiceError::ForeignInstallation { .. })),
12446                "{result:?}"
12447            );
12448        };
12449        foreign(
12450            operations
12451                .install(&request_with_directories(&earlier, StartMode::Boot))
12452                .map(drop),
12453        );
12454        foreign(operations.uninstall().map(drop));
12455        foreign(operations.set_start_mode(StartMode::Boot).map(drop));
12456        foreign(operations.start());
12457        foreign(operations.stop().map(drop));
12458
12459        let registration = owner
12460            .controls
12461            .control(StartMode::Boot)
12462            .unwrap()
12463            .query(&ServiceIdentity::product())
12464            .unwrap()
12465            .expect("the owner's registration is still there");
12466        assert_eq!(
12467            ServiceDirectories::from_command_line(&registration.command_line),
12468            Some(ServiceDirectories::of(&owner.paths)),
12469            "and still runs against the owner's directories"
12470        );
12471        assert!(registration.running, "and was never stopped");
12472        assert!(
12473            InstallRecord::read(&earlier.paths).unwrap().is_some(),
12474            "the refused uninstall kept this account's record"
12475        );
12476    }
12477
12478    #[test]
12479    fn forget_local_leaves_agent_directories_nested_in_the_configuration_alone() {
12480        // macOS puts state, runtime and logs inside the configuration
12481        // directory: `config_local_dir` and `data_local_dir` are one folder.
12482        let root = tempfile::tempdir().unwrap();
12483        let config = root.path().join("Application Support");
12484        let paths = AppPaths::from_directories(
12485            &config,
12486            config.join("state"),
12487            config.join("runtime"),
12488            config.join("logs"),
12489        );
12490        paths.create_all().unwrap();
12491        std::fs::write(config.join(RECORD_FILE), b"an earlier install").unwrap();
12492        std::fs::write(paths.logs_dir().join("runner-manager.log"), b"log").unwrap();
12493        let operations = ServiceOperations::with_controls(
12494            paths.clone(),
12495            ServiceIdentity::product(),
12496            std::sync::Arc::new(RecordingControls::new()),
12497        );
12498
12499        let forgotten = operations.forget_local(Utc::now()).expect("archived");
12500
12501        assert_eq!(forgotten.moved, vec![config.join(RECORD_FILE)]);
12502        for kept in [paths.state_dir(), paths.runtime_dir(), paths.logs_dir()] {
12503            assert!(kept.is_dir(), "{} was moved", kept.display());
12504        }
12505        assert!(paths.logs_dir().join("runner-manager.log").is_file());
12506    }
12507
12508    #[test]
12509    fn a_remedy_is_cut_from_every_detail_a_builder_writes() {
12510        for (detail, diagnosis) in [
12511            (
12512                "x holds an automatic registration, but the daemon is stopped. Run `runner-manager \
12513                 service install --start-at boot`; if the service manager denies replacement",
12514                "x holds an automatic registration, but the daemon is stopped",
12515            ),
12516            (
12517                "store -- MISMATCH. Run `auth login` again so the token is stored",
12518                "store -- MISMATCH",
12519            ),
12520            (
12521                "unit differs, so the daemon runs under outdated settings. Re-register it with \
12522                 `sudo runner-manager service install --start-at boot`",
12523                "unit differs, so the daemon runs under outdated settings",
12524            ),
12525            (
12526                "it starts no runner; the log has it. Sign in again: `runner-manager auth login`",
12527                "it starts no runner; the log has it",
12528            ),
12529            (
12530                "C:\\a\\runner-manager.exe -- STALE: moved. Run `service uninstall` and `service \
12531                 install`",
12532                "C:\\a\\runner-manager.exe -- STALE: moved",
12533            ),
12534            (
12535                "a new builder: run `runner-manager service install` here, or not",
12536                "the installation's own records report this",
12537            ),
12538        ] {
12539            assert_eq!(diagnosis_only(detail), diagnosis, "{detail}");
12540        }
12541    }
12542}