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    protected: Vec<PathBuf>,
984}
985
986impl InstallRequest {
987    /// Register the binary that is running this call.
988    #[must_use]
989    pub fn new(start_mode: StartMode) -> Self {
990        Self {
991            start_mode,
992            binary: None,
993            source_binary: None,
994            arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
995            restart: RestartPolicy::default(),
996            on_demand: false,
997            protected: Vec::new(),
998        }
999    }
1000
1001    /// Host paths the service, its runners and their jobs may not reach at
1002    /// all. See [`ProtectedPaths`].
1003    #[must_use]
1004    pub fn protecting(mut self, paths: Vec<PathBuf>) -> Self {
1005        self.protected = paths;
1006        self
1007    }
1008
1009    /// Registers the service but does not ask the manager to start it by
1010    /// itself.
1011    ///
1012    /// Production never uses this: a boot-mode registration that does not start
1013    /// at boot is the failure `service status` reports. **The privileged
1014    /// installer tests use it for every fixture they create**, so that a
1015    /// registration which somehow escaped its cleanup guard cannot start with
1016    /// the owner's machine on the next reboot. A leaked service is bad; a
1017    /// leaked service that runs is worse, and the difference costs one flag.
1018    #[must_use]
1019    pub const fn started_on_demand(mut self) -> Self {
1020        self.on_demand = true;
1021        self
1022    }
1023
1024    /// Register a named binary instead of the running one.
1025    ///
1026    /// The privileged installer tests use this to register a fixture service
1027    /// host. Production does not: `05-infrastructure.md` item 6 is specifically
1028    /// about the path of *the running binary*, and letting an operator name a
1029    /// different one would defeat the stale-path detection it asks for.
1030    #[must_use]
1031    pub fn for_binary(mut self, binary: impl Into<PathBuf>) -> Self {
1032        self.binary = Some(binary.into());
1033        self
1034    }
1035
1036    /// Where the registered binary was copied from.
1037    ///
1038    /// # Why a registration has a source at all
1039    ///
1040    /// A service registered directly against a package manager's own file can
1041    /// never be upgraded on Windows, because the running service holds that
1042    /// file open and `npm i -g` cannot replace it. What it does instead is
1043    /// worse than failing: it rewrites the package metadata, reports success,
1044    /// and leaves the old executable in place — so the operator is told the new
1045    /// version is installed while the old one keeps running. That was observed,
1046    /// twice in a row, before this existed.
1047    ///
1048    /// So the service runs a copy the product owns, and this records where that
1049    /// copy came from. The source is what a package manager updates, what
1050    /// [`inspect_binary`] watches for item 6's stale-path case, and what the
1051    /// daemon compares its own version against to know an upgrade is waiting.
1052    #[must_use]
1053    pub fn copied_from(mut self, source: impl Into<PathBuf>) -> Self {
1054        self.source_binary = Some(source.into());
1055        self
1056    }
1057
1058    /// Replace the registered arguments. Defaults to [`DAEMON_ARGUMENTS`].
1059    #[must_use]
1060    pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1061    where
1062        I: IntoIterator<Item = S>,
1063        S: Into<OsString>,
1064    {
1065        self.arguments = arguments.into_iter().map(Into::into).collect();
1066        self
1067    }
1068
1069    /// Replace the restart-on-failure policy. Defaults to
1070    /// [`RestartPolicy::default`].
1071    #[must_use]
1072    pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1073        self.restart = restart;
1074        self
1075    }
1076
1077    /// The start mode asked for.
1078    #[must_use]
1079    pub const fn start_mode(&self) -> StartMode {
1080        self.start_mode
1081    }
1082}
1083
1084/// Everything a backend needs, resolved and validated.
1085///
1086/// Constructing one is where `05-infrastructure.md` item 6 is actually
1087/// satisfied: the binary path is made absolute and is confirmed to be a file
1088/// *before* anything is registered, so a registration that would fail at boot
1089/// is refused at install time instead.
1090#[derive(Debug, Clone, PartialEq, Eq)]
1091pub struct InstallPlan {
1092    identity: ServiceIdentity,
1093    start_mode: StartMode,
1094    binary: PathBuf,
1095    source_binary: Option<PathBuf>,
1096    arguments: Vec<OsString>,
1097    account: ServiceAccount,
1098    restart: RestartPolicy,
1099    directories: ServiceDirectories,
1100    secret_guard: Option<PathBuf>,
1101    on_demand: bool,
1102    protected: Vec<PathBuf>,
1103}
1104
1105impl InstallPlan {
1106    /// Resolves a request against this host.
1107    ///
1108    /// # Errors
1109    ///
1110    /// [`ServiceError::BinaryPath`] when the running executable cannot be
1111    /// located, and [`ServiceError::BinaryMissing`] when the path that would be
1112    /// registered is not a file.
1113    pub fn resolve(
1114        identity: ServiceIdentity,
1115        request: &InstallRequest,
1116        directories: ServiceDirectories,
1117    ) -> Result<Self, ServiceError> {
1118        let binary = match &request.binary {
1119            Some(named) => absolute(named)?,
1120            None => running_executable()?,
1121        };
1122        if !binary.is_file() {
1123            return Err(ServiceError::BinaryMissing { path: binary });
1124        }
1125        // `d2` publishes the guard file the store lives in so the systemd
1126        // sandbox can admit only its parent for atomic token rotation. A store
1127        // that cannot be resolved is not fatal here; it merely cannot receive
1128        // that narrow writable-path exception.
1129        let secret_guard = crate::secrets::PlatformSecretStore::for_start_mode(request.start_mode)
1130            .ok()
1131            .map(|store| store.guard());
1132        Ok(Self {
1133            identity,
1134            start_mode: request.start_mode,
1135            binary,
1136            source_binary: request.source_binary.clone(),
1137            arguments: request.arguments.clone(),
1138            account: ServiceAccount::for_start_mode(request.start_mode),
1139            restart: request.restart,
1140            directories,
1141            secret_guard,
1142            on_demand: request.on_demand,
1143            protected: request.protected.clone(),
1144        })
1145    }
1146
1147    /// Builds a plan from values a caller already holds, without touching the
1148    /// filesystem.
1149    ///
1150    /// This is what makes the renderers testable on a host that has none of the
1151    /// paths involved: a Windows developer can render the systemd unit for
1152    /// `/opt/runner-manager/bin/runner-manager` and assert every line of it.
1153    #[must_use]
1154    pub fn unchecked(
1155        identity: ServiceIdentity,
1156        start_mode: StartMode,
1157        binary: impl Into<PathBuf>,
1158        directories: ServiceDirectories,
1159    ) -> Self {
1160        Self {
1161            identity,
1162            start_mode,
1163            binary: binary.into(),
1164            source_binary: None,
1165            arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
1166            account: ServiceAccount::for_start_mode(start_mode),
1167            restart: RestartPolicy::default(),
1168            directories,
1169            secret_guard: None,
1170            on_demand: false,
1171            protected: Vec::new(),
1172        }
1173    }
1174
1175    /// The same plan with `paths` made inaccessible. See [`ProtectedPaths`].
1176    #[must_use]
1177    pub fn with_protected_paths(mut self, paths: Vec<PathBuf>) -> Self {
1178        self.protected = paths;
1179        self
1180    }
1181
1182    /// The host paths the service and its jobs may not reach.
1183    #[must_use]
1184    pub fn protected_paths(&self) -> &[PathBuf] {
1185        &self.protected
1186    }
1187
1188    /// Registers without arming the manager's automatic start. See
1189    /// [`InstallRequest::started_on_demand`].
1190    #[must_use]
1191    pub const fn started_on_demand(mut self) -> Self {
1192        self.on_demand = true;
1193        self
1194    }
1195
1196    /// Whether the manager was asked not to start this by itself.
1197    #[must_use]
1198    pub const fn is_on_demand(&self) -> bool {
1199        self.on_demand
1200    }
1201
1202    /// Names the file the secret store lives in, so systemd can permit atomic
1203    /// replacement of it under `ProtectSystem=strict`.
1204    ///
1205    /// [`InstallPlan::resolve`] fills this from `d2`; a test names it directly
1206    /// so that a Windows or macOS CI leg can assert the Linux unit's writable
1207    /// path for a store that platform does not have.
1208    #[must_use]
1209    pub fn with_secret_guard(mut self, guard: impl Into<PathBuf>) -> Self {
1210        self.secret_guard = Some(guard.into());
1211        self
1212    }
1213
1214    /// The file the machine-scoped secret store lives in, when one resolved.
1215    #[must_use]
1216    pub fn secret_guard(&self) -> Option<&Path> {
1217        self.secret_guard.as_deref()
1218    }
1219
1220    /// Overrides the restart policy on an already-built plan.
1221    #[must_use]
1222    pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1223        self.restart = restart;
1224        self
1225    }
1226
1227    /// Overrides the registered arguments on an already-built plan.
1228    #[must_use]
1229    pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1230    where
1231        I: IntoIterator<Item = S>,
1232        S: Into<OsString>,
1233    {
1234        self.arguments = arguments.into_iter().map(Into::into).collect();
1235        self
1236    }
1237
1238    /// What the operating system calls this registration.
1239    #[must_use]
1240    pub const fn identity(&self) -> &ServiceIdentity {
1241        &self.identity
1242    }
1243
1244    /// Boot or login.
1245    #[must_use]
1246    pub const fn start_mode(&self) -> StartMode {
1247        self.start_mode
1248    }
1249
1250    /// The resolved absolute path of the binary to register.
1251    #[must_use]
1252    pub fn binary(&self) -> &Path {
1253        &self.binary
1254    }
1255
1256    /// Where [`Self::binary`] was copied from, when it is a copy.
1257    ///
1258    /// `None` is the legacy layout: the registration names the file a package
1259    /// manager owns, and cannot be upgraded while it runs. See
1260    /// [`InstallRequest::copied_from`].
1261    #[must_use]
1262    pub fn source_binary(&self) -> Option<&Path> {
1263        self.source_binary.as_deref()
1264    }
1265
1266    /// The arguments it is registered with.
1267    #[must_use]
1268    pub fn arguments(&self) -> &[OsString] {
1269        &self.arguments
1270    }
1271
1272    /// The account it runs under.
1273    #[must_use]
1274    pub const fn account(&self) -> &ServiceAccount {
1275        &self.account
1276    }
1277
1278    /// The restart-on-failure policy.
1279    #[must_use]
1280    pub const fn restart(&self) -> RestartPolicy {
1281        self.restart
1282    }
1283
1284    /// The four directories it was installed against.
1285    #[must_use]
1286    pub const fn directories(&self) -> &ServiceDirectories {
1287        &self.directories
1288    }
1289
1290    /// The command line as one string, quoted the way each platform expects.
1291    #[must_use]
1292    pub fn command_line(&self) -> String {
1293        let mut out = quote_argument(&self.binary.to_string_lossy());
1294        for argument in &self.arguments {
1295            out.push(' ');
1296            out.push_str(&quote_argument(&argument.to_string_lossy()));
1297        }
1298        out
1299    }
1300}
1301
1302/// Undoes a runner-root change, and says what could not be undone.
1303///
1304/// `None` when the rollback was complete — either because there was nothing to
1305/// undo, or because the directory this operation created was removed again and
1306/// the descriptor it replaced was written back. `Some` is the "report any
1307/// non-reversible existing directory state explicitly" half of the requirement,
1308/// and the caller folds it into the failure it was already reporting.
1309fn retained_runner_root(change: &RootAccessChange) -> Option<String> {
1310    let reversal = change.revert();
1311    matches!(reversal, Reversal::Retained { .. }).then(|| reversal.to_string())
1312}
1313
1314/// The error a failed step returns once both of its rollbacks have been
1315/// attempted.
1316///
1317/// `retained` is [`retained_runner_root`]'s answer and `rollback` is what
1318/// undoing the *registration* reported — whatever it reports on success, which
1319/// a failure has no use for. Both arrive already evaluated, because each may be
1320/// attempted exactly once and the order is the caller's to choose.
1321///
1322/// A complete rollback leaves `cause` exactly as it was: the operator's problem
1323/// is what failed, not the tidying up afterwards. An incomplete one is promoted
1324/// to [`ServiceError::Rollback`], which is the variant that exists to say "this
1325/// failed *and* something is left behind" — and it names everything that did.
1326fn rolled_back<T>(
1327    retained: Option<String>,
1328    rollback: Result<T, ServiceError>,
1329    operation: &'static str,
1330    identity: &ServiceIdentity,
1331    cause: ServiceError,
1332) -> ServiceError {
1333    let left_behind = match (rollback.err(), retained) {
1334        (Some(rollback), Some(retained)) => Some(format!("{rollback}; {retained}")),
1335        (Some(rollback), None) => Some(rollback.to_string()),
1336        (None, retained) => retained,
1337    };
1338    match left_behind {
1339        Some(rollback) => ServiceError::Rollback {
1340            operation,
1341            name: identity.name().to_string(),
1342            cause: cause.to_string(),
1343            rollback,
1344        },
1345        None => cause,
1346    }
1347}
1348
1349/// [`rolled_back`] for a failure with nothing registered yet, where the runner
1350/// root is the only thing there is to undo.
1351fn undo_runner_root(
1352    change: &RootAccessChange,
1353    operation: &'static str,
1354    identity: &ServiceIdentity,
1355    cause: ServiceError,
1356) -> ServiceError {
1357    rolled_back(
1358        retained_runner_root(change),
1359        Ok(()),
1360        operation,
1361        identity,
1362        cause,
1363    )
1364}
1365
1366/// Makes a path absolute without resolving symlinks.
1367///
1368/// Symbolic links are deliberately left alone. A Homebrew installation puts a
1369/// stable link at `/opt/homebrew/bin/runner-manager` and moves the file it
1370/// points at on every upgrade; recording the link's target would make an
1371/// ordinary `brew upgrade` look exactly like the npm failure this module exists
1372/// to detect, and recording the link records the thing the operator installed.
1373///
1374/// Linux is the one platform where this is not fully in the caller's hands:
1375/// `std::env::current_exe` reads `/proc/self/exe`, which the kernel has already
1376/// resolved. There the recorded path is the real file rather than the shim —
1377/// which is still correct for the npm case, because a real file under a Node
1378/// prefix disappears with the prefix.
1379fn absolute(path: &Path) -> Result<PathBuf, ServiceError> {
1380    std::path::absolute(path).map_err(|error| ServiceError::BinaryPath {
1381        detail: format!("{} could not be made absolute: {error}", path.display()),
1382    })
1383}
1384
1385/// The absolute path of the executable running this call.
1386fn running_executable() -> Result<PathBuf, ServiceError> {
1387    let raw = std::env::current_exe().map_err(|error| ServiceError::BinaryPath {
1388        detail: error.to_string(),
1389    })?;
1390    absolute(&raw)
1391}
1392
1393/// Quotes one command-line argument when it needs it.
1394///
1395/// The rule is Windows', because Windows is the platform where a command line is
1396/// a single string the callee re-splits, and because `windows-service` applies
1397/// exactly this rule when it builds `lpBinaryPathName`. The Unix backends embed
1398/// arguments in a plist array and in a systemd `ExecStart=`, both of which
1399/// accept the same quoting, so one rule serves all three rather than three
1400/// nearly-identical ones.
1401pub(crate) fn quote_argument(argument: &str) -> String {
1402    if !argument.is_empty() && !argument.contains([' ', '"', '\t', '\n']) {
1403        return argument.to_string();
1404    }
1405    let mut out = String::with_capacity(argument.len() + 2);
1406    out.push('"');
1407    let mut backslashes = 0usize;
1408    for c in argument.chars() {
1409        match c {
1410            '\\' => {
1411                backslashes += 1;
1412                out.push('\\');
1413            }
1414            '"' => {
1415                // Every backslash immediately before a quote must be doubled,
1416                // and the quote itself escaped.
1417                for _ in 0..=backslashes {
1418                    out.push('\\');
1419                }
1420                out.push('"');
1421                backslashes = 0;
1422            }
1423            other => {
1424                backslashes = 0;
1425                out.push(other);
1426            }
1427        }
1428    }
1429    // Trailing backslashes would otherwise escape the closing quote.
1430    for _ in 0..backslashes {
1431        out.push('\\');
1432    }
1433    out.push('"');
1434    out
1435}
1436
1437/// Reads the executable back out of a command line quoted by
1438/// [`quote_argument`].
1439///
1440/// `service status` needs this because the Windows Service Control Manager
1441/// stores the binary and its arguments as one string and hands the whole thing
1442/// back from `QueryServiceConfigW`. Comparing a record against a registration
1443/// means splitting that string the same way Windows itself does.
1444///
1445/// Returns `None` for an empty or whitespace-only command line, which is the
1446/// only input with no first argument to find.
1447#[must_use]
1448pub fn executable_from_command_line(command_line: &str) -> Option<PathBuf> {
1449    split_command_line(command_line)
1450        .into_iter()
1451        .next()
1452        .filter(|executable| !executable.is_empty())
1453        .map(PathBuf::from)
1454}
1455
1456/// Splits a command line quoted by [`quote_argument`] back into its
1457/// arguments, by the rule Windows itself applies: whitespace separates
1458/// arguments outside quotes, `2n` backslashes before a quote are `n`
1459/// backslashes and a quote that opens or closes, `2n+1` are `n` backslashes
1460/// and a literal quote, and every other backslash is literal.
1461#[must_use]
1462pub fn split_command_line(command_line: &str) -> Vec<String> {
1463    let mut arguments = Vec::new();
1464    let mut current = String::new();
1465    let mut started = false;
1466    let mut quoted = false;
1467    let mut backslashes = 0usize;
1468    for c in command_line.chars() {
1469        match c {
1470            '\\' => {
1471                backslashes += 1;
1472                started = true;
1473            }
1474            '"' => {
1475                current.extend(std::iter::repeat_n('\\', backslashes / 2));
1476                if backslashes.is_multiple_of(2) {
1477                    quoted = !quoted;
1478                } else {
1479                    current.push('"');
1480                }
1481                backslashes = 0;
1482                started = true;
1483            }
1484            ' ' | '\t' if !quoted => {
1485                current.extend(std::iter::repeat_n('\\', backslashes));
1486                backslashes = 0;
1487                if started {
1488                    arguments.push(std::mem::take(&mut current));
1489                    started = false;
1490                }
1491            }
1492            other => {
1493                current.extend(std::iter::repeat_n('\\', backslashes));
1494                backslashes = 0;
1495                current.push(other);
1496                started = true;
1497            }
1498        }
1499    }
1500    current.extend(std::iter::repeat_n('\\', backslashes));
1501    if started {
1502        arguments.push(current);
1503    }
1504    arguments
1505}
1506
1507// ---------------------------------------------------------------------------
1508// Rendered definitions
1509// ---------------------------------------------------------------------------
1510
1511/// Which of the four things a platform reads.
1512#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1513pub enum DefinitionKind {
1514    /// The parameters `CreateServiceW` is called with, rendered as a canonical
1515    /// descriptor so that they can be reviewed and asserted like the other
1516    /// three. The Service Control Manager has no file.
1517    WindowsService,
1518    /// A Task Scheduler XML document, for `--start-at login` on Windows.
1519    WindowsScheduledTask,
1520    /// A launchd property list — a LaunchDaemon at boot, a LaunchAgent at login.
1521    LaunchdPlist,
1522    /// A systemd unit — a system unit at boot, a user unit at login.
1523    SystemdUnit,
1524}
1525
1526impl DefinitionKind {
1527    /// What to call the thing that reads it, in an operator-facing message.
1528    #[must_use]
1529    pub const fn manager(self) -> &'static str {
1530        match self {
1531            Self::WindowsService => "the Windows Service Control Manager",
1532            Self::WindowsScheduledTask => "Windows Task Scheduler",
1533            Self::LaunchdPlist => "launchd",
1534            Self::SystemdUnit => "systemd",
1535        }
1536    }
1537}
1538
1539impl fmt::Display for DefinitionKind {
1540    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1541        f.write_str(self.manager())
1542    }
1543}
1544
1545/// One platform's definition of the registration, as text.
1546///
1547/// Producing this is pure: no `cfg`, no privileges, no filesystem. That is what
1548/// lets every leg of the CI matrix assert every platform's definition, instead
1549/// of each leg asserting only its own and the other two being reviewed by
1550/// reading.
1551#[derive(Debug, Clone, PartialEq, Eq)]
1552pub struct ServiceDefinition {
1553    kind: DefinitionKind,
1554    text: String,
1555    install_path: Option<PathBuf>,
1556}
1557
1558impl ServiceDefinition {
1559    /// Which platform this is for.
1560    #[must_use]
1561    pub const fn kind(&self) -> DefinitionKind {
1562        self.kind
1563    }
1564
1565    /// The definition itself.
1566    #[must_use]
1567    pub fn text(&self) -> &str {
1568        &self.text
1569    }
1570
1571    /// Where the platform expects the file, when the platform reads a file.
1572    ///
1573    /// `None` for [`DefinitionKind::WindowsService`], whose "definition" is a
1574    /// set of arguments to `CreateServiceW`, and for a Task Scheduler document,
1575    /// which is handed to `schtasks` from a temporary file and is not stored
1576    /// where it was written.
1577    #[must_use]
1578    pub fn install_path(&self) -> Option<&Path> {
1579        self.install_path.as_deref()
1580    }
1581
1582    /// The definition this host's own service manager would be given.
1583    ///
1584    /// The value-level twin of [`host_definition_kind`]. Rendering needs no
1585    /// privileges and no service manager, so anything that wants to *see* what
1586    /// an install would register — `f3`'s `service install --dry-run`, a
1587    /// support bundle, [`RecordingControls`] — can have it without registering
1588    /// anything.
1589    ///
1590    /// # Errors
1591    ///
1592    /// [`ServiceError::Control`] when a Windows logon-triggered registration is
1593    /// asked for and the session reports no account to register it for.
1594    pub fn for_host(plan: &InstallPlan) -> Result<Self, ServiceError> {
1595        Ok(match host_definition_kind(plan.start_mode()) {
1596            DefinitionKind::WindowsService => Self::windows_service(plan),
1597            DefinitionKind::WindowsScheduledTask => {
1598                Self::windows_scheduled_task(plan, &TaskPrincipal::current()?)
1599            }
1600            DefinitionKind::LaunchdPlist => Self::launchd(plan, host_home().as_deref()),
1601            DefinitionKind::SystemdUnit => Self::systemd(plan, host_home().as_deref()),
1602        })
1603    }
1604
1605    /// The Windows service parameters, as a canonical descriptor.
1606    #[must_use]
1607    pub fn windows_service(plan: &InstallPlan) -> Self {
1608        Self {
1609            kind: DefinitionKind::WindowsService,
1610            text: windows_service_descriptor(plan),
1611            install_path: None,
1612        }
1613    }
1614
1615    /// A Task Scheduler document for `--start-at login`.
1616    #[must_use]
1617    pub fn windows_scheduled_task(plan: &InstallPlan, principal: &TaskPrincipal) -> Self {
1618        Self {
1619            kind: DefinitionKind::WindowsScheduledTask,
1620            text: windows_scheduled_task_xml(plan, principal),
1621            install_path: None,
1622        }
1623    }
1624
1625    /// A launchd property list.
1626    ///
1627    /// `home` is only consulted for `--start-at login`, where the file belongs
1628    /// in the operator's own `~/Library/LaunchAgents`.
1629    #[must_use]
1630    pub fn launchd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1631        let file = format!("{}.plist", plan.identity().launchd_label());
1632        let install_path = match plan.start_mode() {
1633            StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
1634            StartMode::Login => home.map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file)),
1635        };
1636        Self {
1637            kind: DefinitionKind::LaunchdPlist,
1638            text: launchd_plist(plan),
1639            install_path,
1640        }
1641    }
1642
1643    /// Wraps text this module did not render.
1644    ///
1645    /// Two callers need it and both matter. A test builds a deliberately
1646    /// widened definition and watches [`review_least_privilege`] reject it —
1647    /// without which the review would be a function nobody had ever seen fail.
1648    /// And `service status` can review the definition **actually on disk**,
1649    /// which on Linux and macOS an operator is free to edit after installation.
1650    #[must_use]
1651    pub fn from_text(kind: DefinitionKind, text: impl Into<String>) -> Self {
1652        Self {
1653            kind,
1654            text: text.into(),
1655            install_path: None,
1656        }
1657    }
1658
1659    /// A systemd unit.
1660    ///
1661    /// `home` is only consulted for `--start-at login`, where the unit belongs
1662    /// in the operator's own `~/.config/systemd/user`.
1663    #[must_use]
1664    pub fn systemd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1665        let file = plan.identity().systemd_unit();
1666        let install_path = match plan.start_mode() {
1667            StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
1668            StartMode::Login => home.map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file)),
1669        };
1670        Self {
1671            kind: DefinitionKind::SystemdUnit,
1672            text: systemd_unit(plan),
1673            install_path,
1674        }
1675    }
1676}
1677
1678/// Where a LaunchDaemon lives.
1679pub const LAUNCH_DAEMONS_DIR: &str = "/Library/LaunchDaemons";
1680/// Where a LaunchAgent lives, under the operator's home directory.
1681pub const LAUNCH_AGENTS_SUBDIR: &str = "Library/LaunchAgents";
1682/// Where a systemd system unit lives.
1683pub const SYSTEMD_SYSTEM_DIR: &str = "/etc/systemd/system";
1684/// Where a systemd user unit lives, under the operator's home directory.
1685pub const SYSTEMD_USER_SUBDIR: &str = ".config/systemd/user";
1686
1687/// The product's own page, cited by every definition so an operator who finds
1688/// one on a host can find out what put it there.
1689const DOCUMENTATION: &str = "https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI";
1690
1691/// How many consecutive failures the platform tolerates before it stops
1692/// retrying.
1693///
1694/// The companion to [`RestartPolicy::reset_after`]: the delay bounds how *fast*
1695/// a restart may come, and this bounds how *many* come before the host gives
1696/// up and leaves the failure visible instead of hiding it behind an endless
1697/// retry.
1698pub const START_LIMIT_BURST: u32 = 5;
1699
1700// -- systemd -----------------------------------------------------------------
1701
1702/// Renders the systemd unit for this plan.
1703///
1704/// # Why the sandbox is this tight, and what it costs
1705///
1706/// `05-infrastructure.md` item 2 ends *"and write its configured cache and
1707/// runtime directories — and no more"*. `ProtectSystem=strict` plus an explicit
1708/// `ReadWritePaths` is what "and no more" means on Linux: everything outside the
1709/// recorded application-data directories and the credential store directory
1710/// is read-only to this unit. The latter must be writable because refresh-token
1711/// rotation atomically replaces the credential file.
1712///
1713/// **The runner inherits it.** The agent spawns the GitHub Actions runner as a
1714/// child, so a workflow running on this host also runs inside this sandbox: it
1715/// cannot write outside those directories and its private `/tmp`, and
1716/// `NoNewPrivileges=yes` means it cannot `sudo`. `07-security.md` assumes a
1717/// hostile workflow may run here, so that is the intended direction — but it is
1718/// a real behavioural limit and `docs/service-account.md` states it where an
1719/// operator will find it.
1720#[must_use]
1721pub fn systemd_unit(plan: &InstallPlan) -> String {
1722    let identity = plan.identity();
1723    let restart = plan.restart();
1724    let directories = plan.directories();
1725
1726    let mut out = String::new();
1727    out.push_str("[Unit]\n");
1728    out.push_str(&format!("Description={}\n", identity.display_name()));
1729    out.push_str(&format!("Documentation={DOCUMENTATION}\n"));
1730    out.push_str("After=network-online.target\n");
1731    out.push_str("Wants=network-online.target\n");
1732    // In `[Unit]` rather than `[Service]`: systemd moved these in v229 and only
1733    // accepts them here without a deprecation warning.
1734    out.push_str(&format!(
1735        "StartLimitIntervalSec={}\n",
1736        restart.reset_after().as_secs()
1737    ));
1738    out.push_str(&format!("StartLimitBurst={START_LIMIT_BURST}\n"));
1739
1740    out.push_str("\n[Service]\n");
1741    out.push_str("Type=simple\n");
1742    // A runner is a child of the daemon. In particular, systemd must not
1743    // signal it merely because a cooperative daemon handover replaces the
1744    // manager process; startup recovery supervises any survivor instead.
1745    out.push_str("KillMode=process\n");
1746    out.push_str(&format!("ExecStart={}\n", plan.command_line()));
1747    out.push_str(&format!(
1748        "WorkingDirectory={}\n",
1749        directories.state.display()
1750    ));
1751    out.push_str(&format!("SyslogIdentifier={identity}\n"));
1752    out.push_str("Restart=on-failure\n");
1753    out.push_str(&format!("RestartSec={}\n", restart.delay().as_secs()));
1754
1755    out.push_str("\n# Least privilege. See docs/service-account.md.\n");
1756    for directive in systemd_hardening(plan.start_mode()) {
1757        out.push_str(directive);
1758        out.push('\n');
1759    }
1760    let quoted = |paths: &[String]| {
1761        paths
1762            .iter()
1763            .map(|path| quote_argument(path))
1764            .collect::<Vec<_>>()
1765            .join(" ")
1766    };
1767    out.push_str(&format!(
1768        "ReadWritePaths={}\n",
1769        quoted(&permitted_paths(DefinitionKind::SystemdUnit, plan))
1770    ));
1771    if !plan.protected_paths().is_empty() {
1772        out.push_str(&format!(
1773            "InaccessiblePaths={}\n",
1774            inaccessible_entries(plan).join(" ")
1775        ));
1776    }
1777
1778    out.push_str("\n[Install]\n");
1779    out.push_str(match plan.start_mode() {
1780        StartMode::Boot => "WantedBy=multi-user.target\n",
1781        StartMode::Login => "WantedBy=default.target\n",
1782    });
1783    out
1784}
1785
1786/// The hardening directives every unit carries, in the order they are rendered.
1787///
1788/// One list, used by [`systemd_hardening`], so that [`review_least_privilege`]
1789/// is written against the same directives the renderer emits: a directive
1790/// dropped from the unit is one the review then reports as missing, rather
1791/// than one that silently stops being checked.
1792///
1793/// The capability bounding set listed here is a login unit's: empty. A boot
1794/// unit's is [`BOOT_CAPABILITIES`]; see [`systemd_hardening`].
1795///
1796/// # Why not `RestrictSUIDSGID=yes`
1797///
1798/// Its seccomp filter cannot inspect `openat2`'s flags, so it answers every
1799/// `openat2` with `ENOSYS`, expecting a fallback to `openat`. GNU tar 1.35
1800/// extracts through `openat2(..., RESOLVE_BENEATH)` and has none, so every
1801/// extraction in a job failed with "Cannot open: Function not implemented" --
1802/// and the runner unpacks every action it downloads with `tar`. Measured on
1803/// Ubuntu 26.04 with systemd 259. What the directive guarded against is
1804/// covered without it: `NoNewPrivileges=yes` makes a set-user-ID bit inert for
1805/// everything this unit starts, and the only places it can write are `root`'s
1806/// own `0700` directories and its private `/tmp`, which no other account can
1807/// reach to execute a file left there.
1808const SYSTEMD_HARDENING: [&str; 12] = [
1809    "NoNewPrivileges=yes",
1810    "CapabilityBoundingSet=",
1811    "AmbientCapabilities=",
1812    "PrivateTmp=yes",
1813    "PrivateDevices=yes",
1814    "ProtectSystem=strict",
1815    "ProtectKernelTunables=yes",
1816    "ProtectKernelModules=yes",
1817    "ProtectControlGroups=yes",
1818    "RestrictNamespaces=yes",
1819    "RestrictRealtime=yes",
1820    "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1821];
1822
1823/// The capabilities a boot unit's daemon keeps, all for one purpose: running
1824/// its runners as accounts of a [`crate::runner_account::RunnerPool`] rather than as
1825/// itself.
1826///
1827/// - `CAP_SETUID`, `CAP_SETGID`: to start a runner as that account.
1828/// - `CAP_KILL`: to signal the runner's processes, which are no longer its own.
1829/// - `CAP_CHOWN`: to hand the runner its attempt directory and caches.
1830/// - `CAP_DAC_OVERRIDE`, `CAP_FOWNER`: to clean up what the runner left, and
1831///   to prune caches, though the runner's account owns those files.
1832///
1833/// None reaches a job. Moving from `root` to another uid clears the permitted
1834/// and effective sets, the ambient set is empty, and `NoNewPrivileges=yes`
1835/// stops a set-user-ID or file-capability binary from granting any back.
1836pub const BOOT_CAPABILITIES: &str = boot_capabilities!();
1837
1838/// The one spelling of [`BOOT_CAPABILITIES`], so that the directive the unit
1839/// carries and the list the review checks it against cannot drift apart.
1840macro_rules! boot_capabilities {
1841    () => {
1842        "CAP_CHOWN CAP_DAC_OVERRIDE CAP_FOWNER CAP_KILL CAP_SETGID CAP_SETUID"
1843    };
1844}
1845use boot_capabilities;
1846
1847/// The hardening directives a unit started in `mode` carries.
1848///
1849/// A boot unit keeps [`BOOT_CAPABILITIES`] for its daemon, and hides every
1850/// home directory behind an empty `tmpfs`: its
1851/// data lives outside them, and a workflow running as `root` has no business
1852/// reading the files there that DAC leaves world-readable. `tmpfs` rather than
1853/// `yes`, because tools such as `git` treat an *unreadable* `$HOME/.gitconfig`
1854/// as fatal and an absent one as nothing.
1855fn systemd_hardening(mode: StartMode) -> Vec<&'static str> {
1856    let mut directives = SYSTEMD_HARDENING.to_vec();
1857    if mode == StartMode::Boot {
1858        if let Some(directive) = directives
1859            .iter_mut()
1860            .find(|directive| **directive == "CapabilityBoundingSet=")
1861        {
1862            *directive = concat!("CapabilityBoundingSet=", boot_capabilities!());
1863        }
1864        directives.push("ProtectHome=tmpfs");
1865    }
1866    directives
1867}
1868
1869/// `InaccessiblePaths=` entries for `plan`'s protected paths, each prefixed
1870/// with `-` so that a path that does not exist yet is not an error.
1871fn inaccessible_entries(plan: &InstallPlan) -> Vec<String> {
1872    plan.protected_paths()
1873        .iter()
1874        .map(|path| quote_argument(&format!("-{}", path.to_string_lossy())))
1875        .collect()
1876}
1877
1878/// Host paths no runner or job may reach, which the operator names with
1879/// `host protect-path`.
1880///
1881/// On Linux the systemd unit lists them in `InaccessiblePaths=`, so inside the
1882/// unit the kernel replaces each with a node of mode `000`: no runner or job,
1883/// in any pool account, reaches it, whatever its own permissions. Measured on
1884/// Ubuntu 26.04: `rm-runner-1` reads such a file on the host and gets
1885/// `Permission denied` inside the unit. The
1886/// list is read when the service is registered, so a change takes effect when
1887/// it is registered again. Windows and macOS have no equivalent the installer
1888/// can set, so there it is refused rather than written and silently ignored.
1889#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
1890#[serde(deny_unknown_fields)]
1891pub struct ProtectedPaths {
1892    #[serde(default)]
1893    pub paths: Vec<PathBuf>,
1894}
1895
1896impl ProtectedPaths {
1897    /// The file in the configuration directory.
1898    pub const FILE: &'static str = "protected-paths.json";
1899
1900    /// Whether this platform can enforce protected paths.
1901    #[must_use]
1902    pub const fn supported() -> bool {
1903        cfg!(all(unix, not(target_os = "macos")))
1904    }
1905
1906    /// The list in `config_dir`, empty when there is none.
1907    ///
1908    /// # Errors
1909    ///
1910    /// When the file cannot be read or does not parse.
1911    pub fn load(config_dir: &Path) -> std::io::Result<Self> {
1912        crate::settings_file::load(&config_dir.join(Self::FILE))
1913    }
1914
1915    /// Writes the list into `config_dir`.
1916    ///
1917    /// # Errors
1918    ///
1919    /// When the file cannot be written.
1920    pub fn save(&self, config_dir: &Path) -> std::io::Result<()> {
1921        crate::settings_file::save(&config_dir.join(Self::FILE), self)
1922    }
1923
1924    /// Why `path` cannot be protected, or `None` when it can.
1925    ///
1926    /// The paths every job needs to run at all, and the service's own
1927    /// directories, are refused: hiding them would stop every runner rather
1928    /// than keep jobs out of something.
1929    #[must_use]
1930    pub fn refusal(path: &Path, own: &[PathBuf]) -> Option<String> {
1931        const SYSTEM: [&str; 16] = [
1932            "/", "/bin", "/boot", "/dev", "/etc", "/lib", "/lib32", "/lib64", "/opt", "/proc",
1933            "/run", "/sbin", "/sys", "/tmp", "/usr", "/var",
1934        ];
1935        if !path.is_absolute()
1936            || path
1937                .components()
1938                .any(|component| matches!(component, std::path::Component::ParentDir))
1939        {
1940            return Some(format!(
1941                "{} is not an absolute path without `..`",
1942                path.display()
1943            ));
1944        }
1945        if SYSTEM.iter().any(|system| path == Path::new(system)) {
1946            return Some(format!(
1947                "{} is one every job needs to run; protect a directory inside it instead",
1948                path.display()
1949            ));
1950        }
1951        if let Some(needed) = own
1952            .iter()
1953            .find(|needed| needed.starts_with(path) || path.starts_with(needed))
1954        {
1955            return Some(format!(
1956                "{} holds or lies inside {}, which the service itself uses",
1957                path.display(),
1958                needed.display()
1959            ));
1960        }
1961        None
1962    }
1963}
1964
1965/// The trees `ProtectHome=` hides, which a boot unit's directories must
1966/// therefore lie outside.
1967#[cfg(any(test, all(unix, not(target_os = "macos"))))]
1968const SYSTEMD_HOME_ROOTS: [&str; 3] = ["/home", "/root", "/run/user"];
1969
1970/// Renames a non-empty directory at `path` to `<name>.before-move-<stamp>`
1971/// beside it, leaving nothing at `path`.
1972#[cfg(any(all(test, unix), target_os = "linux"))]
1973fn set_aside(path: &Path, stamp: &str) -> std::io::Result<()> {
1974    let occupied = match std::fs::read_dir(path) {
1975        Ok(mut entries) => entries.next().is_some(),
1976        Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
1977        Err(error) => return Err(error),
1978    };
1979    if occupied {
1980        let name = path.file_name().map_or_else(
1981            || std::borrow::Cow::Borrowed("directory"),
1982            |name| name.to_string_lossy(),
1983        );
1984        std::fs::rename(
1985            path,
1986            path.with_file_name(format!("{name}.before-move-{stamp}")),
1987        )?;
1988    }
1989    Ok(())
1990}
1991
1992/// Copies the tree at `from` into `to`, except a top-level entry named `skip`.
1993/// Directories are created `0700`, files keep their mode, and symbolic links
1994/// are copied as links rather than followed.
1995#[cfg(any(all(test, unix), target_os = "linux"))]
1996fn copy_tree(from: &Path, to: &Path, skip: Option<&str>) -> std::io::Result<()> {
1997    use std::os::unix::fs::DirBuilderExt as _;
1998
1999    std::fs::DirBuilder::new()
2000        .recursive(true)
2001        .mode(0o700)
2002        .create(to)?;
2003    for entry in std::fs::read_dir(from)? {
2004        let entry = entry?;
2005        if skip.is_some_and(|skip| entry.file_name() == skip) {
2006            continue;
2007        }
2008        let source = entry.path();
2009        let target = to.join(entry.file_name());
2010        let kind = entry.file_type()?;
2011        if kind.is_symlink() {
2012            std::os::unix::fs::symlink(std::fs::read_link(&source)?, &target)?;
2013        } else if kind.is_dir() {
2014            copy_tree(&source, &target, None)?;
2015        } else {
2016            std::fs::copy(&source, &target)?;
2017        }
2018    }
2019    Ok(())
2020}
2021
2022/// Why a boot unit could not use `path`, owned by `owner`, or `None` when it
2023/// can.
2024///
2025/// A boot unit runs as `root` with no capability at all, so a directory
2026/// another account owns is one it cannot open, and one under a home tree is
2027/// one its `ProtectHome=` hides.
2028#[cfg(any(test, all(unix, not(target_os = "macos"))))]
2029fn boot_directory_refusal(path: &Path, owner: Option<u32>) -> Option<String> {
2030    if let Some(root) = SYSTEMD_HOME_ROOTS
2031        .iter()
2032        .find(|root| path.starts_with(root))
2033    {
2034        return Some(format!(
2035            "{} is under {root}, which a boot service cannot see",
2036            path.display()
2037        ));
2038    }
2039    match owner {
2040        Some(0) => None,
2041        Some(uid) => Some(format!(
2042            "{} belongs to uid {uid}, and a boot service runs as root with no capability \
2043             to open another account's directory",
2044            path.display()
2045        )),
2046        None => Some(format!("{} cannot be inspected", path.display())),
2047    }
2048}
2049
2050// -- launchd -----------------------------------------------------------------
2051
2052/// The `ProcessType` every rendered plist carries.
2053///
2054/// # Why not `Background`
2055///
2056/// launchd applies a job's `ProcessType` to the whole process tree it starts,
2057/// not only to the daemon. Under `Background` every runner the daemon launched
2058/// ran at base priority 4, on efficiency cores with throttled I/O, so each job
2059/// took 4 to 20 times as long as on a classic runner.
2060///
2061/// A child cannot leave it. This is the process type launchd chose at spawn
2062/// (`spawn type = background (5)` in `launchctl print`), not the
2063/// `PRIO_DARWIN_BG` flag `setpriority` controls: measured on macOS 27, a child
2064/// of a `Background` agent reads `getpriority(PRIO_DARWIN_PROCESS) == 0`, and
2065/// `setpriority(PRIO_DARWIN_PROCESS, 0, 0)` returns success and leaves it at
2066/// 4, before `exec` and after it alike. 0.4.31 tried exactly that.
2067///
2068/// The measured alternatives, as the base priority of a runner the agent
2069/// starts: `Background` 4, `Adaptive` 4, `Standard` (the default when the key
2070/// is absent) 20, `Interactive` 31 (the same as a classic runner, and the value
2071/// GitHub's own runner service template uses). The daemon itself mostly waits,
2072/// so it costs nothing to run it at that priority too.
2073pub const LAUNCHD_PROCESS_TYPE: &str = "Interactive";
2074
2075/// Renders the launchd property list for this plan.
2076///
2077/// `KeepAlive` is a dictionary rather than `<true/>` on purpose: the
2078/// requirement is *restart on failure*, and a bare `KeepAlive` also restarts a
2079/// job that exited cleanly, which would turn a deliberate `service stop` into a
2080/// fight with launchd.
2081#[must_use]
2082pub fn launchd_plist(plan: &InstallPlan) -> String {
2083    let identity = plan.identity();
2084    let directories = plan.directories();
2085    let mut out = String::new();
2086    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
2087    out.push_str(
2088        "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
2089         \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
2090    );
2091    out.push_str("<plist version=\"1.0\">\n<dict>\n");
2092    out.push_str(&plist_string("Label", &identity.launchd_label()));
2093
2094    out.push_str("  <key>ProgramArguments</key>\n  <array>\n");
2095    out.push_str(&format!(
2096        "    <string>{}</string>\n",
2097        xml_escape(&plan.binary().to_string_lossy())
2098    ));
2099    for argument in plan.arguments() {
2100        out.push_str(&format!(
2101            "    <string>{}</string>\n",
2102            xml_escape(&argument.to_string_lossy())
2103        ));
2104    }
2105    out.push_str("  </array>\n");
2106
2107    out.push_str("  <key>RunAtLoad</key>\n  <true/>\n");
2108    out.push_str("  <key>KeepAlive</key>\n  <dict>\n");
2109    out.push_str("    <key>SuccessfulExit</key>\n    <false/>\n");
2110    out.push_str("  </dict>\n");
2111    out.push_str(&format!(
2112        "  <key>ThrottleInterval</key>\n  <integer>{}</integer>\n",
2113        plan.restart().delay().as_secs()
2114    ));
2115    out.push_str(&plist_string("ProcessType", LAUNCHD_PROCESS_TYPE));
2116    out.push_str(&plist_string(
2117        "WorkingDirectory",
2118        &directories.state.to_string_lossy(),
2119    ));
2120    out.push_str(&plist_string(
2121        "StandardOutPath",
2122        &directories
2123            .logs
2124            .join("runner-manager.launchd.out.log")
2125            .to_string_lossy(),
2126    ));
2127    out.push_str(&plist_string(
2128        "StandardErrorPath",
2129        &directories
2130            .logs
2131            .join("runner-manager.launchd.err.log")
2132            .to_string_lossy(),
2133    ));
2134
2135    match plan.start_mode() {
2136        StartMode::Boot => {
2137            // A LaunchDaemon runs as root unless told otherwise, and here it
2138            // must: the System Keychain's master key is root-only.
2139            out.push_str(&plist_string(
2140                "UserName",
2141                ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
2142                    .as_str(),
2143            ));
2144            // A daemon has no user session and must not be given one.
2145            out.push_str("  <key>SessionCreate</key>\n  <false/>\n");
2146        }
2147        StartMode::Login => {
2148            // A LaunchAgent already runs as the operator. Naming a `UserName`
2149            // here would be asking launchd for an account switch a login-mode
2150            // registration has no reason to want.
2151        }
2152    }
2153
2154    out.push_str("</dict>\n</plist>\n");
2155    out
2156}
2157
2158/// One `<key>`/`<string>` pair, indented as the rest of the plist is.
2159fn plist_string(key: &str, value: &str) -> String {
2160    format!(
2161        "  <key>{}</key>\n  <string>{}</string>\n",
2162        xml_escape(key),
2163        xml_escape(value)
2164    )
2165}
2166
2167// -- Windows Task Scheduler --------------------------------------------------
2168
2169/// The account a Task Scheduler task runs as.
2170///
2171/// Task Scheduler, unlike the other three managers, needs the principal spelled
2172/// out in the document. Isolating the one impure step keeps
2173/// [`windows_scheduled_task_xml`] a pure function that every CI leg can assert.
2174#[derive(Debug, Clone, PartialEq, Eq)]
2175pub struct TaskPrincipal {
2176    user_id: String,
2177}
2178
2179impl TaskPrincipal {
2180    /// The account running this call, as `DOMAIN\user`.
2181    ///
2182    /// # Errors
2183    ///
2184    /// [`ServiceError::BinaryPath`] is not the right shape here, so this
2185    /// reports [`ServiceError::Control`] naming what the environment failed to
2186    /// say.
2187    pub fn current() -> Result<Self, ServiceError> {
2188        let user = std::env::var("USERNAME")
2189            .ok()
2190            .filter(|value| !value.trim().is_empty());
2191        let Some(user) = user else {
2192            return Err(ServiceError::Control {
2193                operation: "identify the account for",
2194                name: SERVICE_NAME.to_string(),
2195                manager: "Windows Task Scheduler",
2196                detail: "this session reports no %USERNAME%, so there is no principal to \
2197                         register a logon-triggered task for"
2198                    .to_string(),
2199            });
2200        };
2201        let domain = std::env::var("USERDOMAIN")
2202            .ok()
2203            .filter(|value| !value.trim().is_empty());
2204        Ok(Self {
2205            user_id: match domain {
2206                Some(domain) => format!("{domain}\\{user}"),
2207                None => user,
2208            },
2209        })
2210    }
2211
2212    /// A named principal, for tests and for a caller that already knows.
2213    #[must_use]
2214    pub fn named(user_id: impl Into<String>) -> Self {
2215        Self {
2216            user_id: user_id.into(),
2217        }
2218    }
2219
2220    /// `DOMAIN\user`.
2221    #[must_use]
2222    pub fn user_id(&self) -> &str {
2223        &self.user_id
2224    }
2225}
2226
2227/// Renders the Task Scheduler document for a `--start-at login` registration.
2228///
2229/// `RunLevel` is `LeastPrivilege`, which is the whole of Windows' answer to
2230/// item 2 in this domain: the task runs with the operator's ordinary filtered
2231/// token and never with an elevated one, whatever the operator's group
2232/// membership.
2233#[must_use]
2234pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
2235    let identity = plan.identity();
2236    let user = xml_escape(principal.user_id());
2237    let arguments = plan
2238        .arguments()
2239        .iter()
2240        .map(|argument| quote_argument(&argument.to_string_lossy()))
2241        .collect::<Vec<_>>()
2242        .join(" ");
2243    let mut out = String::new();
2244    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
2245    out.push_str(
2246        "<Task version=\"1.4\" \
2247         xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
2248    );
2249    out.push_str("  <RegistrationInfo>\n");
2250    out.push_str(&format!(
2251        "    <Description>{}</Description>\n",
2252        xml_escape(identity.description())
2253    ));
2254    out.push_str(&format!(
2255        "    <URI>\\{}</URI>\n",
2256        xml_escape(identity.name())
2257    ));
2258    out.push_str("  </RegistrationInfo>\n");
2259
2260    out.push_str("  <Triggers>\n    <LogonTrigger>\n");
2261    out.push_str("      <Enabled>true</Enabled>\n");
2262    out.push_str(&format!("      <UserId>{user}</UserId>\n"));
2263    out.push_str("    </LogonTrigger>\n  </Triggers>\n");
2264
2265    out.push_str("  <Principals>\n    <Principal id=\"Author\">\n");
2266    out.push_str(&format!("      <UserId>{user}</UserId>\n"));
2267    out.push_str("      <LogonType>InteractiveToken</LogonType>\n");
2268    out.push_str("      <RunLevel>LeastPrivilege</RunLevel>\n");
2269    out.push_str("    </Principal>\n  </Principals>\n");
2270
2271    out.push_str("  <Settings>\n");
2272    // One agent per host is `d1`'s lock; saying so here means Task Scheduler
2273    // refuses the second start rather than starting a process that then loses
2274    // the race and exits.
2275    out.push_str("    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
2276    out.push_str("    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
2277    out.push_str("    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
2278    out.push_str("    <AllowHardTerminate>true</AllowHardTerminate>\n");
2279    out.push_str("    <StartWhenAvailable>true</StartWhenAvailable>\n");
2280    out.push_str("    <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
2281    out.push_str("    <IdleSettings>\n");
2282    out.push_str("      <StopOnIdleEnd>false</StopOnIdleEnd>\n");
2283    out.push_str("      <RestartOnIdle>false</RestartOnIdle>\n");
2284    out.push_str("    </IdleSettings>\n");
2285    out.push_str("    <AllowStartOnDemand>true</AllowStartOnDemand>\n");
2286    out.push_str("    <Enabled>true</Enabled>\n");
2287    out.push_str("    <Hidden>false</Hidden>\n");
2288    out.push_str("    <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
2289    out.push_str("    <WakeToRun>false</WakeToRun>\n");
2290    // A daemon has no natural end, so any limit here would be a scheduled kill.
2291    out.push_str("    <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
2292    out.push_str("    <Priority>7</Priority>\n");
2293    out.push_str("    <RestartOnFailure>\n");
2294    out.push_str(&format!(
2295        "      <Interval>{}</Interval>\n",
2296        iso8601_minutes(
2297            plan.restart()
2298                .effective_delay(DefinitionKind::WindowsScheduledTask)
2299        )
2300    ));
2301    out.push_str(&format!("      <Count>{START_LIMIT_BURST}</Count>\n"));
2302    out.push_str("    </RestartOnFailure>\n");
2303    out.push_str("  </Settings>\n");
2304
2305    out.push_str("  <Actions Context=\"Author\">\n    <Exec>\n");
2306    out.push_str(&format!(
2307        "      <Command>{}</Command>\n",
2308        xml_escape(&plan.binary().to_string_lossy())
2309    ));
2310    if !arguments.is_empty() {
2311        out.push_str(&format!(
2312            "      <Arguments>{}</Arguments>\n",
2313            xml_escape(&arguments)
2314        ));
2315    }
2316    out.push_str(&format!(
2317        "      <WorkingDirectory>{}</WorkingDirectory>\n",
2318        xml_escape(&plan.directories().state.to_string_lossy())
2319    ));
2320    out.push_str("    </Exec>\n  </Actions>\n");
2321    out.push_str("</Task>\n");
2322    out
2323}
2324
2325/// `PT<n>M`, which is the only shape Task Scheduler accepts for a restart
2326/// interval. See [`RestartPolicy::effective_delay`].
2327fn iso8601_minutes(duration: Duration) -> String {
2328    format!("PT{}M", duration.as_secs() / 60)
2329}
2330
2331/// Escapes the five XML entities. Applied to every value that reaches a plist
2332/// or a task document, because a Windows account name may legitimately contain
2333/// `&` and a path may contain `<`.
2334///
2335/// `pub(crate)` rather than private because [`crate::wsl::task`] renders a
2336/// second kind of Task Scheduler document and must escape it the same way; two
2337/// escapers in one crate is exactly how one of them ends up subtly different.
2338pub(crate) fn xml_escape(value: &str) -> String {
2339    let mut out = String::with_capacity(value.len());
2340    for c in value.chars() {
2341        match c {
2342            '&' => out.push_str("&amp;"),
2343            '<' => out.push_str("&lt;"),
2344            '>' => out.push_str("&gt;"),
2345            '"' => out.push_str("&quot;"),
2346            '\'' => out.push_str("&apos;"),
2347            other => out.push(other),
2348        }
2349    }
2350    out
2351}
2352
2353/// The inverse of [`xml_escape`], for reading a definition back off disk.
2354///
2355/// `&amp;` is replaced last, which is the whole subtlety: replacing it first
2356/// would turn a literal `&amp;amp;` into `&` in two passes instead of `&amp;`
2357/// in one.
2358fn xml_unescape(value: &str) -> String {
2359    value
2360        .replace("&lt;", "<")
2361        .replace("&gt;", ">")
2362        .replace("&quot;", "\"")
2363        .replace("&apos;", "'")
2364        .replace("&amp;", "&")
2365}
2366
2367// -- Windows Service Control Manager -----------------------------------------
2368
2369/// How `CreateServiceW` should be called, in a form that can be reviewed,
2370/// asserted, and printed on any platform.
2371///
2372/// The Windows backend builds its `ServiceInfo` from **this** value rather than
2373/// from the plan, so the descriptor a Linux CI leg asserts and the parameters a
2374/// Windows host registers cannot drift apart.
2375#[derive(Debug, Clone, PartialEq, Eq)]
2376pub struct WindowsServiceSpec {
2377    /// The service name.
2378    pub name: String,
2379    /// What `services.msc` shows.
2380    pub display_name: String,
2381    /// The one-line description.
2382    pub description: String,
2383    /// `true` for `SERVICE_AUTO_START`, `false` for `SERVICE_DEMAND_START`.
2384    pub automatic_start: bool,
2385    /// The account, or `None` for `LocalSystem`, which is what
2386    /// `CreateServiceW` takes a null `lpServiceStartName` to mean.
2387    pub account: Option<String>,
2388    /// The full launch command, quoted.
2389    pub command_line: String,
2390    /// The restart-on-failure policy.
2391    pub restart: RestartPolicy,
2392}
2393
2394/// Derives the Windows service parameters from a plan.
2395#[must_use]
2396pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
2397    WindowsServiceSpec {
2398        name: plan.identity().name().to_string(),
2399        display_name: plan.identity().display_name().to_string(),
2400        description: plan.identity().description().to_string(),
2401        // Only a boot-mode registration is a service at all; a login-mode one
2402        // is a scheduled task. So an automatic start is the only kind here, and
2403        // the field exists because the privileged tests register an on-demand
2404        // fixture rather than one that starts with the test machine.
2405        automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
2406        account: match ServiceAccount::for_definition(
2407            DefinitionKind::WindowsService,
2408            plan.start_mode(),
2409        ) {
2410            // `None` means LocalSystem to `CreateServiceW`, and naming it
2411            // explicitly would be one more string to spell right.
2412            ServiceAccount::LocalSystem => None,
2413            other => Some(other.as_str().to_string()),
2414        },
2415        command_line: plan.command_line(),
2416        restart: plan.restart(),
2417    }
2418}
2419
2420/// Renders the descriptor [`review_least_privilege`] reads and `service status`
2421/// can print.
2422#[must_use]
2423fn windows_service_descriptor(plan: &InstallPlan) -> String {
2424    let spec = windows_service_spec(plan);
2425    let mut out = String::new();
2426    out.push_str("[windows-service]\n");
2427    out.push_str(&format!("Name={}\n", spec.name));
2428    out.push_str(&format!("DisplayName={}\n", spec.display_name));
2429    out.push_str(&format!("Description={}\n", spec.description));
2430    // OWN_PROCESS and never INTERACTIVE_PROCESS: an interactive service would
2431    // put a process this product controls on the operator's desktop, which is
2432    // both deprecated by Windows and more than the requirement asks for.
2433    out.push_str("ServiceType=OWN_PROCESS\n");
2434    out.push_str(&format!(
2435        "StartType={}\n",
2436        if spec.automatic_start {
2437            "AutoStart"
2438        } else {
2439            "OnDemand"
2440        }
2441    ));
2442    out.push_str("ErrorControl=Normal\n");
2443    out.push_str(&format!(
2444        "Account={}\n",
2445        spec.account
2446            .as_deref()
2447            .unwrap_or(ServiceAccount::LocalSystem.as_str())
2448    ));
2449    out.push_str(&format!("CommandLine={}\n", spec.command_line));
2450    out.push_str(&format!(
2451        "FailureActionRestartDelaySecs={}\n",
2452        spec.restart.delay().as_secs()
2453    ));
2454    out.push_str(&format!(
2455        "FailureActionsResetPeriodSecs={}\n",
2456        spec.restart.reset_after().as_secs()
2457    ));
2458    // Without this flag the Service Control Manager applies failure actions
2459    // only to a crash, and a daemon that exits non-zero after failing to reach
2460    // GitHub is not a crash.
2461    out.push_str("FailureActionsOnNonCrashFailures=true\n");
2462    out.push_str(&format!(
2463        "ReadWritePaths={}\n",
2464        plan.directories()
2465            .all()
2466            .iter()
2467            .map(|path| quote_argument(&path.to_string_lossy()))
2468            .collect::<Vec<_>>()
2469            .join(" ")
2470    ));
2471    out
2472}
2473
2474// ---------------------------------------------------------------------------
2475// The least-privilege review
2476// ---------------------------------------------------------------------------
2477
2478/// Whether a finding is about too much authority or too little.
2479#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2480pub enum FindingKind {
2481    /// The definition grants more than `05-infrastructure.md` item 2 asks for.
2482    /// This is what makes a review fail.
2483    Excess,
2484    /// The definition does not grant something the daemon needs, or does not
2485    /// state something the review cannot verify without.
2486    Shortfall,
2487}
2488
2489impl fmt::Display for FindingKind {
2490    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2491        f.write_str(match self {
2492            Self::Excess => "excess",
2493            Self::Shortfall => "shortfall",
2494        })
2495    }
2496}
2497
2498/// One thing the review has to say about a definition.
2499#[derive(Debug, Clone, PartialEq, Eq)]
2500pub struct PrivilegeFinding {
2501    /// Excess or shortfall.
2502    pub kind: FindingKind,
2503    /// The directive, key, or element it is about.
2504    pub subject: String,
2505    /// What is wrong with it, in terms an operator can act on.
2506    pub detail: String,
2507}
2508
2509impl fmt::Display for PrivilegeFinding {
2510    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2511        write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2512    }
2513}
2514
2515/// What a definition actually grants, measured against the requirement.
2516///
2517/// `05-infrastructure.md` item 2 and `07-security.md`'s release gate
2518/// (*"Service account permissions are documented and verified least
2519/// privilege"*) are the same requirement stated twice, and this is the
2520/// verification half. The documentation half is `docs/service-account.md`.
2521///
2522/// The review reads the **rendered text**, not the plan it came from. That is
2523/// deliberate and it is the only version of this check worth having: a review
2524/// that re-derived its expectations from the same values the renderer used
2525/// would agree with the renderer by construction and could never fail. Reading
2526/// the text means a hand-edited unit on a real host is reviewed as it is, and
2527/// means a test can widen a definition and watch this say so.
2528#[derive(Debug, Clone, PartialEq, Eq)]
2529pub struct PrivilegeReview {
2530    kind: DefinitionKind,
2531    account: ServiceAccount,
2532    controls: Vec<String>,
2533    findings: Vec<PrivilegeFinding>,
2534}
2535
2536impl PrivilegeReview {
2537    /// Whether the definition grants nothing beyond the requirement.
2538    #[must_use]
2539    pub fn is_least_privilege(&self) -> bool {
2540        !self
2541            .findings
2542            .iter()
2543            .any(|finding| finding.kind == FindingKind::Excess)
2544    }
2545
2546    /// Everything the review found, excesses and shortfalls together.
2547    #[must_use]
2548    pub fn findings(&self) -> &[PrivilegeFinding] {
2549        &self.findings
2550    }
2551
2552    /// Only the excesses — the findings that make [`Self::is_least_privilege`]
2553    /// false.
2554    #[must_use]
2555    pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2556        self.findings
2557            .iter()
2558            .filter(|finding| finding.kind == FindingKind::Excess)
2559            .collect()
2560    }
2561
2562    /// The controls the definition was confirmed to carry.
2563    ///
2564    /// Present so that a passing review says *what it checked* rather than only
2565    /// that it passed. A check that reports nothing when it succeeds is
2566    /// indistinguishable from a check that did not run.
2567    #[must_use]
2568    pub fn controls(&self) -> &[String] {
2569        &self.controls
2570    }
2571
2572    /// The account the registration runs under, and why it is the minimum.
2573    #[must_use]
2574    pub const fn account(&self) -> &ServiceAccount {
2575        &self.account
2576    }
2577
2578    /// Which platform's definition was reviewed.
2579    #[must_use]
2580    pub const fn kind(&self) -> DefinitionKind {
2581        self.kind
2582    }
2583}
2584
2585impl fmt::Display for PrivilegeReview {
2586    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2587        writeln!(
2588            f,
2589            "{} runs as {} ({})",
2590            self.kind,
2591            self.account,
2592            self.account.justification()
2593        )?;
2594        for control in &self.controls {
2595            writeln!(f, "  confirmed  {control}")?;
2596        }
2597        for finding in &self.findings {
2598            writeln!(f, "  {finding}")?;
2599        }
2600        if self.is_least_privilege() {
2601            write!(f, "  verdict    least privilege")
2602        } else {
2603            write!(
2604                f,
2605                "  verdict    NOT least privilege: {} excess(es)",
2606                self.excesses().len()
2607            )
2608        }
2609    }
2610}
2611
2612/// Reads a definition back and reports what it grants.
2613///
2614/// `plan` supplies only the two things the text cannot: the four directories
2615/// the registration is *allowed* to write, and the start mode, which decides
2616/// which account is the minimum.
2617#[must_use]
2618pub fn review_least_privilege(
2619    definition: &ServiceDefinition,
2620    plan: &InstallPlan,
2621) -> PrivilegeReview {
2622    let mut controls = Vec::new();
2623    let mut findings = Vec::new();
2624    match definition.kind() {
2625        DefinitionKind::SystemdUnit => {
2626            review_systemd(definition.text(), plan, &mut controls, &mut findings);
2627        }
2628        DefinitionKind::LaunchdPlist => {
2629            review_launchd(definition.text(), plan, &mut controls, &mut findings);
2630        }
2631        DefinitionKind::WindowsScheduledTask => {
2632            review_scheduled_task(definition.text(), &mut controls, &mut findings);
2633        }
2634        DefinitionKind::WindowsService => {
2635            review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2636        }
2637    }
2638    PrivilegeReview {
2639        kind: definition.kind(),
2640        // The definition's account, not the plan's: a Linux CI leg reviewing the
2641        // Windows descriptor must report `LocalSystem`, which is the account
2642        // that descriptor registers, and not `root`, which is the account this
2643        // leg's own host would use.
2644        account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2645        controls,
2646        findings,
2647    }
2648}
2649
2650/// Every writable path a definition may name, as the strings it names them by.
2651fn permitted_paths(kind: DefinitionKind, plan: &InstallPlan) -> Vec<String> {
2652    let mut permitted = plan
2653        .directories()
2654        .all()
2655        .iter()
2656        .map(|path| path.to_string_lossy().into_owned())
2657        .collect::<Vec<_>>();
2658    if kind == DefinitionKind::SystemdUnit
2659        && let Some(secret_directory) = plan.secret_guard().and_then(Path::parent)
2660    {
2661        permitted.push(secret_directory.to_string_lossy().into_owned());
2662    }
2663    permitted
2664}
2665
2666/// Whether two path spellings name the same place on **this host's**
2667/// filesystem. Case-insensitive on Windows, exact elsewhere.
2668///
2669/// For a rendered definition use [`same_path_for`] instead: which comparison is
2670/// right there follows the platform the definition is *for*, and every leg of
2671/// the CI matrix reviews all four.
2672fn same_path_text(left: &str, right: &str) -> bool {
2673    if cfg!(windows) {
2674        left.eq_ignore_ascii_case(right)
2675    } else {
2676        left == right
2677    }
2678}
2679
2680/// Whether two path spellings name the same place on the platform a given
2681/// definition targets.
2682fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
2683    match kind {
2684        DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
2685            left.eq_ignore_ascii_case(right)
2686        }
2687        DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
2688    }
2689}
2690
2691/// Checks a `ReadWritePaths`-style list against the permitted directories.
2692fn review_writable_paths(
2693    kind: DefinitionKind,
2694    subject: &str,
2695    listed: &[String],
2696    plan: &InstallPlan,
2697    controls: &mut Vec<String>,
2698    findings: &mut Vec<PrivilegeFinding>,
2699) {
2700    let permitted = permitted_paths(kind, plan);
2701    for entry in listed {
2702        if !permitted
2703            .iter()
2704            .any(|allowed| same_path_for(kind, allowed, entry))
2705        {
2706            findings.push(PrivilegeFinding {
2707                kind: FindingKind::Excess,
2708                subject: subject.to_string(),
2709                detail: format!(
2710                    "{entry} is writable but is not one of this registration's required paths"
2711                ),
2712            });
2713        }
2714    }
2715    for allowed in &permitted {
2716        if !listed
2717            .iter()
2718            .any(|entry| same_path_for(kind, allowed, entry))
2719        {
2720            findings.push(PrivilegeFinding {
2721                kind: FindingKind::Shortfall,
2722                subject: subject.to_string(),
2723                detail: format!(
2724                    "{allowed} is one of this registration's directories but is not writable, \
2725                     so the daemon cannot use it"
2726                ),
2727            });
2728        }
2729    }
2730    if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
2731        controls.push(format!(
2732            "{subject} names exactly the required application-data and credential paths"
2733        ));
2734    }
2735}
2736
2737/// Compares a unit's capability bounding set with the one it should keep, as
2738/// sets: a capability it lacks is a shortfall, one it adds is an excess.
2739fn review_capabilities(
2740    expected: &str,
2741    wanted: &str,
2742    actual: &str,
2743    controls: &mut Vec<String>,
2744    findings: &mut Vec<PrivilegeFinding>,
2745) {
2746    let subject = "CapabilityBoundingSet";
2747    // `~` inverts the list, keeping every capability *except* those named.
2748    if actual.trim_start().starts_with('~') {
2749        findings.push(PrivilegeFinding {
2750            kind: FindingKind::Excess,
2751            subject: subject.to_string(),
2752            detail: format!("is `{actual}`, which keeps every capability it does not name"),
2753        });
2754        return;
2755    }
2756    let wanted: BTreeSet<&str> = wanted.split_whitespace().collect();
2757    let actual: BTreeSet<&str> = actual.split_whitespace().collect();
2758    let extra = actual.difference(&wanted).copied().collect::<Vec<_>>();
2759    let missing = wanted.difference(&actual).copied().collect::<Vec<_>>();
2760    if !extra.is_empty() {
2761        findings.push(PrivilegeFinding {
2762            kind: FindingKind::Excess,
2763            subject: subject.to_string(),
2764            detail: format!(
2765                "keeps {}, which the requirement does not ask for",
2766                extra.join(" ")
2767            ),
2768        });
2769    }
2770    if !missing.is_empty() {
2771        findings.push(PrivilegeFinding {
2772            kind: FindingKind::Shortfall,
2773            subject: subject.to_string(),
2774            detail: format!("drops {}, which the daemon needs", missing.join(" ")),
2775        });
2776    }
2777    if extra.is_empty() && missing.is_empty() {
2778        controls.push(expected.to_string());
2779    }
2780}
2781
2782/// The one inbound-surface rule, applied to whichever platform names it.
2783///
2784/// `07-security.md` handling rule 2: *"The product exposes no inbound HTTP,
2785/// socket, or RPC surface anywhere, in any command."* A definition that asks the
2786/// service manager to open a socket or publish a Mach service on the daemon's
2787/// behalf would create exactly that surface without a line of product code
2788/// changing, which is why it is checked here and not only in review.
2789fn review_inbound_surface(
2790    text: &str,
2791    markers: &[(&str, &str)],
2792    controls: &mut Vec<String>,
2793    findings: &mut Vec<PrivilegeFinding>,
2794) {
2795    let mut clean = true;
2796    for (marker, detail) in markers {
2797        if text.contains(marker) {
2798            clean = false;
2799            findings.push(PrivilegeFinding {
2800                kind: FindingKind::Excess,
2801                subject: (*marker).to_string(),
2802                detail: (*detail).to_string(),
2803            });
2804        }
2805    }
2806    if clean {
2807        controls.push(
2808            "no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
2809        );
2810    }
2811}
2812
2813fn review_systemd(
2814    text: &str,
2815    plan: &InstallPlan,
2816    controls: &mut Vec<String>,
2817    findings: &mut Vec<PrivilegeFinding>,
2818) {
2819    let directives = ini_directives(text, "Service");
2820    for expected in systemd_hardening(plan.start_mode()) {
2821        let (key, value) = expected
2822            .split_once('=')
2823            .expect("every hardening directive is written as key=value");
2824        match directives.get(key) {
2825            Some(actual) if key == "CapabilityBoundingSet" => {
2826                review_capabilities(expected, value, actual, controls, findings);
2827            }
2828            Some(actual) if actual == value => controls.push((*expected).to_string()),
2829            Some(actual) => findings.push(PrivilegeFinding {
2830                kind: FindingKind::Excess,
2831                subject: key.to_string(),
2832                detail: format!(
2833                    "is `{actual}`, not `{value}`, so the unit keeps authority the \
2834                                 requirement does not ask for"
2835                ),
2836            }),
2837            None => findings.push(PrivilegeFinding {
2838                kind: FindingKind::Excess,
2839                subject: key.to_string(),
2840                detail: format!(
2841                    "is absent, so the unit inherits systemd's default rather than `{value}`"
2842                ),
2843            }),
2844        }
2845    }
2846
2847    match directives.get("ReadWritePaths") {
2848        Some(value) => {
2849            let listed = split_quoted(value);
2850            review_writable_paths(
2851                DefinitionKind::SystemdUnit,
2852                "ReadWritePaths",
2853                &listed,
2854                plan,
2855                controls,
2856                findings,
2857            );
2858        }
2859        None => findings.push(PrivilegeFinding {
2860            kind: FindingKind::Shortfall,
2861            subject: "ReadWritePaths".to_string(),
2862            detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
2863                .to_string(),
2864        }),
2865    }
2866
2867    // Each path the operator protected must stay inaccessible: a unit that no
2868    // longer lists one has let every job back into it.
2869    if !plan.protected_paths().is_empty() {
2870        let listed = directives
2871            .get("InaccessiblePaths")
2872            .map(|value| split_quoted(value))
2873            .unwrap_or_default();
2874        let missing = plan
2875            .protected_paths()
2876            .iter()
2877            .filter(|path| {
2878                let entry = format!("-{}", path.to_string_lossy());
2879                !listed.contains(&entry)
2880            })
2881            .map(|path| path.display().to_string())
2882            .collect::<Vec<_>>();
2883        if missing.is_empty() {
2884            controls.push("InaccessiblePaths keeps every protected path from every job".into());
2885        } else {
2886            findings.push(PrivilegeFinding {
2887                kind: FindingKind::Excess,
2888                subject: "InaccessiblePaths".to_string(),
2889                detail: format!("no longer hides {}", missing.join(", ")),
2890            });
2891        }
2892    }
2893
2894    // `User=` on a system unit is an escalation only in the other direction, so
2895    // what is checked is the pair systemd actually treats as privileged.
2896    if directives.contains_key("PrivateUsers")
2897        && directives.get("PrivateUsers") == Some(&"no".to_string())
2898    {
2899        findings.push(PrivilegeFinding {
2900            kind: FindingKind::Excess,
2901            subject: "PrivateUsers".to_string(),
2902            detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
2903                .to_string(),
2904        });
2905    }
2906
2907    review_inbound_surface(
2908        text,
2909        &[
2910            (
2911                "ListenStream=",
2912                "asks systemd to open a listening socket for this service, which \
2913                 07-security.md rule 2 forbids the product to have",
2914            ),
2915            (
2916                "ListenDatagram=",
2917                "asks systemd to open a listening socket for this service, which \
2918                 07-security.md rule 2 forbids the product to have",
2919            ),
2920        ],
2921        controls,
2922        findings,
2923    );
2924}
2925
2926fn review_launchd(
2927    text: &str,
2928    plan: &InstallPlan,
2929    controls: &mut Vec<String>,
2930    findings: &mut Vec<PrivilegeFinding>,
2931) {
2932    // A scheduling class is not an authority, so no value here is an excess.
2933    // Anything but `Interactive` is a shortfall: launchd applies it to every
2934    // runner the daemon starts. See `LAUNCHD_PROCESS_TYPE`.
2935    match plist_string_value(text, "ProcessType") {
2936        Some(value) if value == LAUNCHD_PROCESS_TYPE => {
2937            controls.push(format!("ProcessType={LAUNCHD_PROCESS_TYPE}"));
2938        }
2939        found => findings.push(PrivilegeFinding {
2940            kind: FindingKind::Shortfall,
2941            subject: "ProcessType".to_string(),
2942            detail: format!(
2943                "is {}, and launchd applies it to every runner the daemon starts, so their jobs \
2944                 run below normal priority. The daemon rewrites it to `{LAUNCHD_PROCESS_TYPE}` \
2945                 when it next starts with no runner; `service status` says how to restart it",
2946                found.map_or_else(
2947                    || "absent (launchd's `Standard`)".to_string(),
2948                    |value| format!("`{value}`")
2949                )
2950            ),
2951        }),
2952    }
2953
2954    match plan.start_mode() {
2955        StartMode::Boot => {
2956            if plist_bool_value(text, "SessionCreate") == Some(true) {
2957                findings.push(PrivilegeFinding {
2958                    kind: FindingKind::Excess,
2959                    subject: "SessionCreate".to_string(),
2960                    detail: "asks launchd to create a security session for a job that runs \
2961                             outside every login session and has no use for one"
2962                        .to_string(),
2963                });
2964            } else {
2965                controls.push("SessionCreate is not requested".to_string());
2966            }
2967            match plist_string_value(text, "UserName").as_deref() {
2968                Some("root") => {
2969                    controls.push("UserName=root, stated rather than inherited".to_string())
2970                }
2971                Some(other) => findings.push(PrivilegeFinding {
2972                    kind: FindingKind::Shortfall,
2973                    subject: "UserName".to_string(),
2974                    detail: format!(
2975                        "is `{other}`, which cannot unlock the System Keychain: \
2976                         /var/db/SystemKey is root-only, so the daemon would start and then \
2977                         find no credential"
2978                    ),
2979                }),
2980                None => findings.push(PrivilegeFinding {
2981                    kind: FindingKind::Shortfall,
2982                    subject: "UserName".to_string(),
2983                    detail: "is absent, so the account is launchd's implicit default and this \
2984                             review cannot confirm it"
2985                        .to_string(),
2986                }),
2987            }
2988        }
2989        StartMode::Login => {
2990            if let Some(named) = plist_string_value(text, "UserName") {
2991                findings.push(PrivilegeFinding {
2992                    kind: FindingKind::Excess,
2993                    subject: "UserName".to_string(),
2994                    detail: format!(
2995                        "names `{named}` in a LaunchAgent, which already runs as the operator; \
2996                         naming an account here asks launchd for a switch a login-mode \
2997                         registration has no reason to want"
2998                    ),
2999                });
3000            } else {
3001                controls
3002                    .push("no UserName: the agent runs as the operator and no other".to_string());
3003            }
3004        }
3005    }
3006
3007    review_inbound_surface(
3008        text,
3009        &[
3010            (
3011                "<key>Sockets</key>",
3012                "asks launchd to open a socket for this job, which 07-security.md rule 2 \
3013                 forbids the product to have",
3014            ),
3015            (
3016                "<key>MachServices</key>",
3017                "publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
3018                 forbids the product to have",
3019            ),
3020        ],
3021        controls,
3022        findings,
3023    );
3024}
3025
3026fn review_scheduled_task(
3027    text: &str,
3028    controls: &mut Vec<String>,
3029    findings: &mut Vec<PrivilegeFinding>,
3030) {
3031    match xml_value(text, "RunLevel").as_deref() {
3032        Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
3033        Some(other) => findings.push(PrivilegeFinding {
3034            kind: FindingKind::Excess,
3035            subject: "RunLevel".to_string(),
3036            detail: format!(
3037                "is `{other}`, so the task runs with an elevated token whenever the operator is \
3038                 an administrator"
3039            ),
3040        }),
3041        None => findings.push(PrivilegeFinding {
3042            kind: FindingKind::Excess,
3043            subject: "RunLevel".to_string(),
3044            detail: "is absent, so Task Scheduler decides the token rather than the definition"
3045                .to_string(),
3046        }),
3047    }
3048
3049    match xml_value(text, "LogonType").as_deref() {
3050        Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
3051        Some(other) => findings.push(PrivilegeFinding {
3052            kind: FindingKind::Excess,
3053            subject: "LogonType".to_string(),
3054            detail: format!(
3055                "is `{other}`, which asks Windows to store or synthesise a credential for this \
3056                 task; an interactive token needs neither"
3057            ),
3058        }),
3059        None => findings.push(PrivilegeFinding {
3060            kind: FindingKind::Shortfall,
3061            subject: "LogonType".to_string(),
3062            detail: "is absent, so this review cannot confirm that no credential is stored"
3063                .to_string(),
3064        }),
3065    }
3066}
3067
3068fn review_windows_service(
3069    text: &str,
3070    plan: &InstallPlan,
3071    controls: &mut Vec<String>,
3072    findings: &mut Vec<PrivilegeFinding>,
3073) {
3074    let directives = ini_directives(text, "windows-service");
3075
3076    match directives.get("ServiceType").map(String::as_str) {
3077        Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
3078        Some(other) => findings.push(PrivilegeFinding {
3079            kind: FindingKind::Excess,
3080            subject: "ServiceType".to_string(),
3081            detail: format!(
3082                "is `{other}`; an interactive or shared-process service reaches further than a \
3083                 daemon that only talks to GitHub over HTTPS"
3084            ),
3085        }),
3086        None => findings.push(PrivilegeFinding {
3087            kind: FindingKind::Shortfall,
3088            subject: "ServiceType".to_string(),
3089            detail: "is absent, so this review cannot confirm the service is not interactive"
3090                .to_string(),
3091        }),
3092    }
3093
3094    // The account is not a free choice on Windows: `d2`'s DACL decides it. What
3095    // the review can check is that the definition names the one account that
3096    // DACL admits, and no broader one.
3097    match directives.get("Account").map(String::as_str) {
3098        Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
3099            controls.push(format!(
3100                "Account={account}: the only stock account the machine-scoped store's DACL \
3101                 (SY, BA, OW) admits"
3102            ));
3103        }
3104        Some(other) => findings.push(PrivilegeFinding {
3105            kind: FindingKind::Shortfall,
3106            subject: "Account".to_string(),
3107            detail: format!(
3108                "is `{other}`, which the machine-scoped store's DACL does not name, so the \
3109                 daemon would start and then find no credential. Widening that DACL is not this \
3110                 registration's to do: an ACE reaching `{other}` would also reach every other \
3111                 service running under it"
3112            ),
3113        }),
3114        None => findings.push(PrivilegeFinding {
3115            kind: FindingKind::Shortfall,
3116            subject: "Account".to_string(),
3117            detail: "is absent, so this review cannot confirm which account was registered"
3118                .to_string(),
3119        }),
3120    }
3121
3122    match directives.get("ReadWritePaths") {
3123        Some(value) => {
3124            let listed = split_quoted(value);
3125            review_writable_paths(
3126                DefinitionKind::WindowsService,
3127                "ReadWritePaths",
3128                &listed,
3129                plan,
3130                controls,
3131                findings,
3132            );
3133        }
3134        None => findings.push(PrivilegeFinding {
3135            kind: FindingKind::Shortfall,
3136            subject: "ReadWritePaths".to_string(),
3137            detail: "is absent, so the directories the service was installed against are not \
3138                     recorded"
3139                .to_string(),
3140        }),
3141    }
3142}
3143
3144// -- parsing helpers ---------------------------------------------------------
3145
3146/// Reads `key=value` lines out of one `[section]` of an INI-shaped document.
3147///
3148/// systemd's unit format and this module's Windows descriptor are both this
3149/// shape. A repeated key takes its last value, which is systemd's own rule for
3150/// every directive here.
3151fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
3152    let mut out = BTreeMap::new();
3153    let mut inside = false;
3154    for line in text.lines() {
3155        let line = line.trim();
3156        if line.starts_with('[') && line.ends_with(']') {
3157            inside = &line[1..line.len() - 1] == section;
3158            continue;
3159        }
3160        if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
3161            continue;
3162        }
3163        if let Some((key, value)) = line.split_once('=') {
3164            out.insert(key.trim().to_string(), value.trim().to_string());
3165        }
3166    }
3167    out
3168}
3169
3170/// The command line in a systemd `ExecStart=`, without the prefixes systemd
3171/// reads off its front (`-`, `@`, `:`, `+`, `!`, `!!`).
3172///
3173/// A drop-in may carry any of them, and left in place they become part of the
3174/// executable's path. With `@` the second word is the `argv[0]` handed to the
3175/// process rather than an argument, so it goes as well.
3176#[cfg(any(test, all(unix, not(target_os = "macos"))))]
3177fn systemd_exec_command_line(value: &str) -> String {
3178    let value = value.trim();
3179    let command = value.trim_start_matches(['-', '@', ':', '+', '!']);
3180    if !value[..value.len() - command.len()].contains('@') {
3181        return command.to_string();
3182    }
3183    let mut words = split_command_line(command);
3184    if words.len() > 1 {
3185        words.remove(1);
3186    }
3187    words
3188        .iter()
3189        .map(|word| quote_argument(word))
3190        .collect::<Vec<_>>()
3191        .join(" ")
3192}
3193
3194/// Parses a systemd time span such as `15`, `15s`, `500ms`, `1min 30s` or
3195/// `2h`. A bare number is seconds. `None` for `infinity`, an empty value, or
3196/// anything systemd would not accept either.
3197#[cfg(any(test, all(unix, not(target_os = "macos"))))]
3198fn systemd_timespan(value: &str) -> Option<Duration> {
3199    let mut rest = value.trim();
3200    if rest.is_empty() || rest == "infinity" {
3201        return None;
3202    }
3203    let mut seconds = 0f64;
3204    while !rest.is_empty() {
3205        let digits = rest
3206            .find(|c: char| !(c.is_ascii_digit() || c == '.'))
3207            .unwrap_or(rest.len());
3208        if digits == 0 {
3209            return None;
3210        }
3211        let number: f64 = rest[..digits].parse().ok()?;
3212        rest = rest[digits..].trim_start();
3213        let letters = rest
3214            .find(|c: char| !c.is_alphabetic())
3215            .unwrap_or(rest.len());
3216        let scale = match &rest[..letters] {
3217            "" | "s" | "sec" | "second" | "seconds" => 1.0,
3218            "us" | "usec" | "\u{3bc}s" | "\u{b5}s" => 1e-6,
3219            "ms" | "msec" => 1e-3,
3220            "m" | "min" | "minute" | "minutes" => 60.0,
3221            "h" | "hr" | "hour" | "hours" => 3_600.0,
3222            "d" | "day" | "days" => 86_400.0,
3223            "w" | "week" | "weeks" => 604_800.0,
3224            "M" | "month" | "months" => 2_629_800.0,
3225            "y" | "year" | "years" => 31_557_600.0,
3226            _ => return None,
3227        };
3228        seconds += number * scale;
3229        rest = rest[letters..].trim_start();
3230    }
3231    Duration::try_from_secs_f64(seconds).ok()
3232}
3233
3234/// Splits a whitespace-separated list that may contain arguments quoted by
3235/// [`quote_argument`].
3236fn split_quoted(value: &str) -> Vec<String> {
3237    let mut out = Vec::new();
3238    let mut rest = value.trim();
3239    while !rest.is_empty() {
3240        if rest.starts_with('"') {
3241            // `executable_from_command_line` already implements Windows' quoting
3242            // rules; reuse it rather than write a second, subtly different one.
3243            if let Some(parsed) = executable_from_command_line(rest) {
3244                out.push(parsed.to_string_lossy().into_owned());
3245            }
3246            // Advance past the closing quote.
3247            let mut depth = 0usize;
3248            let mut end = rest.len();
3249            for (index, c) in rest.char_indices() {
3250                match c {
3251                    '\\' => depth += 1,
3252                    '"' => {
3253                        if depth.is_multiple_of(2) && index > 0 {
3254                            end = index + 1;
3255                            break;
3256                        }
3257                        depth = 0;
3258                    }
3259                    _ => depth = 0,
3260                }
3261            }
3262            rest = rest[end..].trim_start();
3263        } else {
3264            let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
3265            out.push(rest[..end].to_string());
3266            rest = rest[end..].trim_start();
3267        }
3268    }
3269    out
3270}
3271
3272/// The text between the **first** `<tag>` and its `</tag>`, unescaped, for the
3273/// small, known documents this module renders. Not a general XML parser and
3274/// does not pretend to be one.
3275///
3276/// "First" matters for a document that repeats a tag: a Task Scheduler task has
3277/// an `<Enabled>` in its trigger *and* one in its settings, so a caller after
3278/// the second one passes the slice that starts at `<Settings>`.
3279///
3280/// `pub(crate)` rather than private because [`crate::wsl::task`] reads back a
3281/// second kind of Task Scheduler document; one reader for both is the same
3282/// argument [`xml_escape`] makes for one escaper.
3283pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
3284    let open = format!("<{tag}>");
3285    let close = format!("</{tag}>");
3286    let start = text.find(&open)? + open.len();
3287    let end = text[start..].find(&close)? + start;
3288    Some(xml_unescape(text[start..end].trim()))
3289}
3290
3291/// Whether Task Scheduler will launch a login task automatically.
3292///
3293/// Its `/Query /XML` output normalises away values that equal the schema
3294/// default. In particular, an enabled task commonly has no `<Enabled>` node at
3295/// all, so only an explicit `false` means disabled.
3296#[cfg(any(windows, test))]
3297fn windows_login_task_starts_automatically(document: &str) -> bool {
3298    document.contains("<LogonTrigger>")
3299        && xml_value(document, "Enabled").as_deref() != Some("false")
3300}
3301
3302/// The `<string>` that follows `<key>key</key>` in a property list.
3303fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
3304    let marker = format!("<key>{key}</key>");
3305    let start = text.find(&marker)? + marker.len();
3306    Some(text[start..].trim_start())
3307}
3308
3309pub fn plist_string_value(text: &str, key: &str) -> Option<String> {
3310    let rest = plist_value_after_key(text, key)?;
3311    if !rest.starts_with("<string>") {
3312        return None;
3313    }
3314    xml_value(rest, "string")
3315}
3316
3317fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
3318    let rest = plist_value_after_key(text, key)?;
3319    if rest.starts_with("<true/>") {
3320        Some(true)
3321    } else if rest.starts_with("<false/>") {
3322        Some(false)
3323    } else {
3324        None
3325    }
3326}
3327
3328// ---------------------------------------------------------------------------
3329// The install record
3330// ---------------------------------------------------------------------------
3331
3332/// The version of [`InstallRecord`] on disk.
3333///
3334/// Bumped when a record written by an older version can no longer be read.
3335/// `service status` reports an unreadable record as a problem with a remedy
3336/// rather than failing, because the remedy — uninstall and install again —
3337/// touches no configuration, no secret and no cache.
3338pub const RECORD_SCHEMA_VERSION: u32 = 1;
3339
3340/// What `service install` wrote down, and what `service status` reads back.
3341///
3342/// This is the *record* half of `05-infrastructure.md` item 6. The service
3343/// manager also knows the binary path, and the two are compared: a registration
3344/// whose command line no longer matches the record is a registration something
3345/// else has edited, and saying so is more useful than picking one of them to
3346/// believe.
3347///
3348/// It holds no credential. The nearest thing is the *location* of the secret
3349/// store, which `d2` publishes for `host show` to print, and even that is
3350/// derived rather than stored.
3351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3352pub struct InstallRecord {
3353    /// [`RECORD_SCHEMA_VERSION`] at the time of writing.
3354    pub schema_version: u32,
3355    /// What the operating system calls the registration.
3356    pub service_name: String,
3357    /// Which service manager holds it.
3358    pub manager: String,
3359    /// Boot or login.
3360    pub start_mode: StartMode,
3361    /// The account it runs under.
3362    pub account: ServiceAccount,
3363    /// **The resolved absolute path of the binary at install time.** Item 6.
3364    pub binary: PathBuf,
3365    /// Where [`Self::binary`] was copied from, when it is a copy.
3366    ///
3367    /// `#[serde(default)]` rather than a schema bump: a record written before
3368    /// this existed is still readable, and reads as `None`. That is not a
3369    /// silent downgrade — `None` means the registration names a package
3370    /// manager's own file, which is the layout that cannot be upgraded while
3371    /// the service runs, and `service status` says so by name.
3372    #[serde(default)]
3373    pub source_binary: Option<PathBuf>,
3374    /// The arguments it was registered with.
3375    pub arguments: Vec<String>,
3376    /// The restart-on-failure delay.
3377    pub restart_delay_secs: u64,
3378    /// How long it must run before the failure count resets.
3379    pub restart_reset_secs: u64,
3380    /// The rotating diagnostic log's stem. Item 4.
3381    pub log_file: PathBuf,
3382    /// Whether the manager was asked not to start this by itself. Always
3383    /// `false` for a registration an operator made; see
3384    /// [`InstallRequest::started_on_demand`].
3385    ///
3386    /// `#[serde(default)]` so that a record missing the field is rejected by
3387    /// the schema check below, with its remedy, rather than by a parse error
3388    /// that names a field an operator has never heard of.
3389    #[serde(default)]
3390    pub starts_on_demand: bool,
3391    /// Where the platform's definition file was written, when there is one.
3392    pub definition_path: Option<PathBuf>,
3393    /// The host paths the registration made inaccessible. See
3394    /// [`ProtectedPaths`]; empty in a record written before they existed.
3395    #[serde(default)]
3396    pub protected_paths: Vec<PathBuf>,
3397    /// When the registration was made.
3398    pub installed_at: DateTime<Utc>,
3399    /// Which build of the product made it.
3400    pub installed_by_version: String,
3401    // A TOML table has to follow every scalar at its level, so this field is
3402    // last by necessity rather than by taste.
3403    /// The four directories the registration was installed against. Item 2's
3404    /// *"configured cache and runtime directories"*.
3405    pub directories: ServiceDirectories,
3406}
3407
3408impl InstallRecord {
3409    /// Where the record lives, given this host's directories.
3410    #[must_use]
3411    pub fn path(paths: &AppPaths) -> PathBuf {
3412        paths.config_dir().join(RECORD_FILE)
3413    }
3414
3415    /// Builds the record for a plan that has just been applied.
3416    #[must_use]
3417    pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
3418        Self {
3419            schema_version: RECORD_SCHEMA_VERSION,
3420            service_name: plan.identity().name().to_string(),
3421            manager: definition.kind().manager().to_string(),
3422            start_mode: plan.start_mode(),
3423            account: plan.account().clone(),
3424            binary: plan.binary().to_path_buf(),
3425            arguments: plan
3426                .arguments()
3427                .iter()
3428                .map(|argument| argument.to_string_lossy().into_owned())
3429                .collect(),
3430            restart_delay_secs: plan.restart().delay().as_secs(),
3431            restart_reset_secs: plan.restart().reset_after().as_secs(),
3432            starts_on_demand: plan.is_on_demand(),
3433            source_binary: plan.source_binary().map(Path::to_path_buf),
3434            log_file: plan.directories().log_file(),
3435            definition_path: definition.install_path().map(Path::to_path_buf),
3436            protected_paths: plan.protected_paths().to_vec(),
3437            installed_at: at,
3438            installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
3439            directories: plan.directories().clone(),
3440        }
3441    }
3442
3443    /// The plan this record describes, as this build would register it.
3444    ///
3445    /// The directories, binary, arguments and restart policy are the recorded
3446    /// ones; everything else -- hardening, the secret store's guard -- is what
3447    /// this build renders. That difference is the point: it is how a
3448    /// registration written by an older build is compared against, and
3449    /// repaired to, the current definition.
3450    #[must_use]
3451    pub fn plan(&self, identity: ServiceIdentity) -> InstallPlan {
3452        let plan = InstallPlan::unchecked(
3453            identity,
3454            self.start_mode,
3455            self.binary.clone(),
3456            self.directories.clone(),
3457        )
3458        .with_arguments(self.arguments.clone())
3459        .with_restart(self.restart())
3460        .with_protected_paths(self.protected_paths.clone());
3461        let plan = if self.starts_on_demand {
3462            plan.started_on_demand()
3463        } else {
3464            plan
3465        };
3466        match crate::secrets::PlatformSecretStore::for_start_mode(self.start_mode) {
3467            Ok(store) => plan.with_secret_guard(store.guard()),
3468            Err(_) => plan,
3469        }
3470    }
3471
3472    /// The restart policy this record describes, or [`RestartPolicy::default`]
3473    /// when the recorded numbers are outside the supported range — which can
3474    /// only happen to a record something else has edited.
3475    #[must_use]
3476    pub fn restart(&self) -> RestartPolicy {
3477        RestartPolicy::new(
3478            Duration::from_secs(self.restart_delay_secs),
3479            Duration::from_secs(self.restart_reset_secs),
3480        )
3481        .unwrap_or_default()
3482    }
3483
3484    /// Reads the record, or reports that there is none.
3485    ///
3486    /// `Ok(None)` means no registration was made by this product on this host.
3487    ///
3488    /// # Errors
3489    ///
3490    /// [`ServiceError::Record`] when the file exists and cannot be read,
3491    /// [`ServiceError::RecordNotPermitted`] when it exists and this account may
3492    /// not read it, and [`ServiceError::RecordUnreadable`] when it is not a
3493    /// record this version understands.
3494    pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
3495        let path = Self::path(paths);
3496        let text = match std::fs::read_to_string(&path) {
3497            Ok(text) => text,
3498            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3499            // Told apart from every other read failure because `status` carries
3500            // on through this one alone. See `ServiceError::RecordNotPermitted`.
3501            Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
3502                return Err(ServiceError::RecordNotPermitted {
3503                    path,
3504                    detail: error.to_string(),
3505                });
3506            }
3507            Err(error) => {
3508                return Err(ServiceError::Record {
3509                    operation: "read",
3510                    path,
3511                    detail: error.to_string(),
3512                });
3513            }
3514        };
3515        let record: Self =
3516            toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
3517                path: path.clone(),
3518                detail: error.to_string(),
3519            })?;
3520        if record.schema_version != RECORD_SCHEMA_VERSION {
3521            return Err(ServiceError::RecordUnreadable {
3522                path,
3523                detail: format!(
3524                    "it declares schema version {} and this build reads version {}",
3525                    record.schema_version, RECORD_SCHEMA_VERSION
3526                ),
3527            });
3528        }
3529        Ok(Some(record))
3530    }
3531
3532    /// Writes the record, replacing whatever was there.
3533    ///
3534    /// # Errors
3535    ///
3536    /// [`ServiceError::Record`].
3537    pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
3538        use std::io::Write as _;
3539
3540        let path = Self::path(paths);
3541        let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
3542            operation: "encode",
3543            path: path.clone(),
3544            detail: error.to_string(),
3545        })?;
3546        if let Some(parent) = path.parent() {
3547            std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
3548                operation: "write",
3549                path: path.clone(),
3550                detail: error.to_string(),
3551            })?;
3552        }
3553        let parent = path.parent().ok_or_else(|| ServiceError::Record {
3554            operation: "write",
3555            path: path.clone(),
3556            detail: "the record path has no parent directory".to_string(),
3557        })?;
3558        let mut temporary =
3559            tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
3560                operation: "write",
3561                path: path.clone(),
3562                detail: error.to_string(),
3563            })?;
3564        temporary
3565            .write_all(text.as_bytes())
3566            .and_then(|()| temporary.as_file().sync_all())
3567            .map_err(|error| ServiceError::Record {
3568                operation: "write",
3569                path: path.clone(),
3570                detail: error.to_string(),
3571            })?;
3572        // ------------------------------------------------------------------
3573        // THE RECORD IS READABLE BY THE ACCOUNT WHOSE DIRECTORY IT IS IN.
3574        // ------------------------------------------------------------------
3575        // `NamedTempFile` creates at `0600`, which is right for a temporary
3576        // file and wrong for this one. A boot-mode `service install` runs under
3577        // `sudo`, so `0600` made the record `root`-owned and unreadable by the
3578        // operator — and `service status`, which asks no privilege of anybody,
3579        // failed on their own host with `Permission denied` and a remedy that
3580        // named the command that had just failed.
3581        //
3582        // `0644` does not widen anything: `RECORD_FILE`'s own documentation is
3583        // that this is *"non-secret TOML"* holding no credential, and
3584        // `AppPaths::create_all` puts it in a `0700` directory, so no other
3585        // local account can reach it whatever its own mode says. It is the same
3586        // arrangement the rotating diagnostics already have.
3587        #[cfg(unix)]
3588        {
3589            use std::os::unix::fs::PermissionsExt as _;
3590
3591            temporary
3592                .as_file()
3593                .set_permissions(std::fs::Permissions::from_mode(0o644))
3594                .map_err(|error| ServiceError::Record {
3595                    operation: "write",
3596                    path: path.clone(),
3597                    detail: error.to_string(),
3598                })?;
3599        }
3600        temporary
3601            .persist(&path)
3602            .map(|_| ())
3603            .map_err(|error| ServiceError::Record {
3604                operation: "write",
3605                path,
3606                detail: error.error.to_string(),
3607            })
3608    }
3609
3610    /// Removes the record. Returns whether there was one.
3611    ///
3612    /// # Errors
3613    ///
3614    /// [`ServiceError::Record`].
3615    pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
3616        let path = Self::path(paths);
3617        match std::fs::remove_file(&path) {
3618            Ok(()) => Ok(true),
3619            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
3620            Err(error) => Err(ServiceError::Record {
3621                operation: "remove",
3622                path,
3623                detail: error.to_string(),
3624            }),
3625        }
3626    }
3627}
3628
3629// ---------------------------------------------------------------------------
3630// The last successful GitHub contact
3631// ---------------------------------------------------------------------------
3632
3633/// The heartbeat file's own schema version.
3634const CONTACT_SCHEMA_VERSION: u32 = 1;
3635
3636#[derive(Debug, Clone, Serialize, Deserialize)]
3637struct ContactRecord {
3638    schema_version: u32,
3639    last_success: DateTime<Utc>,
3640}
3641
3642/// Records that GitHub was reached successfully, for `service status` to
3643/// report.
3644///
3645/// # The contract, for `f3` and for the agent
3646///
3647/// Journey 5 step 4 requires `service status` to report *"the last successful
3648/// GitHub contact"*, and `service status` runs in the operator's terminal while
3649/// the daemon runs in a service. They share no memory, so the fact has to be on
3650/// disk, and this is the file. **The daemon calls this after each successful
3651/// GitHub call; nothing else writes it.**
3652///
3653/// It is a single timestamp and deliberately not a log: an operator asking this
3654/// question wants to know whether the agent is alive and reaching GitHub *now*,
3655/// and a value that is minutes old answers it. Writing it is a whole-file
3656/// replace through a temporary in the same directory, so a status command can
3657/// never read a half-written timestamp.
3658///
3659/// # Errors
3660///
3661/// [`ServiceError::Record`] when `state/` cannot be written.
3662pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3663    write_state_record(
3664        &contact_path(paths),
3665        &ContactRecord {
3666            schema_version: CONTACT_SCHEMA_VERSION,
3667            last_success: at,
3668        },
3669    )
3670}
3671
3672/// Reads the last successful GitHub contact, or reports that none was recorded.
3673///
3674/// `Ok(None)` is the honest answer on a host whose agent has never run, and it
3675/// is what `service status` prints rather than a zero timestamp.
3676///
3677/// # Errors
3678///
3679/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3680/// A malformed heartbeat is reported rather than treated as absence: absence
3681/// means *"the agent has never reached GitHub"*, and reporting a parse failure
3682/// as that would be a wrong answer to the question Journey 5 asks.
3683pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3684    Ok(read_state_record::<ContactRecord>(&contact_path(paths))?.map(|record| record.last_success))
3685}
3686
3687/// Where the heartbeat lives.
3688#[must_use]
3689pub fn contact_path(paths: &AppPaths) -> PathBuf {
3690    paths.state_dir().join(CONTACT_FILE)
3691}
3692
3693/// Writes a small TOML record under `state/`, through a temporary in the same
3694/// directory and a rename, so a status command never reads half of one.
3695pub(crate) fn write_state_record(path: &Path, record: &impl Serialize) -> Result<(), ServiceError> {
3696    let failed = |detail: String| ServiceError::Record {
3697        operation: "write",
3698        path: path.to_path_buf(),
3699        detail,
3700    };
3701    let text = toml::to_string_pretty(record).map_err(|error| failed(error.to_string()))?;
3702    let directory = path.parent().unwrap_or_else(|| Path::new("."));
3703    std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3704    let temporary = path.with_extension("toml.new");
3705    std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3706    std::fs::rename(&temporary, path).map_err(|error| failed(error.to_string()))
3707}
3708
3709/// Reads a record [`write_state_record`] wrote. `Ok(None)` when there is none;
3710/// one that exists and does not parse is an error, never absence.
3711pub(crate) fn read_state_record<T: serde::de::DeserializeOwned>(
3712    path: &Path,
3713) -> Result<Option<T>, ServiceError> {
3714    let failed = |detail: String| ServiceError::Record {
3715        operation: "read",
3716        path: path.to_path_buf(),
3717        detail,
3718    };
3719    let text = match std::fs::read_to_string(path) {
3720        Ok(text) => text,
3721        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3722        Err(error) => return Err(failed(error.to_string())),
3723    };
3724    toml::from_str(&text)
3725        .map(Some)
3726        .map_err(|error| failed(error.to_string()))
3727}
3728
3729/// Removes a record; one that is not there is already removed.
3730pub(crate) fn remove_state_record(path: &Path) -> Result<(), ServiceError> {
3731    match std::fs::remove_file(path) {
3732        Ok(()) => Ok(()),
3733        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3734        Err(error) => Err(ServiceError::Record {
3735            operation: "remove",
3736            path: path.to_path_buf(),
3737            detail: error.to_string(),
3738        }),
3739    }
3740}
3741
3742// ---------------------------------------------------------------------------
3743// The credential GitHub rejected
3744// ---------------------------------------------------------------------------
3745
3746#[derive(Debug, Serialize, Deserialize)]
3747#[serde(deny_unknown_fields)]
3748struct CredentialRejectionRecord {
3749    schema_version: u32,
3750    rejected_since: DateTime<Utc>,
3751}
3752
3753/// Records that GitHub rejected the daemon's credential, keeping the first
3754/// moment of an ongoing rejection.
3755///
3756/// # Why a file of its own
3757///
3758/// A rejected credential used to be visible only in the daemon's log: the
3759/// contact heartbeat simply stopped moving, `service status` still printed
3760/// `healthy`, and the TUI -- which authenticates with its own, perhaps freshly
3761/// renewed, copy -- showed nothing at all while the service started no runner
3762/// for hours. Separate from [`CONTACT_FILE`] so that an older build reading
3763/// that file is not handed a field it refuses.
3764///
3765/// # Errors
3766///
3767/// [`ServiceError::Record`] when `state/` cannot be written.
3768pub fn record_github_credential_rejected(
3769    paths: &AppPaths,
3770    at: DateTime<Utc>,
3771) -> Result<(), ServiceError> {
3772    if github_credential_rejected_since(paths)
3773        .ok()
3774        .flatten()
3775        .is_some()
3776    {
3777        return Ok(());
3778    }
3779    write_state_record(
3780        &credential_rejection_path(paths),
3781        &CredentialRejectionRecord {
3782            schema_version: CONTACT_SCHEMA_VERSION,
3783            rejected_since: at,
3784        },
3785    )
3786}
3787
3788/// Clears a recorded rejection once GitHub accepts the credential again.
3789///
3790/// # Errors
3791///
3792/// [`ServiceError::Record`] when the record exists and cannot be removed.
3793pub fn clear_github_credential_rejection(paths: &AppPaths) -> Result<(), ServiceError> {
3794    remove_state_record(&credential_rejection_path(paths))
3795}
3796
3797/// Since when GitHub has been rejecting the daemon's credential, if it is.
3798///
3799/// # Errors
3800///
3801/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3802pub fn github_credential_rejected_since(
3803    paths: &AppPaths,
3804) -> Result<Option<DateTime<Utc>>, ServiceError> {
3805    Ok(
3806        read_state_record::<CredentialRejectionRecord>(&credential_rejection_path(paths))?
3807            .map(|record| record.rejected_since),
3808    )
3809}
3810
3811/// Where the rejection record lives.
3812#[must_use]
3813pub fn credential_rejection_path(paths: &AppPaths) -> PathBuf {
3814    paths.state_dir().join(CREDENTIAL_REJECTION_FILE)
3815}
3816
3817#[derive(Debug, Serialize, Deserialize)]
3818#[serde(deny_unknown_fields)]
3819struct CredentialUnreadableRecord {
3820    schema_version: u32,
3821    unreadable_since: DateTime<Utc>,
3822}
3823
3824/// Records that the daemon could not read its stored credential, keeping the
3825/// first moment of an ongoing failure.
3826///
3827/// # Why a file of its own
3828///
3829/// A daemon that cannot read its credential exits, and the service manager
3830/// starts it again a few seconds later, for ever. `service status` reads the
3831/// registration, which is fine, and the heartbeat, which merely stops moving --
3832/// so it said `healthy` while a macOS host that had just been upgraded was
3833/// locked out of its own keychain item and started no runner. The reason was
3834/// only in the daemon's log. The daemon is the one process whose view of the
3835/// store matters, and it shares no memory with `service status`, so it says
3836/// so on disk; [`clear_credential_unreadable`] removes it as soon as a read
3837/// succeeds.
3838///
3839/// # Errors
3840///
3841/// [`ServiceError::Record`] when `state/` cannot be written.
3842pub fn record_credential_unreadable(
3843    paths: &AppPaths,
3844    at: DateTime<Utc>,
3845) -> Result<(), ServiceError> {
3846    if credential_unreadable_since(paths).ok().flatten().is_some() {
3847        return Ok(());
3848    }
3849    write_state_record(
3850        &credential_unreadable_path(paths),
3851        &CredentialUnreadableRecord {
3852            schema_version: CONTACT_SCHEMA_VERSION,
3853            unreadable_since: at,
3854        },
3855    )
3856}
3857
3858/// Clears a recorded failure once the daemon reads its credential again.
3859///
3860/// # Errors
3861///
3862/// [`ServiceError::Record`] when the record exists and cannot be removed.
3863pub fn clear_credential_unreadable(paths: &AppPaths) -> Result<(), ServiceError> {
3864    remove_state_record(&credential_unreadable_path(paths))
3865}
3866
3867/// Since when the daemon has been unable to read its stored credential, if it
3868/// has.
3869///
3870/// # Errors
3871///
3872/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3873pub fn credential_unreadable_since(
3874    paths: &AppPaths,
3875) -> Result<Option<DateTime<Utc>>, ServiceError> {
3876    Ok(
3877        read_state_record::<CredentialUnreadableRecord>(&credential_unreadable_path(paths))?
3878            .map(|record| record.unreadable_since),
3879    )
3880}
3881
3882/// Where the unreadable-credential record lives.
3883#[must_use]
3884pub fn credential_unreadable_path(paths: &AppPaths) -> PathBuf {
3885    paths.state_dir().join(CREDENTIAL_UNREADABLE_FILE)
3886}
3887
3888/// How to sign this host in again for `mode`: the exact command, in
3889/// backticks, and where to run it when that matters.
3890///
3891/// A machine-scoped store needs privilege: `sudo` on Unix, an elevated
3892/// terminal on Windows. A login-mode store on macOS is the login keychain,
3893/// which is unlocked for the desktop session only, so an SSH session cannot
3894/// write it.
3895#[must_use]
3896pub fn sign_in_instruction(mode: StartMode) -> String {
3897    let (command, place) = match mode {
3898        StartMode::Boot if cfg!(unix) => (sudo_command("auth login --start-at boot"), ""),
3899        StartMode::Boot => (
3900            "runner-manager auth login --start-at boot".to_owned(),
3901            " from an elevated terminal",
3902        ),
3903        StartMode::Login => (
3904            "runner-manager auth login --start-at login".to_owned(),
3905            MACOS_LOGIN_PLACE,
3906        ),
3907    };
3908    format!("`{command}`{place}")
3909}
3910
3911/// `sudo`, then this program, then `arguments`: a command an operator can
3912/// paste.
3913///
3914/// `sudo` searches only its own `secure_path`, which an npm or nvm
3915/// installation is not on, and the npm command is a Node shim that root's
3916/// `PATH` could not run either. So the command names this executable -- the
3917/// native binary, even behind that shim -- unless it already lives on
3918/// `secure_path`, where the bare name reads better and works.
3919#[must_use]
3920pub fn sudo_command(arguments: &str) -> String {
3921    // A test binary is not the product; its own path would only make every
3922    // remedy a test compares depend on where the build directory is.
3923    let executable = if cfg!(test) {
3924        None
3925    } else {
3926        std::env::current_exe().ok()
3927    };
3928    format!("sudo {} {arguments}", sudo_program(executable.as_deref()))
3929}
3930
3931/// How a `sudo` command names `executable`. See [`sudo_command`].
3932fn sudo_program(executable: Option<&Path>) -> String {
3933    /// Ubuntu's and Debian's `secure_path`, which other distributions'
3934    /// defaults are a subset of.
3935    const SECURE_PATH: [&str; 7] = [
3936        "/usr/local/sbin",
3937        "/usr/local/bin",
3938        "/usr/sbin",
3939        "/usr/bin",
3940        "/sbin",
3941        "/bin",
3942        "/snap/bin",
3943    ];
3944    let Some(executable) = executable else {
3945        return "runner-manager".to_owned();
3946    };
3947    if executable
3948        .parent()
3949        .is_some_and(|directory| SECURE_PATH.iter().any(|path| directory == Path::new(path)))
3950    {
3951        return "runner-manager".to_owned();
3952    }
3953    let text = executable.to_string_lossy();
3954    if text
3955        .chars()
3956        .all(|c| c.is_ascii_alphanumeric() || "/._-+@".contains(c))
3957    {
3958        text.into_owned()
3959    } else {
3960        format!("'{}'", text.replace('\'', "'\\''"))
3961    }
3962}
3963
3964/// Where a login-mode sign-in has to happen on macOS, as a phrase to follow
3965/// the command; nothing elsewhere.
3966pub const MACOS_LOGIN_PLACE: &str = if cfg!(target_os = "macos") {
3967    " in a Terminal in this Mac's desktop session (an SSH session cannot unlock the login \
3968     keychain)"
3969} else {
3970    ""
3971};
3972
3973// ---------------------------------------------------------------------------
3974// A definition written by an older build
3975// ---------------------------------------------------------------------------
3976
3977/// An installed service definition that differs from what this build renders
3978/// for the same recorded installation.
3979///
3980/// # Why this exists
3981///
3982/// An upgrade replaces the binary a registration runs and nothing else. A
3983/// systemd unit written by 0.4.7 still carried `LoadCredential=`, which hands
3984/// the daemon a copy of the credential frozen at service start, and lacked
3985/// write access to the credential store. Every release after the fix kept
3986/// running under that unit: the daemon could neither renew the token nor see
3987/// one renewed by anybody else, and stopped starting runners the moment the
3988/// frozen access token expired.
3989#[derive(Debug, Clone, PartialEq, Eq)]
3990pub struct DefinitionDrift {
3991    /// The definition file on disk.
3992    pub path: PathBuf,
3993    /// The build that wrote it, as its install record says.
3994    pub installed_by_version: String,
3995    /// What this build renders in its place.
3996    pub rendered: String,
3997    /// The start mode it was installed for.
3998    pub start_mode: StartMode,
3999    /// Which manager reads the definition.
4000    pub manager: DefinitionKind,
4001}
4002
4003/// Compares the installed definition against this build's rendering.
4004///
4005/// A systemd unit is compared byte for byte. A launchd plist is compared on
4006/// `ProcessType` alone (see [`launchd_definition_drift`]). `None` for every
4007/// other manager, for a definition that cannot be read, and for one that
4008/// already matches. The file is read through `controls`, so a double never
4009/// compares against a real host's definition.
4010fn definition_drift(
4011    controls: &dyn ControlFactory,
4012    identity: &ServiceIdentity,
4013    record: &InstallRecord,
4014) -> Option<DefinitionDrift> {
4015    let compare = match host_definition_kind(record.start_mode) {
4016        DefinitionKind::SystemdUnit => systemd_definition_drift,
4017        DefinitionKind::LaunchdPlist => launchd_definition_drift,
4018        DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => return None,
4019    };
4020    let path = record.definition_path.clone()?;
4021    let installed = controls.installed_definition(&path)?;
4022    compare(identity, record, path, &installed)
4023}
4024
4025/// A plist whose `ProcessType` is not [`LAUNCHD_PROCESS_TYPE`], with the plist
4026/// this build renders in its place.
4027///
4028/// Only that one key, unlike the systemd comparison. The daemon repairs this
4029/// drift by itself (`daemon.rs`, `converge_launchd_definition`), so a
4030/// whole-file comparison would make it rewrite and reload over every edit an
4031/// operator made to the plist. A plist that does carry the wrong value is
4032/// replaced whole, as `service install` would replace it.
4033fn launchd_definition_drift(
4034    identity: &ServiceIdentity,
4035    record: &InstallRecord,
4036    path: PathBuf,
4037    installed: &str,
4038) -> Option<DefinitionDrift> {
4039    (plist_string_value(installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE)).then(
4040        || DefinitionDrift {
4041            path,
4042            installed_by_version: record.installed_by_version.clone(),
4043            rendered: launchd_plist(&record.plan(identity.clone())),
4044            start_mode: record.start_mode,
4045            manager: DefinitionKind::LaunchdPlist,
4046        },
4047    )
4048}
4049
4050fn systemd_definition_drift(
4051    identity: &ServiceIdentity,
4052    record: &InstallRecord,
4053    path: PathBuf,
4054    installed: &str,
4055) -> Option<DefinitionDrift> {
4056    let rendered = systemd_unit(&record.plan(identity.clone()));
4057    (installed != rendered).then(|| DefinitionDrift {
4058        path,
4059        installed_by_version: record.installed_by_version.clone(),
4060        rendered,
4061        start_mode: record.start_mode,
4062        manager: DefinitionKind::SystemdUnit,
4063    })
4064}
4065
4066// ---------------------------------------------------------------------------
4067// The last runner-root refusal
4068// ---------------------------------------------------------------------------
4069
4070const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
4071
4072#[derive(Debug, Clone, Serialize, Deserialize)]
4073struct RootRefusalFile {
4074    schema_version: u32,
4075    /// Keyed by policy, because the fact is per-policy and not per-host.
4076    #[serde(default)]
4077    refusals: BTreeMap<String, RootRefusalEntry>,
4078}
4079
4080#[derive(Debug, Clone, Serialize, Deserialize)]
4081struct RootRefusalEntry {
4082    at: DateTime<Utc>,
4083    kind: String,
4084    root: String,
4085    detail: String,
4086}
4087
4088/// One policy's last runner-root refusal, as `service status` reports it.
4089#[derive(Debug, Clone, PartialEq, Eq)]
4090pub struct RunnerRootRefusal {
4091    /// The policy that placed no runner.
4092    pub policy: String,
4093    /// When it was last refused.
4094    pub at: DateTime<Utc>,
4095    /// The closed-vocabulary cause, as `RunnerRootError::kind` names it.
4096    pub kind: String,
4097    /// The root it was refused.
4098    pub root: String,
4099    /// The refusal in full, including the remediation. Not redacted: this file
4100    /// is read by a command in the operator's terminal, not by the log sink.
4101    pub detail: String,
4102}
4103
4104/// Records that one policy could not use its runner root.
4105///
4106/// # The contract, and why a file
4107///
4108/// A launch the runner root refuses fails before any attempt row exists, so the
4109/// journal cannot carry it and no screen can show it. The daemon's own log
4110/// cannot either: it redacts every field it does not allow-list and scrubs
4111/// anything path-shaped out of the ones it does, which is most of a
4112/// `RunnerRootError`. So the sentence an operator needs — the directory and the
4113/// remediation, verbatim — has nowhere to go, and the failure reads
4114/// `runner_start_failed reason=other` once per poll for as long as it lasts.
4115/// That is not hypothetical: it cost three hours.
4116///
4117/// **Keyed by policy, and that is load-bearing.** A host runs several policies
4118/// and they do not share a fate: `allocate_persistent_slot` tolerates an
4119/// unresolvable host default, so a repository with its own persistent root
4120/// places runners happily while an ephemeral policy on a withheld volume places
4121/// none. A single host-wide slot would have the working policy clear the broken
4122/// one's record on the same reconcile pass, and `service status` would report a
4123/// healthy host that had started zero runners for an entire target.
4124///
4125/// **The daemon writes this when a root refuses a launch and clears the policy's
4126/// entry when one succeeds; nothing else writes it.** It keeps the last refusal
4127/// per policy rather than a history, for the reason [`record_github_contact`] is
4128/// a single timestamp: the operator is asking whether runners can be placed
4129/// *now*.
4130///
4131/// Written whole through a temporary in the same directory, so a status command
4132/// cannot read half a record.
4133///
4134/// # Errors
4135///
4136/// [`ServiceError::Record`] when `state/` cannot be read or written.
4137pub fn record_runner_root_refusal(
4138    paths: &AppPaths,
4139    policy: &str,
4140    at: DateTime<Utc>,
4141    kind: &str,
4142    root: &str,
4143    detail: &str,
4144) -> Result<(), ServiceError> {
4145    let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
4146        schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
4147        refusals: BTreeMap::new(),
4148    });
4149    file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
4150    file.refusals.insert(
4151        policy.to_owned(),
4152        RootRefusalEntry {
4153            at,
4154            kind: kind.to_owned(),
4155            root: root.to_owned(),
4156            detail: detail.to_owned(),
4157        },
4158    );
4159    write_refusal_file(paths, &file)
4160}
4161
4162/// Clears one policy's entry, because that policy placed a runner.
4163///
4164/// Removes the file once the last entry goes, so a host that is working leaves
4165/// nothing behind for a later `service status` to report. A policy with no entry
4166/// is not an error: this is called on every successful placement, and almost
4167/// every one of those follows another success.
4168///
4169/// # Errors
4170///
4171/// [`ServiceError::Record`] when `state/` cannot be read or written.
4172pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
4173    let Some(mut file) = read_refusal_file(paths)? else {
4174        return Ok(());
4175    };
4176    if file.refusals.remove(policy).is_none() {
4177        return Ok(());
4178    }
4179    if file.refusals.is_empty() {
4180        let path = root_refusal_path(paths);
4181        return match std::fs::remove_file(&path) {
4182            Ok(()) => Ok(()),
4183            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
4184            Err(error) => Err(ServiceError::Record {
4185                operation: "remove",
4186                path,
4187                detail: error.to_string(),
4188            }),
4189        };
4190    }
4191    write_refusal_file(paths, &file)
4192}
4193
4194/// Every policy whose runner root refused it and has not since succeeded.
4195///
4196/// Ordered by policy, so two runs of `service status` on an unchanged host
4197/// print the same thing in the same order.
4198///
4199/// # Errors
4200///
4201/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
4202/// Reported rather than treated as absence, for the reason
4203/// [`last_github_contact`] gives: absence means "every policy is placing
4204/// runners", and answering a parse failure with that would be a wrong answer.
4205pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
4206    Ok(read_refusal_file(paths)?
4207        .map(|file| {
4208            file.refusals
4209                .into_iter()
4210                .map(|(policy, entry)| RunnerRootRefusal {
4211                    policy,
4212                    at: entry.at,
4213                    kind: entry.kind,
4214                    root: entry.root,
4215                    detail: entry.detail,
4216                })
4217                .collect()
4218        })
4219        .unwrap_or_default())
4220}
4221
4222fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
4223    read_state_record(&root_refusal_path(paths))
4224}
4225
4226fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
4227    write_state_record(&root_refusal_path(paths), file)
4228}
4229
4230/// Where the refusals live.
4231#[must_use]
4232pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
4233    paths.state_dir().join(ROOT_REFUSAL_FILE)
4234}
4235
4236// ---------------------------------------------------------------------------
4237// The recorded binary path
4238// ---------------------------------------------------------------------------
4239
4240/// What became of the absolute path `install` recorded.
4241///
4242/// `05-infrastructure.md` item 6 in one type. Three of the four variants are
4243/// errors, and `service status` reports them as errors rather than as health —
4244/// which is the whole point, because the npm case produces a service that looks
4245/// installed, is registered, and cannot start.
4246#[derive(Debug, Clone, PartialEq, Eq)]
4247pub enum BinaryPath {
4248    /// The recorded path is a file, and the service manager still starts it.
4249    Current {
4250        /// The path.
4251        path: PathBuf,
4252    },
4253    /// **Nothing is at the recorded path.**
4254    ///
4255    /// This is the npm upgrade: `npm i -g @ivan-murzak/runner-manager` puts the binary under
4256    /// the active Node installation's global prefix, and switching Node versions
4257    /// with `nvm`, `fnm`, or `volta` moves that prefix. The service is still
4258    /// registered, still set to start at boot, and starts nothing.
4259    Missing {
4260        /// The path the record names.
4261        recorded: PathBuf,
4262    },
4263    /// Something is at the recorded path, but it is not a file the service
4264    /// manager could start.
4265    NotExecutable {
4266        /// The path the record names.
4267        recorded: PathBuf,
4268        /// What is there instead.
4269        detail: String,
4270    },
4271    /// The record and the service manager name different binaries.
4272    Diverged {
4273        /// What the record says.
4274        recorded: PathBuf,
4275        /// What the service manager is registered to start.
4276        registered: PathBuf,
4277    },
4278}
4279
4280impl BinaryPath {
4281    /// Whether this is a state `service status` must report as an error.
4282    #[must_use]
4283    pub const fn is_error(&self) -> bool {
4284        !matches!(self, Self::Current { .. })
4285    }
4286
4287    /// The path the record names, whatever state it is in.
4288    #[must_use]
4289    pub fn recorded(&self) -> &Path {
4290        match self {
4291            Self::Current { path } => path,
4292            Self::Missing { recorded }
4293            | Self::NotExecutable { recorded, .. }
4294            | Self::Diverged { recorded, .. } => recorded,
4295        }
4296    }
4297}
4298
4299impl fmt::Display for BinaryPath {
4300    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4301        match self {
4302            Self::Current { path } => write!(f, "{}", path.display()),
4303            Self::Missing { recorded } => write!(
4304                f,
4305                "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
4306                 package manager that moved the binary is the usual cause; an `npm i -g` \
4307                 installation moves with the active Node version. Run `service install` again \
4308                 from the binary that is now installed.",
4309                recorded.display()
4310            ),
4311            Self::NotExecutable { recorded, detail } => write!(
4312                f,
4313                "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
4314                 from the installed binary.",
4315                recorded.display()
4316            ),
4317            Self::Diverged {
4318                recorded,
4319                registered,
4320            } => write!(
4321                f,
4322                "{} -- STALE: the service manager is registered to start {} instead. Something \
4323                 has edited the registration since it was installed. Run `service uninstall` and \
4324                 `service install`; neither touches configuration, secrets, or the cache.",
4325                recorded.display(),
4326                registered.display()
4327            ),
4328        }
4329    }
4330}
4331
4332/// Decides what became of a recorded path.
4333///
4334/// `registered` is what the service manager says it will start, when the
4335/// platform can be asked. Order matters and is deliberate: **absence is checked
4336/// first**, because a missing binary is the failure item 6 exists for and an
4337/// operator hearing "the registration disagrees with the record" about a file
4338/// that is not there would be sent to the wrong problem.
4339#[must_use]
4340pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
4341    match std::fs::metadata(recorded) {
4342        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
4343            return BinaryPath::Missing {
4344                recorded: recorded.to_path_buf(),
4345            };
4346        }
4347        Err(error) => {
4348            return BinaryPath::NotExecutable {
4349                recorded: recorded.to_path_buf(),
4350                detail: format!("it cannot be inspected ({error})"),
4351            };
4352        }
4353        Ok(metadata) if !metadata.is_file() => {
4354            return BinaryPath::NotExecutable {
4355                recorded: recorded.to_path_buf(),
4356                detail: "what is there is not a file".to_string(),
4357            };
4358        }
4359        Ok(_) => {}
4360    }
4361    if let Some(registered) = registered
4362        && !same_path_text(&recorded.to_string_lossy(), &registered.to_string_lossy())
4363    {
4364        return BinaryPath::Diverged {
4365            recorded: recorded.to_path_buf(),
4366            registered: registered.to_path_buf(),
4367        };
4368    }
4369    BinaryPath::Current {
4370        path: recorded.to_path_buf(),
4371    }
4372}
4373
4374// ---------------------------------------------------------------------------
4375// The control seam
4376// ---------------------------------------------------------------------------
4377
4378/// What a service manager says about a registration it holds.
4379#[derive(Debug, Clone, PartialEq, Eq)]
4380pub struct Registration {
4381    /// Which manager holds it, which is also what decides the start mode:
4382    /// a Windows service is a boot registration and a Task Scheduler task is a
4383    /// login one; a LaunchDaemon and a system unit are boot, a LaunchAgent and
4384    /// a user unit are login.
4385    pub manager: DefinitionKind,
4386    /// The start mode the *domain* it was found in implies.
4387    pub start_mode: StartMode,
4388    /// The full command line, as the manager stores it.
4389    pub command_line: String,
4390    /// The account it runs under, spelled as [`ServiceAccount`] spells it, when
4391    /// the manager names a real account.
4392    ///
4393    /// `None` where the manager reports only a placeholder: a login
4394    /// registration that runs as whoever signs in, or a Task Scheduler `UserId`
4395    /// that is a raw SID. Status then reports the install record's account.
4396    pub account: Option<String>,
4397    /// Whether it is running now.
4398    pub running: bool,
4399    /// Whether it will start by itself.
4400    ///
4401    /// Distinct from [`Registration::start_mode`]: a Windows service can be
4402    /// registered in the boot domain and still be set to `demand` start, which
4403    /// is a service that exists, looks installed, and does not come back after
4404    /// a reboot.
4405    pub starts_automatically: bool,
4406    /// The restart-on-failure delay the manager reports, when it reports one.
4407    ///
4408    /// The *delay* rather than the whole [`RestartPolicy`], because that is the
4409    /// half every one of the four managers can be asked for. The failure-count
4410    /// reset window has no representation at all in Task Scheduler or launchd,
4411    /// so a `RestartPolicy` read back from them would carry one number the
4412    /// manager reported and one this module invented — and a comparison against
4413    /// an invented number is a comparison that cannot fail.
4414    pub restart_delay: Option<Duration>,
4415}
4416
4417impl Registration {
4418    /// The executable the manager will start, parsed out of the command line.
4419    #[must_use]
4420    pub fn binary(&self) -> Option<PathBuf> {
4421        executable_from_command_line(&self.command_line)
4422    }
4423}
4424
4425/// One platform's service manager, for one start-mode domain.
4426///
4427/// Every method takes the identity rather than storing it, so one control can
4428/// answer about the product's registration and about a test fixture's without
4429/// either being able to reach the other by accident.
4430pub trait ServiceControl: fmt::Debug {
4431    /// Which manager this is.
4432    fn manager(&self) -> DefinitionKind;
4433
4434    /// Registers the plan, returning the definition that was applied.
4435    ///
4436    /// # Errors
4437    ///
4438    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`].
4439    fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
4440
4441    /// Deregisters. Returns whether there was a registration to remove.
4442    ///
4443    /// **Removes the registration and nothing else.** No implementation of this
4444    /// method may delete a directory, a database, a secret, or a cache;
4445    /// `05-infrastructure.md` item 5 is a property of every backend, not a
4446    /// check somewhere above them.
4447    ///
4448    /// # Errors
4449    ///
4450    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`].
4451    fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
4452
4453    /// What the manager knows about this registration, if anything.
4454    ///
4455    /// # Errors
4456    ///
4457    /// [`ServiceError::Control`].
4458    fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
4459
4460    /// Starts it now, without waiting for the next boot or logon.
4461    ///
4462    /// # Errors
4463    ///
4464    /// [`ServiceError::Control`], [`ServiceError::NeedsElevation`], or
4465    /// [`ServiceError::NotInstalled`].
4466    fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
4467
4468    /// Stops it. Returns whether it was running.
4469    ///
4470    /// # Errors
4471    ///
4472    /// As [`ServiceControl::start`].
4473    fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
4474}
4475
4476/// Chooses the control for a start mode.
4477///
4478/// The indirection is what makes [`ServiceOperations`] testable: production
4479/// hands it [`HostControls`], every test in this file hands it
4480/// [`RecordingControls`], and neither knows the difference.
4481pub trait ControlFactory: fmt::Debug + Send + Sync {
4482    /// The control for one start-mode domain.
4483    ///
4484    /// # Errors
4485    ///
4486    /// [`ServiceError::Control`] when this host has no manager for that domain.
4487    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
4488
4489    /// The text of an installed definition file, for drift detection. `None`
4490    /// when there is none to read. The default reads nothing, so a double
4491    /// never reports the real host's unit as its own.
4492    fn installed_definition(&self, _path: &Path) -> Option<String> {
4493        None
4494    }
4495
4496    /// Whether this Mac signs somebody in after a restart, and whether
4497    /// FileVault holds it at the unlock screen. `None` where the platform has
4498    /// no such probe; the default probes nothing, for the same reason as
4499    /// [`Self::installed_definition`].
4500    fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
4501        None
4502    }
4503}
4504
4505/// The real service managers of the host this binary was built for.
4506#[derive(Debug, Clone, Copy, Default)]
4507pub struct HostControls;
4508
4509// ---------------------------------------------------------------------------
4510// The operations
4511// ---------------------------------------------------------------------------
4512
4513/// What `install` did.
4514#[derive(Debug, Clone)]
4515pub struct Installed {
4516    /// The plan that was applied, including the resolved absolute binary path.
4517    pub plan: InstallPlan,
4518    /// The definition the platform was given.
4519    pub definition: ServiceDefinition,
4520    /// The record that was written.
4521    pub record: InstallRecord,
4522    /// What the definition grants, measured against the requirement.
4523    pub review: PrivilegeReview,
4524    /// What was done to the runner root the registration will run jobs under.
4525    ///
4526    /// Named trustees rather than SIDs, so printing it adds no identity to the
4527    /// output. See [`crate::runner_root_access`].
4528    pub runner_root: RootAccessSummary,
4529    /// Whether this replaced a registration that was already there, rather than
4530    /// making a new one.
4531    ///
4532    /// Reported rather than glossed over: "Service installed" on a host that
4533    /// had one already reads as though nothing had been taken away and put
4534    /// back, and what was taken away and put back is the thing an operator
4535    /// watching a running agent wants to know about.
4536    pub replaced_existing: bool,
4537}
4538
4539/// What `uninstall` did — and, as importantly, what it did not.
4540#[derive(Debug, Clone, PartialEq, Eq)]
4541pub struct Uninstalled {
4542    /// Whether a registration was removed.
4543    pub removed_registration: bool,
4544    /// Whether the install record was removed.
4545    pub removed_record: bool,
4546    /// Whether a definition file was removed, and which.
4547    pub removed_definition: Option<PathBuf>,
4548    /// The directories that were **left exactly as they were**.
4549    ///
4550    /// `05-infrastructure.md` item 5 stated as a value rather than as a
4551    /// promise: `service uninstall` prints this list, so an operator can see
4552    /// that the configuration, the SQLite database, the stored token and the
4553    /// runner package cache are all still there.
4554    pub preserved: Vec<PathBuf>,
4555}
4556
4557impl fmt::Display for Uninstalled {
4558    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4559        if self.removed_registration {
4560            writeln!(f, "The service registration was removed.")?;
4561        } else {
4562            writeln!(f, "There was no service registration to remove.")?;
4563        }
4564        writeln!(f, "Nothing else was deleted. These are untouched:")?;
4565        for path in &self.preserved {
4566            writeln!(f, "  {}", path.display())?;
4567        }
4568        write!(
4569            f,
4570            "The stored GitHub token is untouched too; `auth logout` is what purges it."
4571        )
4572    }
4573}
4574
4575/// What `set_start_mode` did.
4576#[derive(Debug, Clone, PartialEq, Eq)]
4577pub struct StartModeChange {
4578    /// What it was.
4579    pub from: StartMode,
4580    /// What it is now.
4581    pub to: StartMode,
4582    /// `false` when the registration was already in the requested mode.
4583    pub changed: bool,
4584    /// The secret store the new mode obliges, so a caller can tell an operator
4585    /// whether the token has to move too.
4586    pub store_scope: crate::secrets::SecretScope,
4587    /// What the mode change did to the runner root's access control.
4588    ///
4589    /// A mode change moves the account the daemon runs as, and the runner root
4590    /// admits that account by name — so the two move together or the new
4591    /// registration cannot write its own workspaces.
4592    pub runner_root: RootAccessSummary,
4593}
4594
4595impl fmt::Display for StartModeChange {
4596    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4597        if !self.changed {
4598            return write!(f, "The service already starts at {}.", self.to);
4599        }
4600        write!(
4601            f,
4602            "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
4603             if the token was stored under the other scope, run `auth login` again. {}",
4604            self.to, self.from, self.store_scope, self.runner_root
4605        )
4606    }
4607}
4608
4609/// Install, uninstall, inspect, and switch start mode.
4610///
4611/// This is the whole of the library contract `f3` builds `service install`,
4612/// `service uninstall` and `service status` on. Nothing here prints; every
4613/// operation returns a value with a [`fmt::Display`] a command can write, so the
4614/// same facts are available to the TUI and to a JSON document without being
4615/// re-derived from text.
4616#[derive(Debug, Clone)]
4617pub struct ServiceOperations {
4618    paths: AppPaths,
4619    identity: ServiceIdentity,
4620    controls: std::sync::Arc<dyn ControlFactory>,
4621    runner_root: Option<LocalAbsolutePath>,
4622}
4623
4624impl ServiceOperations {
4625    /// Operates on this host's real service managers.
4626    #[must_use]
4627    pub fn on_this_host(paths: AppPaths) -> Self {
4628        Self::with_controls(
4629            paths,
4630            ServiceIdentity::product(),
4631            std::sync::Arc::new(HostControls),
4632        )
4633    }
4634
4635    /// Operates on the controls a caller supplies.
4636    ///
4637    /// Both other arguments are explicit for the same reason: the privileged
4638    /// installer tests register a real service under a
4639    /// [`ServiceIdentity::fixture`] name, against a disposable
4640    /// [`AppPaths::rooted_at`] tree, and must not be able to touch an operator's
4641    /// installation even by mistake.
4642    #[must_use]
4643    pub fn with_controls(
4644        paths: AppPaths,
4645        identity: ServiceIdentity,
4646        controls: std::sync::Arc<dyn ControlFactory>,
4647    ) -> Self {
4648        Self {
4649            paths,
4650            identity,
4651            controls,
4652            runner_root: None,
4653        }
4654    }
4655
4656    /// Points runner-root preparation at a directory the caller owns, for the
4657    /// **current account**.
4658    ///
4659    /// **Only a [`ServiceIdentity::fixture`] registration is allowed to move
4660    /// it, and the guard is here rather than at the call site.** A product
4661    /// registration ignores the override entirely and resolves
4662    /// [`crate::runner_root::default_runner_root`] itself, which is what keeps
4663    /// "custom roots are never re-ACLed" true no matter what a caller passes.
4664    ///
4665    /// It exists because the directory the product uses is
4666    /// `%SystemDrive%\rman`, and a smoke test that created and re-permissioned
4667    /// *that* would be editing the machine it runs on from outside its own
4668    /// fixture. So a fixture aims this at a temporary directory it created and
4669    /// will delete — and "and will delete" is why the override is always
4670    /// reconciled for the calling account rather than for the account the start
4671    /// mode obliges. A boot-mode root admits `SY` and `BA` only, which an
4672    /// ordinary filtered token is neither, so a test that supplied one could
4673    /// not then inspect or remove its own temporary directory.
4674    #[must_use]
4675    pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
4676        self.runner_root = Some(root);
4677        self
4678    }
4679
4680    /// The directories this operates against.
4681    #[must_use]
4682    pub const fn paths(&self) -> &AppPaths {
4683        &self.paths
4684    }
4685
4686    /// The registration this operates on.
4687    #[must_use]
4688    pub const fn identity(&self) -> &ServiceIdentity {
4689        &self.identity
4690    }
4691
4692    /// Registers `daemon run` with the operating system.
4693    ///
4694    /// The order is the contract, and each step is a requirement:
4695    ///
4696    /// 1. the four directories are created, because item 2 is about writing them;
4697    /// 2. **the single-instance lock is taken** — item 1. It is held for the
4698    ///    whole of the install rather than probed and released, so a daemon that
4699    ///    starts halfway through cannot end up racing a registration;
4700    /// 3. neither start-mode domain already holds a registration — item 6's
4701    ///    record would otherwise describe one of two;
4702    /// 4. the binary path is resolved and confirmed to be a file — item 6;
4703    /// 5. the platform registers it;
4704    /// 6. the record is written.
4705    ///
4706    /// # Errors
4707    ///
4708    /// [`ServiceError::LockHeld`] when an agent is already running,
4709    /// [`ServiceError::AlreadyInstalled`], [`ServiceError::BinaryMissing`], and
4710    /// whatever the platform reports.
4711    pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
4712        self.paths
4713            .create_all()
4714            .map_err(|source| ServiceError::Paths {
4715                source: Box::new(source),
4716            })?;
4717
4718        // Item 1. The guard lives until the end of this function; dropping it is
4719        // the release, so an early return releases it too.
4720        let _guard = self.refuse_while_an_agent_runs()?;
4721        let adopted = self.adopt_boot_installation(request.start_mode())?;
4722        // Replacing applies to this account's own registration only: over
4723        // another installation's it would re-register that service against
4724        // this account's directories.
4725        if !adopted {
4726            self.refuse_foreign("replace")?;
4727        }
4728
4729        // --------------------------------------------------------------------
4730        // AN INSTALL OVER THE SAME START MODE REPLACES; IT DOES NOT REFUSE.
4731        // --------------------------------------------------------------------
4732        // This used to refuse any existing registration, and that was wrong in
4733        // a way that took a real host down.
4734        //
4735        // `service install` is *also* how an operator moves to a new version by
4736        // hand — `install_owned_copy` says so, and the `.old` rename it does
4737        // exists precisely so that reinstalling over a **running** service
4738        // works. So the caller replaces the copy the service runs and *then*
4739        // asks to register it. Refusing at that point does not undo the swap:
4740        // the daemon is left running a binary nobody registered, the command
4741        // reports failure, and on macOS the new binary is a program the stored
4742        // credential's keychain grant does not name. Watched on 0.1.17: the
4743        // copy under `state/bin` was replaced at 00:16, `service install` said
4744        // "already registered", and the daemon crash-looped on `-25293` from
4745        // then on.
4746        //
4747        // Registering the same service twice is not what an operator is asking
4748        // for here and is not what they get: the platform is asked to drop the
4749        // registration and take it again, which is a deregister followed by a
4750        // register because that is the only thing launchd, systemd and the SCM
4751        // all support — `launchctl bootstrap` refuses a label already loaded.
4752        //
4753        // The *other* mode is still refused. Moving between boot and login
4754        // moves the registration between two service managers and changes the
4755        // account and the secret store with it; `set_start_mode` does that,
4756        // carefully, and an install is not the place for it.
4757        let (replacing, previous_was_running) = match self.find_registration()? {
4758            Some((existing, registration)) if existing == request.start_mode() => {
4759                (true, registration.running)
4760            }
4761            Some((existing, _)) => {
4762                return Err(ServiceError::AlreadyInstalled {
4763                    name: self.identity.name().to_string(),
4764                    existing,
4765                    requested: request.start_mode(),
4766                });
4767            }
4768            None => (false, false),
4769        };
4770
4771        let plan = InstallPlan::resolve(
4772            self.identity.clone(),
4773            request,
4774            ServiceDirectories::of(&self.paths),
4775        )?;
4776
4777        // Resolved before the runner root, and used after it: every step from
4778        // here on has to be able to undo the directory, and a `?` between the
4779        // preparation and the first fallible use would leave one behind with
4780        // nothing to say so.
4781        let control = self.controls.control(plan.start_mode())?;
4782
4783        // Item 8, and the one step that can refuse an otherwise valid install.
4784        // Before the platform is asked to register anything, because a
4785        // registration whose workspaces would land in a directory ordinary local
4786        // users can write is one that should never have existed — and because
4787        // undoing a directory is cheaper than undoing a service.
4788        let root = self.prepare_runner_root(plan.start_mode())?;
4789
4790        // Read before the registration is dropped, so that a replace which then
4791        // fails to take can put back what was there. A record this account
4792        // cannot read leaves nothing to reinstate, which is reported rather
4793        // than pretended: see `reinstate`.
4794        let previous = if replacing {
4795            InstallRecord::read(&self.paths).ok().flatten()
4796        } else {
4797            None
4798        };
4799        if replacing && let Err(cause) = control.uninstall(&self.identity) {
4800            return Err(undo_runner_root(&root, "install", &self.identity, cause));
4801        }
4802
4803        let definition = match control.install(&plan) {
4804            Ok(definition) => definition,
4805            Err(cause) => {
4806                // Nothing was deregistered on a first install, so there is
4807                // nothing to put back and `reinstate` says so with `Ok(())`.
4808                let restored =
4809                    self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running);
4810                return Err(rolled_back(
4811                    retained_runner_root(&root),
4812                    restored,
4813                    "install",
4814                    &self.identity,
4815                    cause,
4816                ));
4817            }
4818        };
4819        let review = review_least_privilege(&definition, &plan);
4820        let record = InstallRecord::of(&plan, &definition, Utc::now());
4821        if let Err(cause) = record.write(&self.paths) {
4822            let rollback = control
4823                .uninstall(&self.identity)
4824                .map(|_| ())
4825                .and_then(|()| {
4826                    self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4827                });
4828            return Err(rolled_back(
4829                retained_runner_root(&root),
4830                rollback,
4831                "install",
4832                &self.identity,
4833                cause,
4834            ));
4835        }
4836
4837        // Registration is not the postcondition of an install: a service the
4838        // current session still has to start by hand is only half-installed.
4839        // This matters most for a logon-triggered Windows task because its
4840        // trigger has already passed by the time an operator reinstalls it.
4841        // Start through the same control that registered the definition and
4842        // roll the whole replacement back if the manager refuses it.
4843        if let Err(cause) = control.start(&self.identity) {
4844            let remove_new = control.uninstall(&self.identity).map(|_| ());
4845            let restore_registration = remove_new.and_then(|()| {
4846                self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4847            });
4848            let restore_record = restore_registration.and_then(|()| match &previous {
4849                Some(previous) => previous.write(&self.paths),
4850                None => InstallRecord::remove(&self.paths).map(|_| ()),
4851            });
4852            return Err(rolled_back(
4853                retained_runner_root(&root),
4854                restore_record,
4855                "install and start",
4856                &self.identity,
4857                cause,
4858            ));
4859        }
4860        Ok(Installed {
4861            plan,
4862            definition,
4863            record,
4864            review,
4865            runner_root: root.summary().clone(),
4866            replaced_existing: replacing,
4867        })
4868    }
4869
4870    /// Puts back a registration this call deregistered in order to replace it.
4871    ///
4872    /// `Ok(())` when there was nothing to put back, which is the ordinary case:
4873    /// a first install deregisters nothing. When there *was* a record, the plan
4874    /// is rebuilt from it rather than from the request, because the request is
4875    /// the one that just failed.
4876    ///
4877    /// Best effort by construction — the caller passes the result to
4878    /// [`rolled_back`], which reports a failed rollback alongside the original
4879    /// cause rather than replacing it.
4880    fn reinstate(
4881        &self,
4882        control: &dyn ServiceControl,
4883        previous: Option<&InstallRecord>,
4884        was_running: bool,
4885    ) -> Result<(), ServiceError> {
4886        let Some(record) = previous else {
4887            return Ok(());
4888        };
4889        control.install(&record.plan(self.identity.clone()))?;
4890        if was_running {
4891            control.start(&self.identity)?;
4892        }
4893        Ok(())
4894    }
4895
4896    /// Deregisters, and deletes nothing else.
4897    ///
4898    /// # Errors
4899    ///
4900    /// Whatever the platform reports. A missing registration is **not** an
4901    /// error: `service uninstall` on a host that has none should say so and
4902    /// exit cleanly, because an operator running it twice has not made a
4903    /// mistake.
4904    pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
4905        let record = InstallRecord::read(&self.paths).ok().flatten();
4906        let removed_definition = record
4907            .as_ref()
4908            .and_then(|record| record.definition_path.clone());
4909
4910        let mut removed_registration = false;
4911        // Both domains, not only the recorded one. A record can be lost while a
4912        // registration survives, and leaving a registration behind because a
4913        // TOML file was deleted is exactly the state `uninstall` exists to end.
4914        //
4915        // Except another installation's: it is skipped, and when it is all
4916        // there is, the uninstall is refused and this account's record kept.
4917        let mut foreign = None;
4918        for mode in [StartMode::Boot, StartMode::Login] {
4919            let control = self.controls.control(mode)?;
4920            if let Some(directories) = control
4921                .query(&self.identity)
4922                .ok()
4923                .flatten()
4924                .and_then(|registration| self.foreign_directories(&registration))
4925            {
4926                foreign = Some(directories);
4927                continue;
4928            }
4929            if control.uninstall(&self.identity)? {
4930                removed_registration = true;
4931            }
4932        }
4933        if !removed_registration && let Some(directories) = foreign {
4934            return Err(self.foreign_refusal("uninstall", directories));
4935        }
4936        let removed_record = InstallRecord::remove(&self.paths)?;
4937        Ok(Uninstalled {
4938            removed_registration,
4939            removed_record,
4940            removed_definition: removed_definition.filter(|_| removed_registration),
4941            preserved: self
4942                .paths
4943                .all()
4944                .iter()
4945                .map(|(_, path)| (*path).to_path_buf())
4946                .collect(),
4947        })
4948    }
4949
4950    /// Switches between `boot` and `login` **without re-resolving anything**.
4951    ///
4952    /// `05-infrastructure.md` item 7. Everything the new registration carries —
4953    /// the absolute binary path, the arguments, the restart policy, the four
4954    /// directories — comes from the existing record, so the operator is not
4955    /// asked to reinstall the product and a binary that has since moved is not
4956    /// silently swapped for whichever one happens to be running this command.
4957    ///
4958    /// On Windows the registration necessarily moves between two different
4959    /// Windows facilities, because Windows has no service that starts at logon;
4960    /// see this module's documentation. That is still not a reinstall: no file
4961    /// is downloaded, replaced, or re-resolved.
4962    ///
4963    /// The caller must persist the new mode onto this host's `Host` record so
4964    /// that `host show` reports it — [`StartModeChange::to`] is the value, and
4965    /// `f1` already emits `host.service_start_mode` from that field.
4966    ///
4967    /// # Errors
4968    ///
4969    /// [`ServiceError::NotInstalled`] when there is no record to switch, and
4970    /// whatever the platform reports.
4971    pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
4972        let Some(record) = InstallRecord::read(&self.paths)? else {
4973            return Err(ServiceError::NotInstalled {
4974                name: self.identity.name().to_string(),
4975                operation: "switch the start mode of",
4976            });
4977        };
4978        let from = record.start_mode;
4979        if from == to {
4980            // Nothing moves, so nothing about the root's access control has to.
4981            // Reconciling it here would turn a no-op command into one that can
4982            // fail on a permission it does not need.
4983            return Ok(StartModeChange {
4984                from,
4985                to,
4986                changed: false,
4987                store_scope: crate::secrets::SecretScope::for_start_mode(to),
4988                runner_root: RootAccessSummary::NotApplicable,
4989            });
4990        }
4991
4992        // A real switch only: a no-op touches no service manager at all. Over
4993        // another installation's registration it would move that service.
4994        self.refuse_foreign("switch the start mode of")?;
4995
4996        #[cfg(windows)]
4997        let arguments = {
4998            let mut arguments = record.arguments.clone();
4999            arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
5000            if to == StartMode::Boot {
5001                arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
5002            }
5003            arguments
5004        };
5005        #[cfg(not(windows))]
5006        let arguments = record.arguments.clone();
5007        let plan = InstallPlan::unchecked(
5008            self.identity.clone(),
5009            to,
5010            record.binary.clone(),
5011            record.directories.clone(),
5012        )
5013        .with_arguments(arguments)
5014        .with_restart(record.restart());
5015        let plan = if record.starts_on_demand {
5016            plan.started_on_demand()
5017        } else {
5018            plan
5019        };
5020        let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
5021            Ok(store) => plan.with_secret_guard(store.guard()),
5022            Err(_) => plan,
5023        };
5024
5025        // Install the target domain before touching the live one. This makes a
5026        // failed target install a no-op from the operator's point of view and,
5027        // unlike uninstall-first ordering, never trades a working service for
5028        // an error message. Resolved before the runner root and used after it,
5029        // so that no `?` sits between preparing the directory and the first
5030        // step that knows how to undo it.
5031        let target = self.controls.control(to)?;
5032        // The domain being left, resolved here rather than where it is used for
5033        // the same reason: the only step that removes it runs after the runner
5034        // root has been prepared, and a `?` there would abandon the target
5035        // registration, the record and the directory without a word.
5036        let previous = self.controls.control(from)?;
5037
5038        // The account changes with the mode, and so must the account the runner
5039        // root admits: `04-security-recovery.md` requires the selected identity
5040        // to be *reconciled* when service mode changes, which means adding the
5041        // operator's on the way to login and dropping it again on the way back.
5042        // Before the target install, for the same reason `install` does it
5043        // first — a root that cannot be made safe must not produce a working
5044        // registration.
5045        let root = self.prepare_runner_root(to)?;
5046
5047        let definition = match target.install(&plan) {
5048            Ok(definition) => definition,
5049            Err(cause) => {
5050                return Err(undo_runner_root(
5051                    &root,
5052                    "switch start mode",
5053                    &self.identity,
5054                    cause,
5055                ));
5056            }
5057        };
5058        let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
5059        if let Err(cause) = next_record.write(&self.paths) {
5060            return Err(rolled_back(
5061                retained_runner_root(&root),
5062                target.uninstall(&self.identity),
5063                "switch start mode",
5064                &self.identity,
5065                cause,
5066            ));
5067        }
5068
5069        // Only after the target registration and its durable record exist is
5070        // it safe to remove the old domain. If that last step fails, remove the
5071        // target and put the old record back so status and reality agree.
5072        if let Err(cause) = previous.uninstall(&self.identity) {
5073            let target_rollback = target.uninstall(&self.identity);
5074            let record_rollback = record.write(&self.paths);
5075            return Err(rolled_back(
5076                retained_runner_root(&root),
5077                target_rollback.and(record_rollback),
5078                "switch start mode",
5079                &self.identity,
5080                cause,
5081            ));
5082        }
5083        Ok(StartModeChange {
5084            from,
5085            to,
5086            changed: true,
5087            store_scope: crate::secrets::SecretScope::for_start_mode(to),
5088            runner_root: root.summary().clone(),
5089        })
5090    }
5091
5092    /// Starts the registration now.
5093    ///
5094    /// # Errors
5095    ///
5096    /// [`ServiceError::NotInstalled`], or whatever the platform reports.
5097    pub fn start(&self) -> Result<(), ServiceError> {
5098        let mode = self.own_registration_mode("start")?;
5099        self.controls.control(mode)?.start(&self.identity)
5100    }
5101
5102    /// The domain holding this account's own registration, for `operation`.
5103    ///
5104    /// # Errors
5105    ///
5106    /// [`ServiceError::NotInstalled`] when there is none, and
5107    /// [`ServiceError::ForeignInstallation`] when the only one is another
5108    /// installation's.
5109    fn own_registration_mode(&self, operation: &'static str) -> Result<StartMode, ServiceError> {
5110        match self.registration_for_status()? {
5111            None => Err(ServiceError::NotInstalled {
5112                name: self.identity.name().to_string(),
5113                operation,
5114            }),
5115            Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
5116            Some((registration, None)) => Ok(registration.start_mode),
5117        }
5118    }
5119
5120    /// Stops the registration. Returns whether it was running.
5121    ///
5122    /// # Errors
5123    ///
5124    /// As [`ServiceOperations::start`].
5125    pub fn stop(&self) -> Result<bool, ServiceError> {
5126        let mode = self.own_registration_mode("stop")?;
5127        self.controls.control(mode)?.stop(&self.identity)
5128    }
5129
5130    /// Everything Journey 5 step 4 asks `service status` to report.
5131    ///
5132    /// # Errors
5133    ///
5134    /// [`ServiceError::Record`] when local state cannot be read, and whatever
5135    /// the platform reports. A *stale binary path* is deliberately **not** an
5136    /// error here: it is a reported state, because a status command that
5137    /// refused to print anything else would hide the very facts an operator
5138    /// needs in order to fix it.
5139    ///
5140    /// A record **this account may not read** is reported for the same reason
5141    /// and is the second exception. On a boot-mode host the record was written
5142    /// by `sudo service install`, and a version before this one wrote it `0600`
5143    /// — so the operator's own `service status` ended with `Permission denied`
5144    /// and printed nothing at all, on a host whose registration the service
5145    /// manager would have described perfectly well. What launchd, systemd or
5146    /// the SCM says is a separate fact from the record, and it is still worth
5147    /// having.
5148    ///
5149    /// # A registration made from another data root
5150    ///
5151    /// The service manager holds one registration per machine under one name,
5152    /// and every account on the machine sees it. When its command line names
5153    /// application-data directories that are not this account's, it is
5154    /// **another installation's service**, and comparing this account's
5155    /// install record against it would report a stale binary, a mismatched
5156    /// start mode and a secret store in the wrong scope, each with a remedy
5157    /// (`service uninstall`, `service install`, `auth login`) that, followed
5158    /// from this account, breaks a healthy service. So it is reported as what
5159    /// it is: see [`ForeignService`].
5160    pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
5161        match self.registration_for_status()? {
5162            Some((registration, Some(directories))) => {
5163                Ok(self.foreign_status(registration, directories))
5164            }
5165            found => {
5166                let (record, record_refused) = match InstallRecord::read(&self.paths) {
5167                    Ok(record) => (record, None),
5168                    Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
5169                    Err(error) => return Err(error),
5170                };
5171                self.status_at(
5172                    &self.paths,
5173                    record,
5174                    record_refused.as_ref(),
5175                    found.map(|(registration, _)| registration),
5176                )
5177            }
5178        }
5179    }
5180
5181    /// [`Self::status`] for the installation whose directories are `paths`,
5182    /// from the record already read there.
5183    fn status_at(
5184        &self,
5185        paths: &AppPaths,
5186        record: Option<InstallRecord>,
5187        record_refused: Option<&ServiceError>,
5188        found: Option<Registration>,
5189    ) -> Result<ServiceStatus, ServiceError> {
5190        let last_github_contact = last_github_contact(paths)?;
5191        let drift = record
5192            .as_ref()
5193            .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, record));
5194        let credential_rejected_since = github_credential_rejected_since(paths).ok().flatten();
5195        let credential_unreadable_since = credential_unreadable_since(paths).ok().flatten();
5196        let probed = self.controls.unattended_login();
5197        let unattended = record.as_ref().and_then(|record| {
5198            // The account is only read where something was probed to judge
5199            // it against.
5200            let account = probed.as_ref().map_or_else(String::new, |_| {
5201                found
5202                    .as_ref()
5203                    .and_then(|registration| registration.account.clone())
5204                    .unwrap_or_else(|| service_account_name(&record.account))
5205            });
5206            unattended_gap(record.start_mode, &account, probed.as_ref())
5207        });
5208        let now = Utc::now();
5209        let installed = record.is_some() || found.is_some();
5210        let status = ServiceStatus::compose(
5211            self.identity.clone(),
5212            record,
5213            record_refused,
5214            found,
5215            last_github_contact,
5216            paths,
5217        )
5218        .with_definition_drift(drift)
5219        .with_credential_rejection(credential_rejected_since)
5220        .with_credential_unreadable(credential_unreadable_since)
5221        .with_launches_blocked(crate::launch_health::launches_blocked(paths, now))
5222        .with_unattended_gap(unattended)
5223        .with_host_unfit(
5224            crate::host_fitness::host_unfit_in_force(paths, now).filter(|_| installed),
5225        );
5226        let restart = status.restart_command();
5227        Ok(status.with_stall(
5228            crate::daemon_heartbeat::liveness(paths, now).stall(last_github_contact, &restart),
5229        ))
5230    }
5231
5232    /// The installed definition, when it is not what this build renders for
5233    /// the recorded installation. See [`definition_drift`].
5234    ///
5235    /// # Errors
5236    ///
5237    /// Whatever reading the install record reports.
5238    pub fn definition_drift(&self) -> Result<Option<DefinitionDrift>, ServiceError> {
5239        Ok(InstallRecord::read(&self.paths)?
5240            .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, &record)))
5241    }
5242
5243    /// Writes the plist this build renders over an outdated one, then has a
5244    /// detached helper boot the job out and bootstrap it again from the new
5245    /// file.
5246    ///
5247    /// launchd reads a plist only when the job is bootstrapped. Rewriting the
5248    /// file changes nothing for a job that is already loaded, and a `KeepAlive`
5249    /// restart reuses the loaded copy. So the job has to be booted out, and
5250    /// that terminates the daemon that asked for it. The helper runs in a
5251    /// session of its own, which launchd does not kill with the job, and
5252    /// retries the bootstrap: launchd refuses it for a few seconds while the
5253    /// old job is still exiting (`5: Input/output error`, measured).
5254    ///
5255    /// Call this only from the daemon the job runs, and only while it holds no
5256    /// runner. It is an operation on the registration the daemon itself runs
5257    /// under, and it never changes the start mode.
5258    ///
5259    /// # Errors
5260    ///
5261    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`] when the
5262    /// plist cannot be written or the helper cannot be started. The loaded job
5263    /// is untouched in both cases.
5264    #[cfg(target_os = "macos")]
5265    pub fn reload_launchd_definition(&self, drift: &DefinitionDrift) -> Result<(), ServiceError> {
5266        // The record names the start mode; the account this process runs as has
5267        // to agree, or the record describes a registration other than the one
5268        // running, and root would end up rewriting an operator's LaunchAgent.
5269        // SAFETY: `getuid` reads the calling process's real user id and cannot
5270        // fail.
5271        let root = unsafe { libc::getuid() } == 0;
5272        if root != (drift.start_mode == StartMode::Boot) {
5273            return Err(ServiceError::Control {
5274                operation: "rewrite",
5275                name: self.identity.name().to_string(),
5276                manager: "launchd",
5277                detail: format!(
5278                    "the install record says --start-at {}, but this daemon runs as {}; the plist \
5279                     is left as it is",
5280                    drift.start_mode,
5281                    if root { "root" } else { "the operator" }
5282                ),
5283            });
5284        }
5285        replace_definition(self.identity.name(), drift)?;
5286        // The definition is now this build's, as after the WSL unit rewrite.
5287        // Best effort: the record says who wrote the plist and nothing reads it
5288        // to decide whether to repair.
5289        if let Ok(Some(mut record)) = InstallRecord::read(&self.paths) {
5290            record.installed_by_version = env!("CARGO_PKG_VERSION").to_string();
5291            let _ = record.write(&self.paths);
5292        }
5293        sys::reload_detached(&self.identity, drift.start_mode, &drift.path)
5294    }
5295
5296    /// Creates or reconciles the runner root this start mode's account needs.
5297    ///
5298    /// The account is not an argument: it is [`ServiceAccount::for_start_mode`],
5299    /// the same function the registration's own principal comes from, so the
5300    /// directory admits exactly the identity the definition registers and a mode
5301    /// change reconciles both together or neither.
5302    ///
5303    /// On macOS and Linux this is a no-op that returns
5304    /// [`RootAccessSummary::NotApplicable`]; see [`crate::runner_root_access`].
5305    fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
5306        #[cfg(not(windows))]
5307        {
5308            // `runner_root` is consumed here as well as in the Windows arm: a
5309            // field only one platform reads is a dead field on the other.
5310            let _ = (mode, &self.runner_root);
5311            Ok(RootAccessChange::not_applicable())
5312        }
5313        #[cfg(windows)]
5314        {
5315            let wrap = |source| ServiceError::RunnerRoot {
5316                source: Box::new(source),
5317            };
5318            // The test seam, and the two things allowed through it. `cfg!(test)`
5319            // is false in every shipped binary, so what a released build honours
5320            // is the fixture name alone — and a fixture name cannot be the
5321            // product's, which is what keeps a released `service install`
5322            // pointed at the platform default whatever a caller passes.
5323            //
5324            // An overridden root is always reconciled **for this account**,
5325            // which is the foreground admission — a real mode of the product
5326            // rather than a concession invented here. It has to be: a boot-mode
5327            // root admits `SY` and `BA` only, and a test process holding an
5328            // ordinary filtered token is neither, so it could not inspect the
5329            // temporary directory it just supplied nor delete it afterwards.
5330            // What that costs is that the *boot* descriptor is not proved
5331            // through this path; it is proved purely, by this module's
5332            // `the_runner_root_a_boot_registration_needs_admits_only_the_service`
5333            // and by `runner_root_access`'s own tests, and for real by the
5334            // privileged installer test, which runs elevated.
5335            if let Some(root) = self
5336                .runner_root
5337                .as_ref()
5338                .filter(|_| self.identity.is_fixture() || cfg!(test))
5339            {
5340                let admission = RootAdmission::of_this_account().map_err(wrap)?;
5341                return crate::runner_root_access::reconcile(&self.paths, root, &admission)
5342                    .map_err(wrap);
5343            }
5344
5345            let admission = match ServiceAccount::for_start_mode(mode) {
5346                // A boot registration runs as LocalSystem, which the constant
5347                // `SY` ace already names.
5348                ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
5349                // A login registration runs as this account, under a filtered
5350                // token in which Administrators is deny-only — so without an
5351                // ace of its own it would be admitted by nothing.
5352                ServiceAccount::InvokingUser | ServiceAccount::Root => {
5353                    RootAdmission::of_this_account().map_err(wrap)?
5354                }
5355            };
5356            crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
5357        }
5358    }
5359
5360    /// Takes the single-instance lock, or refuses with `d1`'s own message.
5361    fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
5362        HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
5363            |source| match source {
5364                held @ LockError::Held { .. } => ServiceError::LockHeld {
5365                    source: Box::new(held),
5366                },
5367                other => ServiceError::LockUnreadable {
5368                    source: Box::new(other),
5369                },
5370            },
5371        )
5372    }
5373
5374    /// The registration, in whichever domain holds it.
5375    fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
5376        for mode in [StartMode::Boot, StartMode::Login] {
5377            let control = self.controls.control(mode)?;
5378            if let Some(registration) = control.query(&self.identity)? {
5379                return Ok(Some((registration.start_mode, registration)));
5380            }
5381        }
5382        Ok(None)
5383    }
5384
5385    /// The registration [`Self::status`] describes, with the directories it
5386    /// runs against when they are not this account's: this account's own
5387    /// registration when either domain holds one, else whichever there is.
5388    ///
5389    /// [`Self::find_registration`] stops at the boot domain, so a boot service
5390    /// made from another data root would hide this account's own login
5391    /// registration. The other domain is asked only in that case, and a
5392    /// failure to ask it is not this status's failure.
5393    fn registration_for_status(
5394        &self,
5395    ) -> Result<Option<(Registration, Option<ServiceDirectories>)>, ServiceError> {
5396        let Some((mode, found)) = self.find_registration()? else {
5397            return Ok(None);
5398        };
5399        let Some(directories) = self.foreign_directories(&found) else {
5400            return Ok(Some((found, None)));
5401        };
5402        let other = match mode {
5403            StartMode::Boot => StartMode::Login,
5404            StartMode::Login => StartMode::Boot,
5405        };
5406        let own = self
5407            .controls
5408            .control(other)
5409            .ok()
5410            .and_then(|control| control.query(&self.identity).ok().flatten())
5411            .filter(|registration| self.foreign_directories(registration).is_none());
5412        Ok(Some(match own {
5413            Some(own) => (own, None),
5414            None => (found, Some(directories)),
5415        }))
5416    }
5417
5418    /// Moves a Linux boot service registered against another tree onto this
5419    /// one, which for `root` is the system tree. Returns whether it did.
5420    ///
5421    /// `service install` calls this before it opens anything in this tree, so
5422    /// that the copy lands in an empty one; the install itself calls it again,
5423    /// which then only confirms the registration is to be replaced.
5424    ///
5425    /// Before the system tree, a boot service ran against whichever tree
5426    /// `sudo` resolved: `/root`'s on some distributions, the operator's own on
5427    /// others. A boot unit with no capabilities cannot open the operator's, and
5428    /// `ProtectHome=` hides both, so the service is stopped, its configuration
5429    /// and state are copied here unless this tree already has its own, and the
5430    /// install then replaces the registration. The old tree is left as it was.
5431    ///
5432    /// # Errors
5433    ///
5434    /// Whatever stopping the old service or copying its tree reports.
5435    pub fn adopt_boot_installation(&self, mode: StartMode) -> Result<bool, ServiceError> {
5436        #[cfg(target_os = "linux")]
5437        return self.adopt_boot_installation_on_linux(mode);
5438        #[cfg(not(target_os = "linux"))]
5439        {
5440            let _ = mode;
5441            Ok(false)
5442        }
5443    }
5444
5445    #[cfg(target_os = "linux")]
5446    fn adopt_boot_installation_on_linux(&self, mode: StartMode) -> Result<bool, ServiceError> {
5447        if mode != StartMode::Boot {
5448            return Ok(false);
5449        }
5450        self.refuse_overridden_account()?;
5451        if !crate::host_fitness::is_elevated() {
5452            return Ok(false);
5453        }
5454        let Some((StartMode::Boot, found)) = self.find_registration()? else {
5455            return Ok(false);
5456        };
5457        let Some(old) = self.foreign_directories(&found) else {
5458            return Ok(false);
5459        };
5460        self.controls
5461            .control(StartMode::Boot)?
5462            .stop(&self.identity)?;
5463        // No service was ever installed against this tree, so whatever is in it
5464        // is what an interrupted attempt or a stray command left: set aside,
5465        // never deleted, in favour of the data the running service used. A
5466        // tree with its own install record keeps it.
5467        if matches!(InstallRecord::read(&self.paths), Ok(None)) {
5468            let stamp = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
5469            for (from, to, skip) in [
5470                (&old.config, self.paths.config_dir(), None),
5471                // `service install` copies the binary in again.
5472                (&old.state, self.paths.state_dir(), Some("bin")),
5473            ] {
5474                set_aside(to, &stamp)
5475                    .and_then(|()| copy_tree(from, to, skip))
5476                    .map_err(|error| ServiceError::Control {
5477                        operation: "move the data of",
5478                        name: self.identity.name().to_string(),
5479                        manager: "systemd",
5480                        detail: format!(
5481                            "cannot copy {} to {}: {error}",
5482                            from.display(),
5483                            to.display()
5484                        ),
5485                    })?;
5486            }
5487        }
5488        Ok(true)
5489    }
5490
5491    /// Refuses a Linux boot install over a unit an administrator's drop-in made
5492    /// run as another account.
5493    ///
5494    /// A drop-in outlives the unit file `service install` rewrites, so the new
5495    /// unit would run as that account too, and that account cannot open the
5496    /// system tree `root` owns. Said before anything is stopped or copied.
5497    #[cfg(target_os = "linux")]
5498    fn refuse_overridden_account(&self) -> Result<(), ServiceError> {
5499        let Some((StartMode::Boot, found)) = self.find_registration()? else {
5500            return Ok(());
5501        };
5502        match found.account.as_deref() {
5503            Some(account) if account != ServiceAccount::Root.as_str() => {
5504                Err(ServiceError::Control {
5505                    operation: "install",
5506                    name: self.identity.name().to_string(),
5507                    manager: "systemd",
5508                    detail: format!(
5509                        "a drop-in under {SYSTEMD_SYSTEM_DIR}/{}.d/ makes it run as {account}, and \
5510                         a boot service must run as root to use {}. Remove `User=` and `Group=` \
5511                         from that drop-in, run `sudo systemctl daemon-reload`, and install again",
5512                        self.identity.systemd_unit(),
5513                        crate::paths::SYSTEM_DATA_ROOT,
5514                    ),
5515                })
5516            }
5517            _ => Ok(()),
5518        }
5519    }
5520
5521    /// Refuses `operation` when the machine's registration is another
5522    /// installation's. See [`ServiceError::ForeignInstallation`].
5523    fn refuse_foreign(&self, operation: &'static str) -> Result<(), ServiceError> {
5524        match self.registration_for_status()? {
5525            Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
5526            _ => Ok(()),
5527        }
5528    }
5529
5530    /// [`ServiceError::ForeignInstallation`] for the installation at
5531    /// `directories`.
5532    fn foreign_refusal(
5533        &self,
5534        operation: &'static str,
5535        directories: ServiceDirectories,
5536    ) -> ServiceError {
5537        let foreign = self.foreign_service_at(directories);
5538        ServiceError::ForeignInstallation {
5539            name: self.identity.name().to_string(),
5540            operation,
5541            installed_by: foreign.installed_by(),
5542            act_as: foreign.act_as(),
5543        }
5544    }
5545
5546    /// The directories `registration` runs against, when they are not this
5547    /// account's. See [`ForeignService`].
5548    fn foreign_directories(&self, registration: &Registration) -> Option<ServiceDirectories> {
5549        ServiceDirectories::from_command_line(&registration.command_line)
5550            .filter(|directories| !same_directory(&directories.config, self.paths.config_dir()))
5551    }
5552
5553    /// The machine's service and the installation it belongs to, when that is
5554    /// not this account's, without reading anything of that installation's
5555    /// beyond its path. `None` when the service is this account's, when there
5556    /// is none, or when the service manager cannot be asked.
5557    #[must_use]
5558    pub fn foreign_service(&self) -> Option<ForeignService> {
5559        let (_, directories) = self.registration_for_status().ok()??;
5560        Some(self.foreign_service_at(directories?))
5561    }
5562
5563    /// The [`ForeignService`] for an installation at `directories`, before
5564    /// anything of its own is read.
5565    fn foreign_service_at(&self, directories: ServiceDirectories) -> ForeignService {
5566        let root = directories.root();
5567        let home = host_home();
5568        // This account's own installation under other directories, when the
5569        // four are exactly a layout a command of this account resolves.
5570        let this_account = home
5571            .as_deref()
5572            .filter(|home| contains_directory(home, &root))
5573            .and_then(|_| {
5574                if directories == ServiceDirectories::of(&AppPaths::rooted_at(&root)) {
5575                    Some(format!(
5576                        "with `--data-dir {}`",
5577                        quote_argument(&root.to_string_lossy())
5578                    ))
5579                } else {
5580                    AppPaths::discover()
5581                        .ok()
5582                        .filter(|standard| directories == ServiceDirectories::of(standard))
5583                        .map(|_| "without `--data-dir`".to_owned())
5584                }
5585            });
5586        ForeignService {
5587            owner: data_root_owner(&root, home.as_deref()),
5588            this_account,
5589            root,
5590            directories,
5591            local_root: ServiceDirectories::of(&self.paths).root(),
5592            local_record: InstallRecord::path(&self.paths),
5593            unused_local_record: InstallRecord::read(&self.paths).ok().flatten(),
5594            unreadable: None,
5595        }
5596    }
5597
5598    /// [`Self::status`] for a registration made from another data root.
5599    ///
5600    /// What that installation recorded is read when this account may read it,
5601    /// and the status is then exactly the one its owner would see. When it may
5602    /// not, the status holds what the service manager says and nothing it
5603    /// would have to guess.
5604    fn foreign_status(
5605        &self,
5606        registration: Registration,
5607        directories: ServiceDirectories,
5608    ) -> ServiceStatus {
5609        let service_paths = directories.paths();
5610        let mut foreign = self.foreign_service_at(directories);
5611        let unreadable = match InstallRecord::read(&service_paths) {
5612            Ok(Some(record)) => {
5613                match self.status_at(
5614                    &service_paths,
5615                    Some(record),
5616                    None,
5617                    Some(registration.clone()),
5618                ) {
5619                    Ok(status) => return status.with_foreign(foreign),
5620                    Err(error) => error.to_string(),
5621                }
5622            }
5623            Ok(None) => format!(
5624                "there is no install record at {}",
5625                InstallRecord::path(&service_paths).display()
5626            ),
5627            // Not the error's own words: `RecordNotPermitted` and
5628            // `RecordUnreadable` both end in a `service install` remedy, which
5629            // from this account would re-register the machine's service
5630            // against the wrong directories.
5631            Err(
5632                ServiceError::RecordNotPermitted { path, detail }
5633                | ServiceError::RecordUnreadable { path, detail }
5634                | ServiceError::Record { path, detail, .. },
5635            ) => format!("this account cannot read {} ({detail})", path.display()),
5636            Err(error) => error.to_string(),
5637        };
5638        foreign.unreadable = Some(unreadable);
5639        ServiceStatus::foreign_unread(self.identity.clone(), registration, foreign)
5640    }
5641
5642    /// Moves this account's own configuration aside, when no registration on
5643    /// this machine runs against it: `host forget-local`.
5644    ///
5645    /// Everything in the configuration directory -- the install record, the
5646    /// database with its host row and policies, `runner.env` and the rest -- is
5647    /// **renamed** into a `forgotten-<timestamp>` directory beside it, never
5648    /// deleted, so moving it back restores it exactly. The state, runtime and
5649    /// log directories are left alone even where the platform puts them inside
5650    /// the configuration directory (macOS does), and so is every secret store
5651    /// and every directory a registration names.
5652    ///
5653    /// Both domains are asked, strictly: a domain that cannot be asked might
5654    /// hold this account's own registration, so it refuses rather than reads
5655    /// as empty.
5656    ///
5657    /// # Errors
5658    ///
5659    /// [`ServiceError::LocalConfigurationInUse`] when a registration on this
5660    /// machine runs against these directories, when a registration from
5661    /// another data root names a directory overlapping them, or when an agent
5662    /// holds this account's single-instance lock. [`ServiceError::Record`] when
5663    /// the move fails; whatever had already moved is put back first, and
5664    /// anything that could not be put back is named.
5665    pub fn forget_local(&self, at: DateTime<Utc>) -> Result<ForgottenLocal, ServiceError> {
5666        let config = self.paths.config_dir();
5667        let in_use = |detail: String| ServiceError::LocalConfigurationInUse { detail };
5668        let mut service_root = None;
5669        for mode in [StartMode::Boot, StartMode::Login] {
5670            let registration = self
5671                .controls
5672                .control(mode)
5673                .and_then(|control| control.query(&self.identity))
5674                .map_err(|error| {
5675                    in_use(format!(
5676                        "whether a {mode} registration runs against it could not be read ({error})"
5677                    ))
5678                })?;
5679            let Some(registration) = registration else {
5680                continue;
5681            };
5682            let Some(directories) = self.foreign_directories(&registration) else {
5683                return Err(in_use(format!(
5684                    "{} holds a registration that runs against {}. `service status` describes it",
5685                    registration.manager,
5686                    config.display()
5687                )));
5688            };
5689            if let Some(overlap) = directories.all().into_iter().find(|directory| {
5690                contains_directory(directory, config) || contains_directory(config, directory)
5691            }) {
5692                return Err(in_use(format!(
5693                    "the machine's service runs against {}, which overlaps {}",
5694                    overlap.display(),
5695                    config.display()
5696                )));
5697            }
5698            service_root.get_or_insert_with(|| directories.root());
5699        }
5700        let _lock =
5701            HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(|source| {
5702                in_use(format!(
5703                    "an agent may be running against these directories: {source}"
5704                ))
5705            })?;
5706        let failed = |operation: &'static str, path: &Path| {
5707            let path = path.to_path_buf();
5708            move |error: std::io::Error| ServiceError::Record {
5709                operation,
5710                path,
5711                detail: error.to_string(),
5712            }
5713        };
5714        // Never the agent's own directories, wherever the platform nests them.
5715        let kept = [
5716            self.paths.state_dir(),
5717            self.paths.runtime_dir(),
5718            self.paths.logs_dir(),
5719        ];
5720        let mut entries = Vec::new();
5721        for entry in std::fs::read_dir(config).map_err(failed("list", config))? {
5722            let entry = entry.map_err(failed("list", config))?.path();
5723            let archived = entry
5724                .file_name()
5725                .is_some_and(|name| name.to_string_lossy().starts_with(FORGOTTEN_PREFIX));
5726            if !archived && !kept.iter().any(|kept| contains_directory(&entry, kept)) {
5727                entries.push(entry);
5728            }
5729        }
5730        if entries.is_empty() {
5731            return Ok(ForgottenLocal {
5732                archive: None,
5733                moved: entries,
5734                service_root,
5735            });
5736        }
5737        entries.sort();
5738        let archive = config.join(format!("{FORGOTTEN_PREFIX}{}", at.format("%Y%m%dT%H%M%SZ")));
5739        std::fs::create_dir(&archive).map_err(failed("create", &archive))?;
5740        let inside = |entry: &Path| archive.join(entry.file_name().unwrap_or_default());
5741        for (index, entry) in entries.iter().enumerate() {
5742            if let Err(error) = std::fs::rename(entry, inside(entry)) {
5743                let stranded: Vec<String> = entries[..index]
5744                    .iter()
5745                    .rev()
5746                    .filter(|done| std::fs::rename(inside(done), done).is_err())
5747                    .map(|done| inside(done).display().to_string())
5748                    .collect();
5749                let _ = std::fs::remove_dir(&archive);
5750                let mut detail = error.to_string();
5751                if !stranded.is_empty() {
5752                    detail.push_str(&format!(
5753                        "; these could not be put back and are still in the archive: {}",
5754                        stranded.join(", ")
5755                    ));
5756                }
5757                return Err(ServiceError::Record {
5758                    operation: "archive",
5759                    path: entry.clone(),
5760                    detail,
5761                });
5762            }
5763        }
5764        Ok(ForgottenLocal {
5765            archive: Some(archive),
5766            moved: entries,
5767            service_root,
5768        })
5769    }
5770}
5771
5772/// The name every `host forget-local` archive starts with. A later run leaves
5773/// earlier archives where they are.
5774pub const FORGOTTEN_PREFIX: &str = "forgotten-";
5775
5776/// What [`ServiceOperations::forget_local`] did.
5777#[derive(Debug, Clone, PartialEq, Eq)]
5778pub struct ForgottenLocal {
5779    /// Where the configuration went, or `None` when there was nothing to move.
5780    pub archive: Option<PathBuf>,
5781    /// What was moved, by its old path.
5782    pub moved: Vec<PathBuf>,
5783    /// The data root of the machine's service, when one runs from another
5784    /// installation. Named so the operator can see it was not touched.
5785    pub service_root: Option<PathBuf>,
5786}
5787
5788impl fmt::Display for ForgottenLocal {
5789    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5790        let Some(archive) = &self.archive else {
5791            return write!(
5792                f,
5793                "Nothing to archive: this account's configuration directory is empty."
5794            );
5795        };
5796        writeln!(f, "Archived this account's unused local configuration")?;
5797        writeln!(f, "  archive                   {}", archive.display())?;
5798        for path in &self.moved {
5799            writeln!(f, "  moved                     {}", path.display())?;
5800        }
5801        if let Some(root) = &self.service_root {
5802            writeln!(
5803                f,
5804                "  service data root         {} (untouched)",
5805                root.display()
5806            )?;
5807        }
5808        write!(
5809            f,
5810            "  restore                   move everything in the archive back into its parent \
5811             directory"
5812        )
5813    }
5814}
5815
5816/// Whether two directory spellings name the same place: the same text on
5817/// this platform's terms, or the same canonical directory.
5818fn same_directory(left: &Path, right: &Path) -> bool {
5819    let text = |path: &Path| {
5820        path.to_string_lossy()
5821            .trim_end_matches(['/', '\\'])
5822            .to_owned()
5823    };
5824    same_path_text(&text(left), &text(right))
5825        || matches!(
5826            (std::fs::canonicalize(left), std::fs::canonicalize(right)),
5827            (Ok(left), Ok(right)) if left == right
5828        )
5829}
5830
5831/// Where the remedy starts in a [`StatusProblem`]'s detail: the sentence
5832/// openings every builder above uses for it.
5833const REMEDY_OPENINGS: [&str; 8] = [
5834    ". Run ",
5835    ". If nothing is waiting",
5836    ". Re-register",
5837    ". Switch the start mode",
5838    " Sign in again",
5839    ". Fix: ",
5840    "; to do it now",
5841    "; if the service manager denies",
5842];
5843
5844/// A problem's diagnosis without its remedy. See
5845/// [`ServiceStatus::with_foreign`].
5846///
5847/// A detail that still names a command that changes the registration or the
5848/// credential after the cut -- a builder this list does not know yet -- is
5849/// reduced to the first clause rather than passed on, because the cost of
5850/// showing too little is a shorter sentence and the cost of showing too much
5851/// is a broken service.
5852fn diagnosis_only(detail: &str) -> String {
5853    let cut = REMEDY_OPENINGS
5854        .iter()
5855        .filter_map(|opening| detail.find(opening))
5856        .min()
5857        .unwrap_or(detail.len());
5858    let diagnosis = detail[..cut].trim_end_matches(['.', ' ']);
5859    let names_a_change = |text: &str| {
5860        [
5861            "service install",
5862            "service uninstall",
5863            "auth login",
5864            "launchctl",
5865            "systemctl",
5866        ]
5867        .iter()
5868        .any(|command| text.contains(command))
5869    };
5870    if !names_a_change(diagnosis) {
5871        return diagnosis.to_owned();
5872    }
5873    // Clause ends followed by a space, so a `.` inside a path does not cut.
5874    let end = [", ", "; ", ". "]
5875        .iter()
5876        .filter_map(|separator| diagnosis.find(separator))
5877        .min()
5878        .unwrap_or(diagnosis.len());
5879    let first = &diagnosis[..end];
5880    if names_a_change(first) {
5881        "the installation's own records report this".to_owned()
5882    } else {
5883        first.to_owned()
5884    }
5885}
5886
5887/// A path's components as this platform compares them: case-insensitively
5888/// on Windows.
5889fn path_key(path: &Path) -> Vec<String> {
5890    path.components()
5891        .map(|component| {
5892            let text = component.as_os_str().to_string_lossy();
5893            if cfg!(windows) {
5894                text.to_lowercase()
5895            } else {
5896                text.into_owned()
5897            }
5898        })
5899        .collect()
5900}
5901
5902/// Whether `inner` is `outer` or a directory under it, by the rule
5903/// [`same_directory`] uses: on this platform's terms, or canonically.
5904fn contains_directory(outer: &Path, inner: &Path) -> bool {
5905    path_key(inner).starts_with(&path_key(outer))
5906        || matches!(
5907            (std::fs::canonicalize(outer), std::fs::canonicalize(inner)),
5908            (Ok(outer), Ok(inner)) if inner.starts_with(&outer)
5909        )
5910}
5911
5912/// The account whose data root `root` is, when the path says: the folder
5913/// under the directory that holds this account's `home`, `root` for root's
5914/// home on Unix, and `LocalSystem` for its profile on Windows. `None` when
5915/// the path names nobody, which is reported as the path alone.
5916#[must_use]
5917pub fn data_root_owner(root: &Path, home: Option<&Path>) -> Option<String> {
5918    let target = path_key(root);
5919    if cfg!(windows) {
5920        if target
5921            .windows(3)
5922            .any(|run| run == ["system32", "config", "systemprofile"])
5923        {
5924            return Some("LocalSystem".to_owned());
5925        }
5926    } else if [
5927        "/root",
5928        "/var/root",
5929        "/private/var/root",
5930        crate::paths::SYSTEM_DATA_ROOT,
5931    ]
5932    .iter()
5933    .any(|home| root.starts_with(home))
5934    {
5935        return Some("root".to_owned());
5936    }
5937    let users = path_key(home?.parent()?);
5938    // A home directly under the root (`/root` under `sudo`) names no directory
5939    // of homes: every path would then "belong" to its first component.
5940    if users.len() < 2 {
5941        return None;
5942    }
5943    if target.len() > users.len() && target[..users.len()] == users[..] {
5944        root.components()
5945            .nth(users.len())
5946            .map(|name| name.as_os_str().to_string_lossy().into_owned())
5947    } else {
5948        None
5949    }
5950}
5951
5952/// A registration whose command line names application-data directories that
5953/// are not this account's: **another installation's service**.
5954///
5955/// Every account on a machine sees the one registration its service manager
5956/// holds under the product's name. A second account -- one that ran a
5957/// login-mode install once, say -- has an install record and a database of its
5958/// own, and comparing them against the other installation's registration used
5959/// to report a stale binary, a mismatched start mode and a secret store in the
5960/// wrong scope, with `service uninstall`, `service install` and `auth login`
5961/// as the remedies. Followed from that account, each of them breaks or
5962/// replaces a service that is healthy. So the registration is reported as the
5963/// other installation's, with what can be read of it, and this account's own
5964/// record as the unused configuration it is.
5965#[derive(Debug, Clone, PartialEq, Eq)]
5966pub struct ForeignService {
5967    /// The directories its command line names.
5968    pub directories: ServiceDirectories,
5969    /// The data root they sit under. See [`ServiceDirectories::root`].
5970    pub root: PathBuf,
5971    /// The account that data root belongs to, when its path says.
5972    pub owner: Option<String>,
5973    /// How a command run by this account reaches the installation, when it is
5974    /// this account's own under other directories -- an install made with
5975    /// `--data-dir`, seen from a command run without it, or the other way
5976    /// round. `None` for another account's.
5977    pub this_account: Option<String>,
5978    /// This account's own data root, which no registration runs against.
5979    pub local_root: PathBuf,
5980    /// Where this account's own install record is, or would be.
5981    pub local_record: PathBuf,
5982    /// This account's own install record, from an earlier install.
5983    pub unused_local_record: Option<InstallRecord>,
5984    /// Why the service's own records could not be read, or `None` when they
5985    /// were and the status describes them.
5986    pub unreadable: Option<String>,
5987}
5988
5989impl ForeignService {
5990    /// How to see the service's own details: as its owner, or elevated.
5991    #[must_use]
5992    pub fn where_to_look(&self) -> String {
5993        if let Some(reach) = &self.this_account {
5994            return reach.clone();
5995        }
5996        let elevated = if cfg!(windows) {
5997            "from an elevated terminal (Run as administrator)"
5998        } else {
5999            "with sudo"
6000        };
6001        match &self.owner {
6002            Some(owner) if owner != "LocalSystem" && owner != "root" => {
6003                format!("{elevated}, or signed in as {owner}")
6004            }
6005            _ => elevated.to_owned(),
6006        }
6007    }
6008
6009    /// Where a change to the service has to be made from: the installation's
6010    /// own account, because every remedy a command prints acts on the
6011    /// directories of whoever runs it. Elevation alone reads the service's
6012    /// records but is still this account.
6013    #[must_use]
6014    pub fn act_as(&self) -> String {
6015        if let Some(reach) = &self.this_account {
6016            return reach.clone();
6017        }
6018        match &self.owner {
6019            Some(owner) if owner != "LocalSystem" && owner != "root" => {
6020                format!("signed in as {owner}")
6021            }
6022            _ => format!(
6023                "from the account whose data root {} is",
6024                self.root.display()
6025            ),
6026        }
6027    }
6028
6029    /// Whether the service's own records could not be read, so only what the
6030    /// service manager says is known.
6031    #[must_use]
6032    pub const fn is_unread(&self) -> bool {
6033        self.unreadable.is_some()
6034    }
6035
6036    /// Who installed it, for a sentence.
6037    #[must_use]
6038    pub fn installed_by(&self) -> String {
6039        match &self.owner {
6040            Some(owner) => format!("{owner}'s data root {}", self.root.display()),
6041            None => format!("the data root {}", self.root.display()),
6042        }
6043    }
6044
6045    /// The data root and its owner, for a field: `<root> (<owner>)`.
6046    #[must_use]
6047    pub fn label(&self) -> String {
6048        match &self.owner {
6049            Some(owner) => format!("{} ({owner})", self.root.display()),
6050            None => self.root.display().to_string(),
6051        }
6052    }
6053
6054    /// The note describing this account's own record, when there is one.
6055    #[must_use]
6056    pub fn unused_local_note(&self) -> Option<String> {
6057        let record = self.unused_local_record.as_ref()?;
6058        Some(format!(
6059            "unused local configuration from an earlier install: {} records a {} registration \
6060             made by runner-manager {} on {}, and no service on this machine runs against it. \
6061             `runner-manager host forget-local` moves it aside; it never touches the service's \
6062             own directories or any secret.",
6063            self.local_record.display(),
6064            record.start_mode,
6065            record.installed_by_version,
6066            record.installed_at.format("%Y-%m-%d"),
6067        ))
6068    }
6069}
6070
6071// ---------------------------------------------------------------------------
6072// Status
6073// ---------------------------------------------------------------------------
6074
6075/// One thing `service status` has to report as wrong.
6076#[derive(Debug, Clone, PartialEq, Eq)]
6077pub struct StatusProblem {
6078    /// What it is about.
6079    pub subject: &'static str,
6080    /// What is wrong, and what to do.
6081    pub detail: String,
6082}
6083
6084impl fmt::Display for StatusProblem {
6085    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6086        write!(f, "{}: {}", self.subject, self.detail)
6087    }
6088}
6089
6090/// Everything `service status` reports.
6091///
6092/// Journey 5 step 4 names four of these — the start mode, the resolved binary
6093/// path, the diagnostic log path, and the last successful GitHub contact — and
6094/// the release gate adds the fifth: *"`service status` reports a stale binary
6095/// path as an error rather than appearing healthy"*. [`Self::is_healthy`] is
6096/// what an exit code should be derived from, and it is false whenever
6097/// [`Self::problems`] is non-empty.
6098#[derive(Debug, Clone)]
6099pub struct ServiceStatus {
6100    identity: ServiceIdentity,
6101    record: Option<InstallRecord>,
6102    registration: Option<Registration>,
6103    binary: Option<BinaryPath>,
6104    log_file: PathBuf,
6105    store: Option<crate::secrets::ActiveStore>,
6106    last_github_contact: Option<DateTime<Utc>>,
6107    runner_root: Option<(PathBuf, RootAccessReport)>,
6108    credential_rejected_since: Option<DateTime<Utc>>,
6109    launches_blocked: Option<crate::launch_health::LaunchesBlocked>,
6110    unattended_gap: Option<UnattendedGap>,
6111    foreign: Option<ForeignService>,
6112    problems: Vec<StatusProblem>,
6113    notes: Vec<String>,
6114}
6115
6116/// Why a registration does not come back by itself after an unattended
6117/// restart, and what an operator can do about it.
6118#[derive(Debug, Clone, PartialEq, Eq)]
6119pub struct UnattendedGap {
6120    /// One or more sentences, the way `service status` prints them.
6121    pub reason: String,
6122    /// What changes it. Never something this product does by itself.
6123    pub remedy: String,
6124}
6125
6126/// The name of the account a registration runs as: the invoking user's own
6127/// for a login-mode one.
6128#[must_use]
6129pub fn service_account_name(account: &ServiceAccount) -> String {
6130    match account {
6131        // Under `sudo` the invoking user is the one sudo names, not root.
6132        ServiceAccount::InvokingUser => std::env::var("SUDO_USER")
6133            .ok()
6134            .filter(|user| !user.is_empty() && crate::host_fitness::is_elevated())
6135            .or_else(crate::unattended_login::current_account)
6136            .unwrap_or_else(|| "the operator".to_owned()),
6137        other => other.as_str().to_owned(),
6138    }
6139}
6140
6141/// The account `registration` names, which an administrator can change from
6142/// the one the install record saved, and otherwise the record's.
6143fn registered_account<'a>(registration: Option<&'a Registration>, recorded: &'a str) -> &'a str {
6144    registration
6145        .and_then(|registration| registration.account.as_deref())
6146        .unwrap_or(recorded)
6147}
6148
6149/// A Windows service account as `QueryServiceConfigW` names it, spelled the
6150/// way [`ServiceAccount`] does, so a service nobody edited reports the account
6151/// the record holds.
6152#[cfg(any(windows, test))]
6153fn windows_service_account(name: &str) -> String {
6154    if name.eq_ignore_ascii_case("LocalSystem") || name.eq_ignore_ascii_case(".\\LocalSystem") {
6155        ServiceAccount::LocalSystem.as_str().to_string()
6156    } else {
6157        name.to_string()
6158    }
6159}
6160
6161/// A Task Scheduler `UserId`, unless it is a raw SID, which names no account
6162/// an operator would recognise.
6163#[cfg(any(windows, test))]
6164fn scheduled_task_account(user_id: Option<String>) -> Option<String> {
6165    user_id.filter(|id| !id.to_ascii_uppercase().starts_with("S-1-"))
6166}
6167
6168/// Works out the gap for a registration in `mode` running as `account`.
6169///
6170/// `found` is `None` where nothing could be probed (every platform but macOS):
6171/// a login registration then keeps the general statement that it waits for a
6172/// sign-in. On a Mac the statement depends on automatic login and FileVault,
6173/// and a login registration that automatic login brings back has no gap. The
6174/// earlier statement was printed for every login registration, so a Mac set to
6175/// sign its runner account in automatically was told it would not resume.
6176#[must_use]
6177pub fn unattended_gap(
6178    mode: StartMode,
6179    account: &str,
6180    found: Option<&crate::unattended_login::UnattendedLogin>,
6181) -> Option<UnattendedGap> {
6182    let Some(found) = found else {
6183        return (mode == StartMode::Login).then(|| UnattendedGap {
6184            reason: "This registration starts at login, so the agent does not run until the \
6185                     operator signs in; this host does not resume work after an unattended \
6186                     reboot."
6187                .to_owned(),
6188            remedy: "For machine-on operation, move the service and credential to `--start-at \
6189                     boot` from an elevated terminal."
6190                .to_owned(),
6191        });
6192    };
6193    let resume = crate::unattended_login::resume(found, mode, account);
6194    if matches!(resume, crate::unattended_login::Resume::Unknown(_)) {
6195        // `host doctor` says what could not be read; a note here would claim
6196        // a gap nobody has seen.
6197        return None;
6198    }
6199    Some(UnattendedGap {
6200        reason: resume.explain(account)?,
6201        remedy: resume.remedy().unwrap_or_else(|| {
6202            "Run `runner-manager host doctor` on the Mac's desktop, where automatic login and \
6203             FileVault can be read."
6204                .to_owned()
6205        }),
6206    })
6207}
6208
6209impl ServiceStatus {
6210    /// `record_refused` carries a [`ServiceError::RecordNotPermitted`] when
6211    /// there **is** a record and this account may not read it, which is not the
6212    /// same state as having none and must not be reported as one. See
6213    /// [`Operations::status`].
6214    fn compose(
6215        identity: ServiceIdentity,
6216        record: Option<InstallRecord>,
6217        record_refused: Option<&ServiceError>,
6218        registration: Option<Registration>,
6219        last_github_contact: Option<DateTime<Utc>>,
6220        paths: &AppPaths,
6221    ) -> Self {
6222        let mut problems = Vec::new();
6223        let mut notes = Vec::new();
6224
6225        if let Some(refusal) = record_refused {
6226            // A problem rather than a note: `is_healthy` stays false, because
6227            // an operator who cannot read their own install record does have
6228            // something to fix. It is just not a reason to withhold everything
6229            // the service manager knows.
6230            problems.push(StatusProblem {
6231                subject: "install record",
6232                detail: refusal.to_string(),
6233            });
6234        }
6235
6236        let log_file = record.as_ref().map_or_else(
6237            || ServiceDirectories::of(paths).log_file(),
6238            |record| record.log_file.clone(),
6239        );
6240
6241        let binary = record.as_ref().map(|record| {
6242            inspect_binary(
6243                &record.binary,
6244                registration
6245                    .as_ref()
6246                    .and_then(Registration::binary)
6247                    .as_deref(),
6248            )
6249        });
6250        if let Some(state) = &binary
6251            && state.is_error()
6252        {
6253            problems.push(StatusProblem {
6254                subject: "binary",
6255                detail: state.to_string(),
6256            });
6257        }
6258
6259        match (&record, &registration) {
6260            (Some(_), None) => problems.push(StatusProblem {
6261                subject: "registration",
6262                detail: "this host has a service record but no service manager knows the \
6263                         registration. Run `service install` again; it deletes nothing."
6264                    .to_string(),
6265            }),
6266            // Not reported when the record was refused rather than absent: the
6267            // problem above already names the real state, and "there is no
6268            // install record" would be a second, wrong diagnosis of the same
6269            // file, with a `service uninstall` in its remedy.
6270            (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
6271                subject: "record",
6272                detail: format!(
6273                    "{} holds a registration for this service but there is no install record, so \
6274                     the path it was installed from and the directories it was installed against \
6275                     are unknown. Run `service uninstall` and `service install`.",
6276                    found.manager
6277                ),
6278            }),
6279            (None, Some(_)) => {}
6280            (Some(record), Some(found)) => {
6281                if record.start_mode != found.start_mode {
6282                    problems.push(StatusProblem {
6283                        subject: "start mode",
6284                        detail: format!(
6285                            "the record says {} and {} holds a {} registration. Switch the start \
6286                             mode again to make them agree.",
6287                            record.start_mode, found.manager, found.start_mode
6288                        ),
6289                    });
6290                }
6291                if record.start_mode == StartMode::Boot && !found.starts_automatically {
6292                    problems.push(StatusProblem {
6293                        subject: "start mode",
6294                        detail: format!(
6295                            "{} holds the registration but will not start it by itself, so this \
6296                             host does not resume work after a reboot.",
6297                            found.manager
6298                        ),
6299                    });
6300                }
6301                if let Some(actual) = found.restart_delay {
6302                    let expected = record.restart().effective_delay(found.manager);
6303                    if actual != expected {
6304                        problems.push(StatusProblem {
6305                            subject: "restart policy",
6306                            detail: format!(
6307                                "the record says the service restarts after {}s and {} reports \
6308                                 {}s. Something has edited the registration since it was \
6309                                 installed.",
6310                                expected.as_secs(),
6311                                found.manager,
6312                                actual.as_secs()
6313                            ),
6314                        });
6315                    } else if expected != record.restart().delay() {
6316                        // Not a fault. Task Scheduler expresses this in whole
6317                        // minutes, so the delay in force is longer than the one
6318                        // asked for -- never shorter, which is the direction
6319                        // the requirement cares about.
6320                        notes.push(format!(
6321                            "{} expresses the restart delay in whole minutes, so the {}s asked \
6322                             for is enforced as {}s. The service therefore never restarts faster \
6323                             than the configured bound.",
6324                            found.manager,
6325                            record.restart().delay().as_secs(),
6326                            expected.as_secs()
6327                        ));
6328                    }
6329                }
6330                if found.starts_automatically && !found.running {
6331                    problems.push(StatusProblem {
6332                        subject: "runtime",
6333                        detail: format!(
6334                            "{} holds an automatic registration, but the daemon is stopped. Run \
6335                             `runner-manager service install --start-at {}`; if the service \
6336                             manager denies replacement, retry from an elevated terminal. If it \
6337                             stops again, inspect {}.",
6338                            found.manager,
6339                            found.start_mode,
6340                            log_file.display()
6341                        ),
6342                    });
6343                }
6344            }
6345            (None, None) => {}
6346        }
6347
6348        // The two facts here really are independent, which is what `f1`'s
6349        // `status.rs` says this check needs and could not have there: the scope
6350        // is derived from the mode the **record** carries, and it is compared
6351        // against the mode the **service manager** actually holds.
6352        let store = record.as_ref().and_then(|record| {
6353            let registered_mode = registration
6354                .as_ref()
6355                .map_or(record.start_mode, |found| found.start_mode);
6356            crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
6357                .ok()
6358                .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
6359        });
6360        if let Some(store) = &store
6361            && !store.agrees_with_start_mode()
6362        {
6363            problems.push(StatusProblem {
6364                subject: "secret store",
6365                detail: format!(
6366                    "{store}. Run `auth login` again so the token is stored where the registered \
6367                     start mode can read it."
6368                ),
6369            });
6370        }
6371
6372        if last_github_contact.is_none() {
6373            notes.push(
6374                "GitHub has not been reached successfully since this host's state directory was \
6375                 created."
6376                    .to_string(),
6377            );
6378        }
6379
6380        // The privileged inspection output. Read-only, tolerant of an account
6381        // that may not read the descriptor at all, and never a `problem`: a
6382        // root this account cannot inspect is a true statement about this
6383        // account's rights, not a fault in the registration. What refuses an
6384        // install is the preflight in `runner_root_access`, which runs as the
6385        // installing account and has the authority to know.
6386        let runner_root = crate::runner_root::default_runner_root(paths)
6387            .ok()
6388            .map(|root| {
6389                let path = root.as_path().to_path_buf();
6390                let report = crate::runner_root_access::report(&path);
6391                (path, report)
6392            });
6393        // Reported, and deliberately not a `problem`. The Definition of Done
6394        // asks for a broad root to be "reported and fail the security
6395        // preflight", and the security preflight is `install`'s -- which runs
6396        // as the installing account and refuses outright. `service status` runs
6397        // as whoever typed it, is expected to be readable on a host with
6398        // nothing installed at all, and drives an exit code; turning a
6399        // directory that predates this feature into a non-zero exit for a
6400        // machine that has never installed the service would report a fault
6401        // that is not this registration's.
6402        if let Some((
6403            path,
6404            RootAccessReport::Present {
6405                broad_write: true, ..
6406            },
6407        )) = &runner_root
6408        {
6409            notes.push(format!(
6410                "the platform default runner root {} can be written by ordinary local users, so \
6411                 it is not a safe place to run jobs. `service install` refuses it rather than \
6412                 tightening it, because the contents of a directory anybody could write cannot \
6413                 be trusted: remove or empty it, or choose another root with `runner-manager \
6414                 host set-runtime-root --path <PATH>`.",
6415                path.display()
6416            ));
6417        }
6418
6419        // The surface the agent has nowhere else to reach. A root that refuses
6420        // a launch does so before any attempt row exists, and the daemon's log
6421        // redacts the paths out of the sentence, so without this the operator
6422        // sees `runner_start_failed reason=other` once per poll and nothing
6423        // that names the directory or the fix.
6424        //
6425        // A **note** and not a problem, by the same rule the broad-write root
6426        // above obeys: `service status` runs as whoever typed it, is expected
6427        // to be readable on a host with nothing installed at all, and drives an
6428        // exit code. A problem here would fail that exit code on three hosts
6429        // that are not broken -- one whose root was fixed but which has had no
6430        // queued job since, one whose service was uninstalled without clearing
6431        // `state/`, and one that only ever ran the agent in the foreground --
6432        // and the printed remedy (`service uninstall && service install`) does
6433        // not touch `state/`, so following it would not clear the error either.
6434        // A note is read by the same operator at the same moment and traps
6435        // nobody in a loop.
6436        //
6437        // A read failure is reported and not swallowed, for the reason
6438        // `runner_root_refusals` documents: absence means "every policy is
6439        // placing runners", and answering a parse failure with that would be
6440        // the wrong answer to the question this row exists for.
6441        match runner_root_refusals(paths) {
6442            Ok(refusals) => {
6443                for refusal in refusals {
6444                    notes.push(format!(
6445                        "policy {} started no runner: its runner root {} refused the launch \
6446                         ({}), last at {}. {} This clears when that policy next places a \
6447                         runner.",
6448                        refusal.policy,
6449                        refusal.root,
6450                        refusal.kind,
6451                        refusal.at.to_rfc3339(),
6452                        refusal.detail,
6453                    ));
6454                }
6455            }
6456            Err(error) => notes.push(format!(
6457                "whether the agent could use its runner roots could not be read: {error}"
6458            )),
6459        }
6460
6461        Self {
6462            identity,
6463            record,
6464            registration,
6465            binary,
6466            log_file,
6467            store,
6468            last_github_contact,
6469            runner_root,
6470            credential_rejected_since: None,
6471            launches_blocked: None,
6472            unattended_gap: None,
6473            foreign: None,
6474            problems,
6475            notes,
6476        }
6477    }
6478
6479    /// Marks a status read from another installation's directories as that
6480    /// installation's. See [`ForeignService`].
6481    ///
6482    /// Its problems were written for its owner, and their remedies -- `service
6483    /// install`, `auth login`, re-registering a drifted definition -- act on
6484    /// the directories of whoever runs them. Printed to this account, which
6485    /// reaches here precisely by following "run it elevated", they would
6486    /// re-register the machine's service against this account's data root.
6487    /// So each keeps its diagnosis and loses its remedy, and says where the
6488    /// remedy has to be run instead.
6489    fn with_foreign(mut self, foreign: ForeignService) -> Self {
6490        let act_as = foreign.act_as();
6491        for problem in &mut self.problems {
6492            problem.detail = format!(
6493                "{}. Act on it {act_as}: a remedy run from this account acts on this account's \
6494                 own directories, not the service's.",
6495                diagnosis_only(&problem.detail)
6496            );
6497        }
6498        if let Some(note) = foreign.unused_local_note() {
6499            self.notes.push(note);
6500        }
6501        self.foreign = Some(foreign);
6502        self
6503    }
6504
6505    /// A registration made from another data root whose records this account
6506    /// cannot read: what the service manager reports, and nothing else.
6507    ///
6508    /// The only problems are ones the service manager alone settles -- an
6509    /// automatic registration that is stopped, a boot registration that will
6510    /// not start by itself -- and their remedy is to look as the owner, never
6511    /// to reinstall from here.
6512    fn foreign_unread(
6513        identity: ServiceIdentity,
6514        registration: Registration,
6515        foreign: ForeignService,
6516    ) -> Self {
6517        let mut problems = Vec::new();
6518        let look = foreign.where_to_look();
6519        if registration.starts_automatically && !registration.running {
6520            problems.push(StatusProblem {
6521                subject: "runtime",
6522                detail: format!(
6523                    "{} holds an automatic registration for the service installed from {}, but \
6524                     it is stopped. Run `runner-manager service status` {look} to see why; do not \
6525                     reinstall it from this account.",
6526                    registration.manager,
6527                    foreign.installed_by(),
6528                ),
6529            });
6530        }
6531        if registration.start_mode == StartMode::Boot && !registration.starts_automatically {
6532            problems.push(StatusProblem {
6533                subject: "start mode",
6534                detail: format!(
6535                    "{} holds the boot registration installed from {} but will not start it by \
6536                     itself. Run `runner-manager service status` {look}.",
6537                    registration.manager,
6538                    foreign.installed_by(),
6539                ),
6540            });
6541        }
6542        let mut notes = Vec::new();
6543        if let Some(reason) = &foreign.unreadable {
6544            notes.push(format!(
6545                "its health is not known here: {reason}. Run `runner-manager service status` \
6546                 {look} to see it."
6547            ));
6548        }
6549        notes.extend(foreign.unused_local_note());
6550        Self {
6551            identity,
6552            record: None,
6553            log_file: foreign.directories.log_file(),
6554            registration: Some(registration),
6555            binary: None,
6556            store: None,
6557            last_github_contact: None,
6558            runner_root: None,
6559            credential_rejected_since: None,
6560            launches_blocked: None,
6561            unattended_gap: None,
6562            foreign: Some(foreign),
6563            problems,
6564            notes,
6565        }
6566    }
6567
6568    /// The other installation this machine's service belongs to, when it is
6569    /// not this account's. See [`ForeignService`].
6570    #[must_use]
6571    pub const fn foreign(&self) -> Option<&ForeignService> {
6572        self.foreign.as_ref()
6573    }
6574
6575    /// Whether this is another installation's service whose own records this
6576    /// account could not read, so only the service manager's facts are here.
6577    #[must_use]
6578    pub fn is_unread(&self) -> bool {
6579        self.foreign.as_ref().is_some_and(ForeignService::is_unread)
6580    }
6581
6582    /// The one-word-or-so verdict `service status` ends with.
6583    #[must_use]
6584    pub fn verdict(&self) -> String {
6585        if !self.is_healthy() {
6586            return "NOT healthy".to_owned();
6587        }
6588        match &self.foreign {
6589            Some(foreign) if foreign.is_unread() => format!(
6590                "{}; installed from {}, so its health is read {}",
6591                if self.is_running() {
6592                    "running"
6593                } else {
6594                    "not running"
6595                },
6596                foreign.installed_by(),
6597                foreign.where_to_look()
6598            ),
6599            _ => "healthy".to_owned(),
6600        }
6601    }
6602
6603    /// Reports a definition this build would render differently.
6604    #[must_use]
6605    pub fn with_definition_drift(mut self, drift: Option<DefinitionDrift>) -> Self {
6606        if let Some(drift) = drift {
6607            if drift.manager == DefinitionKind::LaunchdPlist {
6608                // A restart, not `service install`: the running daemon holds the
6609                // single-instance lock, which refuses an install, and a daemon
6610                // that starts with no runner repairs the plist itself.
6611                let restart = match drift.start_mode {
6612                    StartMode::Boot => format!(
6613                        "sudo launchctl kickstart -k system/{}",
6614                        self.identity.launchd_label()
6615                    ),
6616                    StartMode::Login => format!(
6617                        "launchctl kickstart -k gui/$(id -u)/{}",
6618                        self.identity.launchd_label()
6619                    ),
6620                };
6621                self.problems.push(StatusProblem {
6622                    subject: "definition",
6623                    detail: format!(
6624                        "{} was written by runner-manager {} with a `ProcessType` other than \
6625                         `{LAUNCHD_PROCESS_TYPE}`, which launchd applies to every runner the \
6626                         daemon starts, so their jobs run below normal priority. The daemon \
6627                         rewrites it and reloads itself when it starts with no runner, which it \
6628                         does after every upgrade; to do it now, restart the service while no \
6629                         job is running: `{restart}`. Configuration and credentials are kept.",
6630                        drift.path.display(),
6631                        drift.installed_by_version,
6632                    ),
6633                });
6634                return self;
6635            }
6636            let repair = match drift.start_mode {
6637                StartMode::Boot => format!(
6638                    "`sudo systemctl stop runner-manager && {}` (for a managed WSL host, run \
6639                     `runner-manager wsl install --distribution <name>` from Windows)",
6640                    sudo_command("service install --start-at boot")
6641                ),
6642                StartMode::Login => "`systemctl --user stop runner-manager && runner-manager \
6643                                     service install --start-at login`"
6644                    .to_owned(),
6645            };
6646            self.problems.push(StatusProblem {
6647                subject: "definition",
6648                detail: format!(
6649                    "{} was written by runner-manager {} and differs from what {} renders, so \
6650                     the daemon runs under outdated settings (for example a credential frozen \
6651                     at service start). Re-register it with {repair}; configuration and \
6652                     credentials are kept.",
6653                    drift.path.display(),
6654                    drift.installed_by_version,
6655                    env!("CARGO_PKG_VERSION"),
6656                ),
6657            });
6658        }
6659        self
6660    }
6661
6662    /// Reports that GitHub is rejecting the daemon's credential.
6663    #[must_use]
6664    pub fn with_credential_rejection(mut self, since: Option<DateTime<Utc>>) -> Self {
6665        if let Some(since) = since {
6666            self.credential_rejected_since = Some(since);
6667            self.problems.push(StatusProblem {
6668                subject: "github credential",
6669                detail: format!(
6670                    "GitHub has rejected the service's credential since {}, so the service \
6671                     cannot see queued jobs or start runners. Run `runner-manager auth login` \
6672                     (for a managed WSL host, `runner-manager --host wsl:<name> auth login` \
6673                     from Windows).",
6674                    since.to_rfc3339()
6675                ),
6676            });
6677        }
6678        self
6679    }
6680
6681    /// Since when GitHub has rejected the service's credential, if it has.
6682    #[must_use]
6683    pub const fn credential_rejected_since(&self) -> Option<DateTime<Utc>> {
6684        self.credential_rejected_since
6685    }
6686
6687    /// Reports that the daemon starts no runner because its launches are
6688    /// blocked. See [`crate::launch_health`].
6689    #[must_use]
6690    pub fn with_launches_blocked(
6691        mut self,
6692        blocked: Option<crate::launch_health::LaunchesBlocked>,
6693    ) -> Self {
6694        if let Some(blocked) = &blocked {
6695            self.problems.push(StatusProblem {
6696                subject: LAUNCHES_BLOCKED_SUBJECT,
6697                detail: format!(
6698                    "the service has started no runner {blocked}. Fix: {}.",
6699                    blocked.remedy
6700                ),
6701            });
6702        }
6703        self.launches_blocked = blocked;
6704        self
6705    }
6706
6707    /// Why the service starts no runner, when its launches are blocked.
6708    #[must_use]
6709    pub const fn launches_blocked(&self) -> Option<&crate::launch_health::LaunchesBlocked> {
6710        self.launches_blocked.as_ref()
6711    }
6712
6713    /// Reports that the daemon cannot read its own stored credential, and the
6714    /// exact command that ends it. See [`record_credential_unreadable`].
6715    #[must_use]
6716    pub fn with_credential_unreadable(mut self, since: Option<DateTime<Utc>>) -> Self {
6717        if let Some(since) = since {
6718            let mode = self.start_mode().unwrap_or_default();
6719            self.problems.push(StatusProblem {
6720                subject: CREDENTIAL_UNREADABLE_SUBJECT,
6721                detail: format!(
6722                    "the service has been unable to read its stored GitHub credential since {}, \
6723                     so it starts no runner; the diagnostic log has the store's own answer.{} \
6724                     Sign in again: {}.",
6725                    since.to_rfc3339(),
6726                    if cfg!(target_os = "macos") {
6727                        " That is what a keychain item written by a different build looks like."
6728                    } else {
6729                        ""
6730                    },
6731                    sign_in_instruction(mode)
6732                ),
6733            });
6734        }
6735        self
6736    }
6737
6738    /// What this host's default runner root grants, and to whom.
6739    ///
6740    /// `None` when the platform default could not even be resolved. The
6741    /// descriptor inside has been through
6742    /// [`crate::runner_root_access::redact`], so it names the well-known
6743    /// trustees and says "an account" for everything else — no more identity
6744    /// than the `account` line above it already prints.
6745    #[must_use]
6746    pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
6747        self.runner_root
6748            .as_ref()
6749            .map(|(path, report)| (path.as_path(), report))
6750    }
6751
6752    /// Whether a registration exists at all.
6753    #[must_use]
6754    pub const fn is_installed(&self) -> bool {
6755        self.registration.is_some() || self.record.is_some()
6756    }
6757
6758    /// Whether the daemon is running now.
6759    #[must_use]
6760    pub fn is_running(&self) -> bool {
6761        self.registration
6762            .as_ref()
6763            .is_some_and(|registration| registration.running)
6764    }
6765
6766    /// **False whenever anything is wrong**, including a stale binary path.
6767    #[must_use]
6768    pub fn is_healthy(&self) -> bool {
6769        self.problems.is_empty()
6770    }
6771
6772    /// Everything that is wrong.
6773    #[must_use]
6774    pub fn problems(&self) -> &[StatusProblem] {
6775        &self.problems
6776    }
6777
6778    /// True statements that are not faults — a login-mode registration not
6779    /// running unattended, or an agent that has not yet reached GitHub.
6780    #[must_use]
6781    pub fn notes(&self) -> &[String] {
6782        &self.notes
6783    }
6784
6785    /// Reports that the daemon starts no runner because a required host check
6786    /// fails from its own session. That is the service not doing its job, so
6787    /// it is a problem: a host whose daemon refused every runner used to read
6788    /// `verdict healthy`.
6789    #[must_use]
6790    pub fn with_host_unfit(mut self, unfit: Option<crate::host_fitness::HostUnfitRecord>) -> Self {
6791        if let Some(unfit) = unfit {
6792            self.problems.push(StatusProblem {
6793                subject: HOST_UNFIT_SUBJECT,
6794                detail: format!(
6795                    "the service has started no runner since {}, because a required host check \
6796                     fails from its own session: {}",
6797                    unfit.since.to_rfc3339(),
6798                    unfit.describe()
6799                ),
6800            });
6801        }
6802        self
6803    }
6804
6805    /// Reports a daemon that is running and has stopped making progress. See
6806    /// [`crate::daemon_heartbeat`]: launchd, systemd and the SCM all report
6807    /// such a daemon running, so this used to read `verdict healthy`.
6808    #[must_use]
6809    pub fn with_stall(mut self, stall: Option<String>) -> Self {
6810        if let Some(detail) = stall {
6811            self.problems.push(StatusProblem {
6812                subject: DAEMON_STALLED_SUBJECT,
6813                detail,
6814            });
6815        }
6816        self
6817    }
6818
6819    /// Records whether this registration comes back by itself after an
6820    /// unattended restart, as a note. See [`unattended_gap`].
6821    #[must_use]
6822    pub fn with_unattended_gap(mut self, gap: Option<UnattendedGap>) -> Self {
6823        if let Some(gap) = &gap {
6824            self.notes.push(gap.reason.clone());
6825        }
6826        self.unattended_gap = gap;
6827        self
6828    }
6829
6830    /// Why this registration does not come back by itself after an
6831    /// unattended restart, when it does not.
6832    #[must_use]
6833    pub const fn unattended_gap(&self) -> Option<&UnattendedGap> {
6834        self.unattended_gap.as_ref()
6835    }
6836
6837    /// The service manager's command that restarts this registration, for its
6838    /// start mode (boot when unknown). See [`ServiceIdentity::restart_command`].
6839    #[must_use]
6840    pub fn restart_command(&self) -> String {
6841        self.identity
6842            .restart_command(self.start_mode().unwrap_or(StartMode::Boot))
6843    }
6844
6845    /// The recorded start mode. Journey 5 step 4. For another installation's
6846    /// service whose record this account cannot read, the mode the service
6847    /// manager's domain implies.
6848    #[must_use]
6849    pub fn start_mode(&self) -> Option<StartMode> {
6850        self.record
6851            .as_ref()
6852            .map(|record| record.start_mode)
6853            .or_else(|| {
6854                self.registration
6855                    .as_ref()
6856                    .filter(|_| self.is_unread())
6857                    .map(|registration| registration.start_mode)
6858            })
6859    }
6860
6861    /// The binary the service runs: the recorded one, or for another
6862    /// installation's service whose record this account cannot read, the one
6863    /// the service manager names.
6864    #[must_use]
6865    pub fn service_binary(&self) -> Option<PathBuf> {
6866        self.record
6867            .as_ref()
6868            .map(|record| record.binary.clone())
6869            .or_else(|| {
6870                self.registration
6871                    .as_ref()
6872                    .filter(|_| self.is_unread())
6873                    .and_then(Registration::binary)
6874            })
6875    }
6876
6877    /// The resolved absolute binary path and what became of it. Journey 5
6878    /// step 4 and `05-infrastructure.md` item 6.
6879    #[must_use]
6880    pub const fn binary(&self) -> Option<&BinaryPath> {
6881        self.binary.as_ref()
6882    }
6883
6884    /// The diagnostic log path. `05-infrastructure.md` item 4.
6885    #[must_use]
6886    pub fn log_file(&self) -> &Path {
6887        &self.log_file
6888    }
6889
6890    /// The last successful GitHub contact. Journey 5 step 4.
6891    #[must_use]
6892    pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
6893        self.last_github_contact
6894    }
6895
6896    /// Which store the daemon reads, and whether that agrees with the
6897    /// registration.
6898    #[must_use]
6899    pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
6900        self.store.as_ref()
6901    }
6902
6903    /// The record `install` wrote.
6904    #[must_use]
6905    pub const fn record(&self) -> Option<&InstallRecord> {
6906        self.record.as_ref()
6907    }
6908
6909    /// What the service manager says.
6910    #[must_use]
6911    pub const fn registration(&self) -> Option<&Registration> {
6912        self.registration.as_ref()
6913    }
6914}
6915
6916impl fmt::Display for ServiceStatus {
6917    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6918        writeln!(f, "Service: {}", self.identity)?;
6919        match (&self.record, &self.registration) {
6920            (None, None) => {
6921                writeln!(
6922                    f,
6923                    "  installed                 no. `service install` registers `{} {}`.",
6924                    SERVICE_NAME,
6925                    DAEMON_ARGUMENTS.join(" ")
6926                )?;
6927            }
6928            _ => {
6929                let manager = self
6930                    .registration
6931                    .as_ref()
6932                    .map(|registration| registration.manager.manager());
6933                writeln!(
6934                    f,
6935                    "  installed                 {}",
6936                    manager.unwrap_or("yes, but no service manager knows it")
6937                )?;
6938                writeln!(
6939                    f,
6940                    "  state                     {}",
6941                    if self.is_running() {
6942                        "running"
6943                    } else {
6944                        "not running"
6945                    }
6946                )?;
6947            }
6948        }
6949        if let Some(foreign) = &self.foreign {
6950            writeln!(
6951                f,
6952                "  installed from            {} -- another installation's, not this account's ({})",
6953                foreign.installed_by(),
6954                foreign.local_root.display()
6955            )?;
6956            // Without the record, the service manager's own facts.
6957            if foreign.is_unread()
6958                && let Some(registration) = &self.registration
6959            {
6960                writeln!(f, "  start mode                {}", registration.start_mode)?;
6961                if let Some(account) = &registration.account {
6962                    writeln!(f, "  account                   {account}")?;
6963                }
6964                if let Some(binary) = self.service_binary() {
6965                    writeln!(f, "  binary                    {}", binary.display())?;
6966                }
6967            }
6968        }
6969        if let Some(record) = &self.record {
6970            writeln!(f, "  start mode                {}", record.start_mode)?;
6971            let recorded = record.account.to_string();
6972            let account = registered_account(self.registration.as_ref(), &recorded);
6973            writeln!(f, "  account                   {account}")?;
6974            writeln!(f, "  restart on failure        {}", record.restart())?;
6975            writeln!(
6976                f,
6977                "  arguments                 {}",
6978                record.arguments.join(" ")
6979            )?;
6980        }
6981        if let Some(binary) = &self.binary {
6982            writeln!(f, "  binary                    {binary}")?;
6983        }
6984        writeln!(f, "  diagnostic log            {}", self.log_file.display())?;
6985        if let Some((path, report)) = &self.runner_root {
6986            // Named as the *default* rather than as "the runner root", because
6987            // it is only the effective one until an operator runs
6988            // `host set-runtime-root`. This crate cannot see that setting — it
6989            // lives in the application's store — so an unqualified label here
6990            // would contradict the `runner root … (host-configured)` row that
6991            // `status` and `host show` print from the value that is in force.
6992            writeln!(f, "  default runner root       {}", path.display())?;
6993            if *report != RootAccessReport::NotApplicable {
6994                writeln!(f, "  default root access       {report}")?;
6995            }
6996        }
6997        if let Some(store) = &self.store {
6998            writeln!(f, "  secret store              {store}")?;
6999        }
7000        // Not known, rather than "never", for a service whose records were not
7001        // read: its contact file is in a directory this account cannot open.
7002        if !self.is_unread() {
7003            writeln!(
7004                f,
7005                "  last GitHub contact       {}",
7006                match self.last_github_contact {
7007                    Some(at) => at.to_rfc3339(),
7008                    None => "never".to_string(),
7009                }
7010            )?;
7011        }
7012        for note in &self.notes {
7013            writeln!(f, "  note                      {note}")?;
7014        }
7015        for problem in &self.problems {
7016            writeln!(f, "  ERROR                     {problem}")?;
7017        }
7018        write!(f, "  verdict                   {}", self.verdict())
7019    }
7020}
7021
7022// ---------------------------------------------------------------------------
7023// The in-memory double
7024// ---------------------------------------------------------------------------
7025
7026/// A [`ControlFactory`] that registers nothing and remembers everything.
7027///
7028/// Public rather than `#[cfg(test)]` on purpose. `f3` builds three commands on
7029/// [`ServiceOperations`] and has to be able to test them without a service
7030/// manager, and a double that lived behind `#[cfg(test)]` here would be
7031/// invisible from another crate — which would leave `f3` either untested or
7032/// writing a second double that drifts from this one.
7033///
7034/// It is not a simulator. It stores what it was asked to register and reports it
7035/// back, which is exactly enough to exercise the logic that sits *above* a
7036/// service manager: the lock refusal, the record, the stale-path detection, the
7037/// start-mode switch, and the promise that uninstall deletes nothing else.
7038#[derive(Debug, Clone, Default)]
7039pub struct RecordingControls {
7040    state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
7041}
7042
7043#[derive(Debug, Default)]
7044struct RecordingState {
7045    registrations: BTreeMap<(StartMode, String), Registration>,
7046    definitions: BTreeMap<String, ServiceDefinition>,
7047    calls: Vec<String>,
7048    #[cfg(test)]
7049    install_failures: BTreeMap<StartMode, String>,
7050    #[cfg(test)]
7051    after_install: BTreeMap<StartMode, TestInstallSideEffect>,
7052}
7053
7054#[cfg(test)]
7055#[derive(Debug, Clone)]
7056enum TestInstallSideEffect {
7057    HideDirectory { directory: PathBuf, hidden: PathBuf },
7058}
7059
7060impl RecordingControls {
7061    /// A factory holding no registrations.
7062    #[must_use]
7063    pub fn new() -> Self {
7064        Self::default()
7065    }
7066
7067    /// Every call made through this factory, in order, as
7068    /// `"<operation> <name> (<mode>)"`.
7069    #[must_use]
7070    pub fn calls(&self) -> Vec<String> {
7071        self.state.lock().expect("not poisoned").calls.clone()
7072    }
7073
7074    /// Every registration currently held, with the domain it is in.
7075    #[must_use]
7076    pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
7077        self.state
7078            .lock()
7079            .expect("not poisoned")
7080            .registrations
7081            .iter()
7082            .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
7083            .collect()
7084    }
7085
7086    /// The definition applied for a registration, if it is held.
7087    #[must_use]
7088    pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
7089        self.state
7090            .lock()
7091            .expect("not poisoned")
7092            .definitions
7093            .get(name)
7094            .cloned()
7095    }
7096
7097    /// Edits a held registration, as something outside this product would.
7098    ///
7099    /// This is how the divergence and start-type problems are made reachable
7100    /// from a test: `sc config`, `launchctl` and `systemctl` can all change a
7101    /// registration after installation, and a status command that could not be
7102    /// shown detecting that would be a status command nobody had tested against
7103    /// the case it exists for.
7104    ///
7105    /// Does nothing when no registration of that name is held.
7106    pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
7107        let mut state = self.state.lock().expect("not poisoned");
7108        if let Some((_, registration)) = state
7109            .registrations
7110            .iter_mut()
7111            .find(|((_, held), _)| held == name)
7112        {
7113            edit(registration);
7114        }
7115    }
7116
7117    #[cfg(test)]
7118    fn fail_next_install(&self, mode: StartMode, detail: &str) {
7119        self.state
7120            .lock()
7121            .expect("not poisoned")
7122            .install_failures
7123            .insert(mode, detail.to_string());
7124    }
7125
7126    #[cfg(test)]
7127    fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
7128        self.state
7129            .lock()
7130            .expect("not poisoned")
7131            .after_install
7132            .insert(
7133                mode,
7134                TestInstallSideEffect::HideDirectory { directory, hidden },
7135            );
7136    }
7137}
7138
7139impl ControlFactory for RecordingControls {
7140    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7141        Ok(Box::new(RecordingControl {
7142            mode,
7143            state: std::sync::Arc::clone(&self.state),
7144        }))
7145    }
7146}
7147
7148#[derive(Debug)]
7149struct RecordingControl {
7150    mode: StartMode,
7151    state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
7152}
7153
7154impl RecordingControl {
7155    fn note(&self, operation: &str, name: &str) {
7156        self.state
7157            .lock()
7158            .expect("not poisoned")
7159            .calls
7160            .push(format!("{operation} {name} ({})", self.mode));
7161    }
7162}
7163
7164impl ServiceControl for RecordingControl {
7165    fn manager(&self) -> DefinitionKind {
7166        // The double reports the kind this host's real backend would, so a test
7167        // that asserts on the manager name asserts something true of the
7168        // platform it is running on.
7169        host_definition_kind(self.mode)
7170    }
7171
7172    fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7173        self.note("install", plan.identity().name());
7174        #[cfg(test)]
7175        if let Some(detail) = self
7176            .state
7177            .lock()
7178            .expect("not poisoned")
7179            .install_failures
7180            .remove(&self.mode)
7181        {
7182            return Err(ServiceError::Control {
7183                operation: "install",
7184                name: plan.identity().name().to_string(),
7185                manager: "recording control",
7186                detail,
7187            });
7188        }
7189        // The real definition, not a stub. Rendering is pure, so the double can
7190        // afford it -- and a stub would make `Installed::review` report that the
7191        // definition confirms nothing, which is exactly the shape of false
7192        // assurance a test double must never hand back.
7193        let definition = ServiceDefinition::for_host(plan)?;
7194        let mut state = self.state.lock().expect("not poisoned");
7195        state.registrations.insert(
7196            (self.mode, plan.identity().name().to_string()),
7197            Registration {
7198                manager: host_definition_kind(self.mode),
7199                start_mode: self.mode,
7200                command_line: plan.command_line(),
7201                account: (*plan.account() != ServiceAccount::InvokingUser)
7202                    .then(|| plan.account().as_str().to_string()),
7203                running: false,
7204                starts_automatically: true,
7205                restart_delay: Some(plan.restart().delay()),
7206            },
7207        );
7208        state
7209            .definitions
7210            .insert(plan.identity().name().to_string(), definition.clone());
7211        #[cfg(test)]
7212        let side_effect = state.after_install.remove(&self.mode);
7213        drop(state);
7214        #[cfg(test)]
7215        if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
7216            std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
7217            std::fs::write(&directory, b"blocks recreation")
7218                .expect("test fault can block record directory recreation");
7219        }
7220        Ok(definition)
7221    }
7222
7223    fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7224        self.note("uninstall", identity.name());
7225        let mut state = self.state.lock().expect("not poisoned");
7226        state.definitions.remove(identity.name());
7227        Ok(state
7228            .registrations
7229            .remove(&(self.mode, identity.name().to_string()))
7230            .is_some())
7231    }
7232
7233    fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7234        self.note("query", identity.name());
7235        Ok(self
7236            .state
7237            .lock()
7238            .expect("not poisoned")
7239            .registrations
7240            .get(&(self.mode, identity.name().to_string()))
7241            .cloned())
7242    }
7243
7244    fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7245        self.note("start", identity.name());
7246        let mut state = self.state.lock().expect("not poisoned");
7247        match state
7248            .registrations
7249            .get_mut(&(self.mode, identity.name().to_string()))
7250        {
7251            Some(registration) => {
7252                registration.running = true;
7253                Ok(())
7254            }
7255            None => Err(ServiceError::NotInstalled {
7256                name: identity.name().to_string(),
7257                operation: "start",
7258            }),
7259        }
7260    }
7261
7262    fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7263        self.note("stop", identity.name());
7264        let mut state = self.state.lock().expect("not poisoned");
7265        match state
7266            .registrations
7267            .get_mut(&(self.mode, identity.name().to_string()))
7268        {
7269            Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
7270            None => Err(ServiceError::NotInstalled {
7271                name: identity.name().to_string(),
7272                operation: "stop",
7273            }),
7274        }
7275    }
7276}
7277
7278impl ControlFactory for HostControls {
7279    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7280        sys::control(mode)
7281    }
7282
7283    fn installed_definition(&self, path: &Path) -> Option<String> {
7284        std::fs::read_to_string(path).ok()
7285    }
7286
7287    fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
7288        crate::unattended_login::probe()
7289    }
7290}
7291
7292/// The operator's home directory, when the platform reports one.
7293///
7294/// Only login-mode registrations need it — a LaunchAgent and a systemd user
7295/// unit both live under it — and it is resolved lazily so that a boot-mode
7296/// operation on an account with no profile is not refused for wanting something
7297/// it never uses.
7298#[must_use]
7299pub fn host_home() -> Option<PathBuf> {
7300    directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
7301}
7302
7303/// The Unix backends' name for the same thing.
7304#[cfg(unix)]
7305fn home_directory() -> Option<PathBuf> {
7306    host_home()
7307}
7308
7309/// Runs a command and returns its exit status with both streams as text.
7310///
7311/// The three Unix backends and the Windows Task Scheduler backend all drive a
7312/// stock command-line tool, and all four want the same three things back. The
7313/// tools are chosen for machine-readable, locale-independent output wherever one
7314/// exists — `systemctl is-active`, `launchctl print`, `schtasks /XML` — because
7315/// parsing a localised human-facing table is how a status command starts lying
7316/// on somebody else's machine.
7317fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
7318    let output = std::process::Command::new(program)
7319        .args(arguments)
7320        .output()?;
7321    Ok((
7322        output.status.success(),
7323        String::from_utf8_lossy(&output.stdout).into_owned(),
7324        String::from_utf8_lossy(&output.stderr).into_owned(),
7325    ))
7326}
7327
7328/// Which manager holds a given start-mode domain on the platform this binary
7329/// was built for.
7330#[must_use]
7331pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
7332    if cfg!(windows) {
7333        match mode {
7334            StartMode::Boot => DefinitionKind::WindowsService,
7335            StartMode::Login => DefinitionKind::WindowsScheduledTask,
7336        }
7337    } else if cfg!(target_os = "macos") {
7338        DefinitionKind::LaunchdPlist
7339    } else {
7340        DefinitionKind::SystemdUnit
7341    }
7342}
7343
7344/// Enables a launchd label after it has been bootstrapped.
7345///
7346/// `launchctl bootstrap` deliberately preserves a label's disabled bit. An
7347/// `enable` failure therefore means the new registration cannot satisfy its
7348/// start policy. Compensate by booting it out and removing the plist so a
7349/// failed install cannot look installed to `service status`.
7350#[cfg(any(target_os = "macos", test))]
7351fn enable_launchd_registration(
7352    mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
7353    domain: &str,
7354    service_target: &str,
7355    plist: &Path,
7356    name: &str,
7357    elevation_remedy: &'static str,
7358) -> Result<(), ServiceError> {
7359    let (enabled, cause) = launchctl(&[
7360        std::ffi::OsStr::new("enable"),
7361        std::ffi::OsStr::new(service_target),
7362    ]);
7363    if enabled {
7364        return Ok(());
7365    }
7366
7367    let (booted_out, bootout_detail) = launchctl(&[
7368        std::ffi::OsStr::new("bootout"),
7369        std::ffi::OsStr::new(service_target),
7370    ]);
7371    let removed = std::fs::remove_file(plist);
7372    if !booted_out || removed.is_err() {
7373        return Err(ServiceError::Rollback {
7374            operation: "enable launchd registration",
7375            name: name.to_string(),
7376            cause,
7377            rollback: format!(
7378                "launchctl bootout {domain}: {}; remove {}: {}",
7379                if booted_out {
7380                    "succeeded".to_string()
7381                } else {
7382                    bootout_detail
7383                },
7384                plist.display(),
7385                removed
7386                    .err()
7387                    .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
7388            ),
7389        });
7390    }
7391
7392    if cause.to_ascii_lowercase().contains("permission denied") {
7393        Err(ServiceError::NeedsElevation {
7394            operation: "enable",
7395            name: name.to_string(),
7396            detail: cause,
7397            remedy: elevation_remedy,
7398        })
7399    } else {
7400        Err(ServiceError::Control {
7401            operation: "enable",
7402            name: name.to_string(),
7403            manager: "launchd",
7404            detail: cause,
7405        })
7406    }
7407}
7408
7409// ---------------------------------------------------------------------------
7410// Windows
7411// ---------------------------------------------------------------------------
7412
7413/// A stop request delivered by the Windows Service Control Manager.
7414///
7415/// The application owns the drain policy; this platform boundary only turns
7416/// `SERVICE_CONTROL_STOP`/`SHUTDOWN` into an awaitable notification.
7417#[derive(Debug, Clone)]
7418pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
7419
7420impl ServiceShutdown {
7421    /// Waits until SCM asks the service to stop or shut down.
7422    pub async fn wait(mut self) {
7423        if !*self.0.borrow() {
7424            let _ = self.0.changed().await;
7425        }
7426    }
7427}
7428
7429/// Runs the production process as a real Windows service host.
7430///
7431/// `StartServiceCtrlDispatcher` must run in the service process's main thread,
7432/// so the callback is handed through a process-global slot to the entrypoint
7433/// invoked by SCM. The slot is single-use by design: one process hosts exactly
7434/// one own-process service.
7435#[cfg(windows)]
7436pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
7437where
7438    F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
7439{
7440    windows_host::run(Box::new(run))
7441}
7442
7443#[cfg(windows)]
7444mod windows_host {
7445    use std::ffi::OsString;
7446    use std::sync::{Arc, Mutex, OnceLock, mpsc};
7447    use std::time::Duration;
7448
7449    use windows_service::service::{
7450        ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
7451        ServiceType,
7452    };
7453    use windows_service::service_control_handler::{
7454        self, ServiceControlHandlerResult, ServiceStatusHandle,
7455    };
7456
7457    use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
7458
7459    type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
7460
7461    struct Invocation {
7462        run: Runner,
7463        result: mpsc::SyncSender<Result<u8, String>>,
7464    }
7465
7466    static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
7467
7468    windows_service::define_windows_service!(ffi_service_main, service_main);
7469
7470    pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
7471        let (result_tx, result_rx) = mpsc::sync_channel(1);
7472        let slot = INVOCATION.get_or_init(|| Mutex::new(None));
7473        let mut invocation = slot
7474            .lock()
7475            .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
7476        if invocation.is_some() {
7477            return Err(host_error(
7478                "prepare",
7479                "the service-host slot was already used",
7480            ));
7481        }
7482        *invocation = Some(Invocation {
7483            run,
7484            result: result_tx,
7485        });
7486        drop(invocation);
7487
7488        windows_service::service_dispatcher::start("", ffi_service_main)
7489            .map_err(|error| host_error("connect", &error.to_string()))?;
7490        result_rx
7491            .recv()
7492            .map_err(|error| host_error("finish", &error.to_string()))?
7493            .map_err(|detail| host_error("run", &detail))
7494    }
7495
7496    fn service_main(_arguments: Vec<OsString>) {
7497        let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
7498            return;
7499        };
7500        let result = run_service(invocation.run);
7501        let _ = invocation.result.send(result);
7502    }
7503
7504    fn run_service(run: Runner) -> Result<u8, String> {
7505        let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
7506        let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
7507        let handler_status = Arc::clone(&status);
7508        let handler = move |control| match control {
7509            ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
7510            ServiceControl::Stop | ServiceControl::Shutdown => {
7511                if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
7512                    let _ = handle.set_service_status(service_status(
7513                        ServiceState::StopPending,
7514                        ServiceControlAccept::empty(),
7515                        1,
7516                        Duration::from_secs(300),
7517                        0,
7518                    ));
7519                }
7520                let _ = stop_tx.send(true);
7521                ServiceControlHandlerResult::NoError
7522            }
7523            _ => ServiceControlHandlerResult::NotImplemented,
7524        };
7525        let handle = service_control_handler::register("", handler)
7526            .map_err(|error| format!("cannot register the service control handler: {error}"))?;
7527        *status
7528            .lock()
7529            .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
7530        handle
7531            .set_service_status(service_status(
7532                ServiceState::Running,
7533                ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
7534                0,
7535                Duration::default(),
7536                0,
7537            ))
7538            .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
7539
7540        let exit = run(ServiceShutdown(stop_rx));
7541        handle
7542            .set_service_status(service_status(
7543                ServiceState::Stopped,
7544                ServiceControlAccept::empty(),
7545                0,
7546                Duration::default(),
7547                u32::from(exit),
7548            ))
7549            .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
7550        Ok(exit)
7551    }
7552
7553    fn service_status(
7554        state: ServiceState,
7555        accepted: ServiceControlAccept,
7556        checkpoint: u32,
7557        wait_hint: Duration,
7558        exit: u32,
7559    ) -> ServiceStatus {
7560        ServiceStatus {
7561            service_type: ServiceType::OWN_PROCESS,
7562            current_state: state,
7563            controls_accepted: accepted,
7564            exit_code: if exit == 0 {
7565                ServiceExitCode::Win32(0)
7566            } else {
7567                ServiceExitCode::ServiceSpecific(exit)
7568            },
7569            checkpoint,
7570            wait_hint,
7571            process_id: None,
7572        }
7573    }
7574
7575    fn host_error(operation: &'static str, detail: &str) -> ServiceError {
7576        ServiceError::Control {
7577            operation,
7578            name: SERVICE_NAME.to_string(),
7579            manager: "the Windows Service Control Manager",
7580            detail: detail.to_string(),
7581        }
7582    }
7583}
7584
7585#[cfg(windows)]
7586mod sys {
7587    //! Two managers, because Windows has two answers.
7588    //!
7589    //! `--start-at boot` is a service in the Service Control Manager, which is
7590    //! the only Windows facility that starts something before anybody logs in.
7591    //! `--start-at login` is a Task Scheduler task with a logon trigger, which
7592    //! is the only Windows facility that starts something when somebody does.
7593    //! There is no single mechanism that does both: service trigger-start has no
7594    //! logon trigger, and a scheduled task cannot run before a session exists.
7595
7596    use std::ffi::{OsStr, OsString};
7597    use std::time::{Duration, Instant};
7598
7599    use runner_manager_domain::model::StartMode;
7600    use windows_service::service::{
7601        ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
7602        ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
7603        ServiceState, ServiceType,
7604    };
7605    use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
7606
7607    use super::{
7608        DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
7609        ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
7610    };
7611
7612    /// `ERROR_SERVICE_DOES_NOT_EXIST`. Not an error here: it is the answer to
7613    /// "is this registered".
7614    const SERVICE_DOES_NOT_EXIST: i32 = 1060;
7615    /// `ERROR_SERVICE_MARKED_FOR_DELETE`. `DeleteService` is asynchronous: the
7616    /// registration remains in this state until every open service handle is
7617    /// closed, and callers must not mistake that short window for a leak.
7618    const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
7619    /// `ERROR_ACCESS_DENIED`.
7620    const ACCESS_DENIED: i32 = 5;
7621    const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
7622    const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
7623
7624    const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
7625         choose \"Run as administrator\".";
7626
7627    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7628        Ok(match mode {
7629            StartMode::Boot => Box::new(ScmControl),
7630            StartMode::Login => Box::new(TaskControl),
7631        })
7632    }
7633
7634    // -- the Service Control Manager -----------------------------------------
7635
7636    #[derive(Debug)]
7637    struct ScmControl;
7638
7639    /// Turns a `windows-service` failure into this module's error, keeping the
7640    /// "needs elevation" case separate: an operator told "access is denied" and
7641    /// an operator told "run this elevated" are not equally well served.
7642    fn scm_error(
7643        operation: &'static str,
7644        name: &str,
7645        error: &windows_service::Error,
7646    ) -> ServiceError {
7647        let raw = match error {
7648            windows_service::Error::Winapi(io) => io.raw_os_error(),
7649            _ => None,
7650        };
7651        let detail = match error {
7652            windows_service::Error::Winapi(io) => io.to_string(),
7653            other => other.to_string(),
7654        };
7655        if raw == Some(ACCESS_DENIED) {
7656            return ServiceError::NeedsElevation {
7657                operation,
7658                name: name.to_string(),
7659                detail,
7660                remedy: ELEVATION_REMEDY,
7661            };
7662        }
7663        ServiceError::Control {
7664            operation,
7665            name: name.to_string(),
7666            manager: "the Windows Service Control Manager",
7667            detail,
7668        }
7669    }
7670
7671    fn open_manager(
7672        access: ServiceManagerAccess,
7673        operation: &'static str,
7674        name: &str,
7675    ) -> Result<ServiceManager, ServiceError> {
7676        ServiceManager::local_computer(None::<&OsStr>, access)
7677            .map_err(|error| scm_error(operation, name, &error))
7678    }
7679
7680    impl ServiceControl for ScmControl {
7681        fn manager(&self) -> DefinitionKind {
7682            DefinitionKind::WindowsService
7683        }
7684
7685        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7686            let spec = windows_service_spec(plan);
7687            let manager = open_manager(
7688                ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
7689                "install",
7690                &spec.name,
7691            )?;
7692            let info = ServiceInfo {
7693                name: OsString::from(&spec.name),
7694                display_name: OsString::from(&spec.display_name),
7695                // Never INTERACTIVE_PROCESS: see `windows_service_descriptor`.
7696                service_type: ServiceType::OWN_PROCESS,
7697                start_type: if spec.automatic_start {
7698                    ServiceStartType::AutoStart
7699                } else {
7700                    ServiceStartType::OnDemand
7701                },
7702                error_control: ServiceErrorControl::Normal,
7703                executable_path: plan.binary().to_path_buf(),
7704                launch_arguments: plan.arguments().to_vec(),
7705                dependencies: Vec::new(),
7706                // `None` is LocalSystem, which is the account `d2`'s DACL admits.
7707                account_name: spec.account.as_ref().map(OsString::from),
7708                // No password is ever taken, asked for, or stored. An account
7709                // that needed one would be an account this installer refuses.
7710                account_password: None,
7711            };
7712            let service = manager
7713                .create_service(
7714                    &info,
7715                    ServiceAccess::CHANGE_CONFIG
7716                        | ServiceAccess::QUERY_CONFIG
7717                        | ServiceAccess::QUERY_STATUS
7718                        | ServiceAccess::START
7719                        | ServiceAccess::STOP
7720                        | ServiceAccess::DELETE,
7721                )
7722                .map_err(|error| scm_error("install", &spec.name, &error))?;
7723            service
7724                .set_description(&spec.description)
7725                .map_err(|error| scm_error("describe", &spec.name, &error))?;
7726            service
7727                .update_failure_actions(ServiceFailureActions {
7728                    reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
7729                    reboot_msg: None,
7730                    command: None,
7731                    // Three identical restart actions rather than one: the
7732                    // Service Control Manager applies the first action to the
7733                    // first failure, the second to the second, and the last to
7734                    // every failure after that. One action would leave the
7735                    // second and later failures unhandled, which is a service
7736                    // that comes back once and then stays down.
7737                    actions: Some(vec![
7738                        ServiceAction {
7739                            action_type: ServiceActionType::Restart,
7740                            delay: spec.restart.delay(),
7741                        };
7742                        3
7743                    ]),
7744                })
7745                .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7746            // Without this the failure actions apply only to a crash, and a
7747            // daemon that exits non-zero is not a crash.
7748            service
7749                .set_failure_actions_on_non_crash_failures(true)
7750                .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7751            Ok(ServiceDefinition::windows_service(plan))
7752        }
7753
7754        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7755            let manager =
7756                open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
7757            let service = match manager.open_service(
7758                identity.name(),
7759                ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
7760            ) {
7761                Ok(service) => Some(service),
7762                Err(error) if is_missing(&error) => return Ok(false),
7763                // A previous caller has already asked SCM to delete this
7764                // registration. That is the desired state, not an uninstall
7765                // failure. Wait for SCM to release the name so a following
7766                // `service install` can use it immediately.
7767                Err(error) if is_marked_for_delete(&error) => None,
7768                Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
7769            };
7770            if let Some(service) = service {
7771                // A running service can be deleted, but it lingers until it stops.
7772                // Stopping first is what makes `uninstall` followed by `install`
7773                // work in one sitting, which is what the start-mode switch needs.
7774                if let Ok(status) = service.query_status()
7775                    && status.current_state != ServiceState::Stopped
7776                {
7777                    let _ = service.stop();
7778                }
7779                match service.delete() {
7780                    Ok(()) => {}
7781                    // DeleteService is idempotent from the operator's point of
7782                    // view. Another process can mark the service between our
7783                    // OpenService and DeleteService calls.
7784                    Err(error) if is_marked_for_delete(&error) => {}
7785                    Err(error) => {
7786                        return Err(scm_error("uninstall", identity.name(), &error));
7787                    }
7788                }
7789                drop(service);
7790            }
7791
7792            // `DeleteService` marks a registration for deletion and returns;
7793            // SCM removes it only after the last service handle closes. Drop
7794            // ours before polling, then wait for the observable postcondition
7795            // promised by `uninstall`: an immediate status check must not see
7796            // a registration that is merely on its way out. This also keeps a
7797            // stop/uninstall/install sequence deterministic on busy hosts.
7798            let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
7799                match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
7800                    Ok(service) => {
7801                        drop(service);
7802                        Ok(false)
7803                    }
7804                    Err(error) if is_missing(&error) => Ok(true),
7805                    Err(error) if is_marked_for_delete(&error) => Ok(false),
7806                    Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
7807                }
7808            })?;
7809            if !absent {
7810                return Err(ServiceError::Control {
7811                    operation: "verify uninstall of",
7812                    name: identity.name().to_string(),
7813                    manager: "the Windows Service Control Manager",
7814                    detail: format!(
7815                        "the registration was still visible {} seconds after DeleteService; \
7816                         retry `service uninstall` from an elevated prompt",
7817                        DELETE_TIMEOUT.as_secs()
7818                    ),
7819                });
7820            }
7821            Ok(true)
7822        }
7823
7824        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7825            let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
7826            let service = match manager.open_service(
7827                identity.name(),
7828                ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
7829            ) {
7830                Ok(service) => service,
7831                Err(error) if is_missing(&error) => return Ok(None),
7832                Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
7833            };
7834            let config = service
7835                .query_config()
7836                .map_err(|error| scm_error("inspect", identity.name(), &error))?;
7837            let status = service
7838                .query_status()
7839                .map_err(|error| scm_error("inspect", identity.name(), &error))?;
7840            let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
7841                actions
7842                    .actions
7843                    .and_then(|actions| actions.into_iter().next())
7844                    .filter(|action| action.action_type == ServiceActionType::Restart)
7845                    .map(|action| action.delay)
7846            });
7847            Ok(Some(Registration {
7848                manager: DefinitionKind::WindowsService,
7849                start_mode: StartMode::Boot,
7850                // `lpBinaryPathName` holds the executable *and* its arguments
7851                // as one string, which is why this module carries its own
7852                // parser rather than trusting `executable_path`'s name.
7853                command_line: config.executable_path.to_string_lossy().into_owned(),
7854                account: config
7855                    .account_name
7856                    .map(|account| super::windows_service_account(&account.to_string_lossy())),
7857                running: status.current_state == ServiceState::Running,
7858                starts_automatically: config.start_type == ServiceStartType::AutoStart,
7859                restart_delay,
7860            }))
7861        }
7862
7863        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7864            let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
7865            let service = manager
7866                .open_service(identity.name(), ServiceAccess::START)
7867                .map_err(|error| scm_error("start", identity.name(), &error))?;
7868            service
7869                .start::<&OsStr>(&[])
7870                .map_err(|error| scm_error("start", identity.name(), &error))
7871        }
7872
7873        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7874            let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
7875            let service = manager
7876                .open_service(
7877                    identity.name(),
7878                    ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
7879                )
7880                .map_err(|error| scm_error("stop", identity.name(), &error))?;
7881            let status = service
7882                .query_status()
7883                .map_err(|error| scm_error("stop", identity.name(), &error))?;
7884            if status.current_state == ServiceState::Stopped {
7885                return Ok(false);
7886            }
7887            service
7888                .stop()
7889                .map_err(|error| scm_error("stop", identity.name(), &error))?;
7890            Ok(true)
7891        }
7892    }
7893
7894    fn is_missing(error: &windows_service::Error) -> bool {
7895        matches!(error, windows_service::Error::Winapi(io)
7896            if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
7897    }
7898
7899    pub(super) fn is_marked_for_delete(error: &windows_service::Error) -> bool {
7900        matches!(error, windows_service::Error::Winapi(io)
7901            if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
7902    }
7903
7904    pub(super) fn wait_until_scm_absent(
7905        timeout: Duration,
7906        poll_interval: Duration,
7907        mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
7908    ) -> Result<bool, ServiceError> {
7909        let deadline = Instant::now() + timeout;
7910        loop {
7911            if probe_absent()? {
7912                return Ok(true);
7913            }
7914            if Instant::now() >= deadline {
7915                return Ok(false);
7916            }
7917            std::thread::sleep(poll_interval);
7918        }
7919    }
7920
7921    // -- Task Scheduler ------------------------------------------------------
7922
7923    #[derive(Debug)]
7924    struct TaskControl;
7925
7926    fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
7927        if detail.to_ascii_lowercase().contains("access is denied") {
7928            return ServiceError::NeedsElevation {
7929                operation,
7930                name: name.to_string(),
7931                detail,
7932                remedy: ELEVATION_REMEDY,
7933            };
7934        }
7935        ServiceError::Control {
7936            operation,
7937            name: name.to_string(),
7938            manager: "Windows Task Scheduler",
7939            detail,
7940        }
7941    }
7942
7943    fn schtasks(
7944        operation: &'static str,
7945        name: &str,
7946        arguments: &[&OsStr],
7947    ) -> Result<(bool, String), ServiceError> {
7948        match run("schtasks.exe", arguments) {
7949            Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
7950            Err(error) => Err(task_error(operation, name, error.to_string())),
7951        }
7952    }
7953
7954    /// Task Scheduler reads `/XML` files as UTF-16, so the document is written
7955    /// as UTF-16 little-endian with a byte-order mark rather than as UTF-8.
7956    fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
7957        let mut bytes = vec![0xFF, 0xFE];
7958        for unit in text.encode_utf16() {
7959            bytes.extend_from_slice(&unit.to_le_bytes());
7960        }
7961        std::fs::write(path, bytes)
7962    }
7963
7964    impl ServiceControl for TaskControl {
7965        fn manager(&self) -> DefinitionKind {
7966            DefinitionKind::WindowsScheduledTask
7967        }
7968
7969        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7970            let name = plan.identity().name().to_string();
7971            let principal = TaskPrincipal::current()?;
7972            let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
7973            let directory = tempfile::tempdir()
7974                .map_err(|error| task_error("install", &name, error.to_string()))?;
7975            let document = directory.path().join("task.xml");
7976            write_utf16(&document, definition.text())
7977                .map_err(|error| task_error("install", &name, error.to_string()))?;
7978            let (ok, message) = schtasks(
7979                "install",
7980                &name,
7981                &[
7982                    OsStr::new("/Create"),
7983                    OsStr::new("/TN"),
7984                    OsStr::new(&name),
7985                    OsStr::new("/XML"),
7986                    document.as_os_str(),
7987                ],
7988            )?;
7989            if !ok {
7990                return Err(task_error("install", &name, message));
7991            }
7992            Ok(definition)
7993        }
7994
7995        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7996            if self.query(identity)?.is_none() {
7997                return Ok(false);
7998            }
7999            let name = identity.name().to_string();
8000            let (ok, message) = schtasks(
8001                "uninstall",
8002                &name,
8003                &[
8004                    OsStr::new("/Delete"),
8005                    OsStr::new("/TN"),
8006                    OsStr::new(&name),
8007                    OsStr::new("/F"),
8008                ],
8009            )?;
8010            if !ok {
8011                return Err(task_error("uninstall", &name, message));
8012            }
8013            Ok(true)
8014        }
8015
8016        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8017            let name = identity.name().to_string();
8018            let (ok, document) = schtasks(
8019                "inspect",
8020                &name,
8021                &[
8022                    OsStr::new("/Query"),
8023                    OsStr::new("/TN"),
8024                    OsStr::new(&name),
8025                    OsStr::new("/XML"),
8026                    OsStr::new("ONE"),
8027                ],
8028            )?;
8029            if !ok {
8030                // `schtasks` reports a missing task and a broken Task Scheduler
8031                // the same way, with a non-zero exit; there is no distinct code.
8032                // Treating it as absence is the safe reading: the caller either
8033                // installs, which will then fail loudly, or reports "not
8034                // installed", which is what an operator with no task sees.
8035                return Ok(None);
8036            }
8037            let command = xml_value(&document, "Command").unwrap_or_default();
8038            let arguments = xml_value(&document, "Arguments").unwrap_or_default();
8039            let command_line = if arguments.is_empty() {
8040                super::quote_argument(&command)
8041            } else {
8042                format!("{} {arguments}", super::quote_argument(&command))
8043            };
8044            Ok(Some(Registration {
8045                manager: DefinitionKind::WindowsScheduledTask,
8046                start_mode: StartMode::Login,
8047                command_line,
8048                account: super::scheduled_task_account(xml_value(&document, "UserId")),
8049                running: task_is_running(&name),
8050                starts_automatically: super::windows_login_task_starts_automatically(&document),
8051                restart_delay: xml_value(&document, "Interval")
8052                    .as_deref()
8053                    .and_then(parse_iso8601),
8054            }))
8055        }
8056
8057        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8058            let name = identity.name().to_string();
8059            let (ok, message) = schtasks(
8060                "start",
8061                &name,
8062                &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
8063            )?;
8064            if ok {
8065                Ok(())
8066            } else {
8067                Err(task_error("start", &name, message))
8068            }
8069        }
8070
8071        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8072            let running = self
8073                .query(identity)?
8074                .is_some_and(|registration| registration.running);
8075            if !running {
8076                return Ok(false);
8077            }
8078            let name = identity.name().to_string();
8079            let (ok, message) = schtasks(
8080                "stop",
8081                &name,
8082                &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
8083            )?;
8084            if ok {
8085                Ok(true)
8086            } else {
8087                Err(task_error("stop", &name, message))
8088            }
8089        }
8090    }
8091
8092    /// Whether Task Scheduler reports the task as running.
8093    ///
8094    /// **This is the one value in this module read from localised output.**
8095    /// `schtasks /Query /FO CSV` prints a `Status` column in the machine's
8096    /// display language, and Task Scheduler exposes no locale-independent
8097    /// equivalent short of COM. On a non-English Windows this therefore reports
8098    /// `false` for a task that is in fact running.
8099    ///
8100    /// That is stated rather than hidden because of what does *not* depend on
8101    /// it: Journey 5's liveness question is answered by the last successful
8102    /// GitHub contact, which is a timestamp this product writes itself and no
8103    /// locale can move. Nothing in the Definition of Done rests on this
8104    /// boolean.
8105    fn task_is_running(name: &str) -> bool {
8106        let Ok((true, stdout, _)) = run(
8107            "schtasks.exe",
8108            &[
8109                OsStr::new("/Query"),
8110                OsStr::new("/TN"),
8111                OsStr::new(name),
8112                OsStr::new("/FO"),
8113                OsStr::new("CSV"),
8114                OsStr::new("/NH"),
8115            ],
8116        ) else {
8117            return false;
8118        };
8119        stdout
8120            .lines()
8121            .filter_map(|line| line.rsplit(',').next())
8122            .any(|status| {
8123                status
8124                    .trim()
8125                    .trim_matches('"')
8126                    .eq_ignore_ascii_case("running")
8127            })
8128    }
8129
8130    /// Parses the `PT<n>M` shape this module writes, and the `PT<n>S` shape
8131    /// Task Scheduler would accept if it took seconds. Anything richer -- days,
8132    /// hours, a combination -- is `None` rather than a guess.
8133    fn parse_iso8601(value: &str) -> Option<Duration> {
8134        let rest = value.strip_prefix("PT")?;
8135        if let Some(minutes) = rest.strip_suffix('M') {
8136            return minutes
8137                .parse::<u64>()
8138                .ok()
8139                .map(|minutes| Duration::from_secs(minutes * 60));
8140        }
8141        rest.strip_suffix('S')?
8142            .parse::<u64>()
8143            .ok()
8144            .map(Duration::from_secs)
8145    }
8146}
8147
8148// ---------------------------------------------------------------------------
8149// Unix: shared plumbing
8150// ---------------------------------------------------------------------------
8151
8152/// Writes a definition file, creating its directory, and says when the refusal
8153/// was a permissions one.
8154///
8155/// Both Unix backends write a file into a directory only `root` can write —
8156/// `/Library/LaunchDaemons` and `/etc/systemd/system` — and an operator who ran
8157/// `service install` without `sudo` deserves to be told that rather than handed
8158/// an `EACCES`.
8159#[cfg(unix)]
8160fn write_definition(
8161    operation: &'static str,
8162    name: &str,
8163    path: &Path,
8164    text: &str,
8165    remedy: &'static str,
8166) -> Result<(), ServiceError> {
8167    if let Some(parent) = path.parent()
8168        && let Err(error) = std::fs::create_dir_all(parent)
8169        && error.kind() != std::io::ErrorKind::AlreadyExists
8170    {
8171        return Err(definition_error(operation, name, error, remedy, parent));
8172    }
8173    std::fs::write(path, text)
8174        .map_err(|error| definition_error(operation, name, error, remedy, path))
8175}
8176
8177/// Replaces an installed definition with [`DefinitionDrift::rendered`].
8178///
8179/// Through a temporary file in the same directory and a rename, so a failure
8180/// at any point leaves the old definition in place rather than a truncated
8181/// one. The mode is set to `0644` before the rename, as `service install`
8182/// leaves it: launchd refuses a plist that others can write.
8183#[cfg(all(unix, any(target_os = "macos", test)))]
8184fn replace_definition(name: &str, drift: &DefinitionDrift) -> Result<(), ServiceError> {
8185    use std::io::Write as _;
8186
8187    let path = drift.path.as_path();
8188    let directory = path.parent().unwrap_or_else(|| Path::new("/"));
8189    let failed = |error: std::io::Error, at: &Path| {
8190        definition_error("rewrite", name, error, SUDO_REMEDY, at)
8191    };
8192    let mut staged =
8193        tempfile::NamedTempFile::new_in(directory).map_err(|error| failed(error, directory))?;
8194    staged
8195        .write_all(drift.rendered.as_bytes())
8196        .and_then(|()| staged.as_file().sync_all())
8197        .map_err(|error| failed(error, staged.path()))?;
8198    std::fs::set_permissions(
8199        staged.path(),
8200        <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o644),
8201    )
8202    .map_err(|error| failed(error, path))?;
8203    staged
8204        .persist(path)
8205        .map(drop)
8206        .map_err(|error| failed(error.error, path))
8207}
8208
8209#[cfg(unix)]
8210fn definition_error(
8211    operation: &'static str,
8212    name: &str,
8213    error: std::io::Error,
8214    remedy: &'static str,
8215    path: &Path,
8216) -> ServiceError {
8217    let detail = format!("{}: {error}", path.display());
8218    if error.kind() == std::io::ErrorKind::PermissionDenied {
8219        ServiceError::NeedsElevation {
8220            operation,
8221            name: name.to_string(),
8222            detail,
8223            remedy,
8224        }
8225    } else {
8226        ServiceError::Control {
8227            operation,
8228            name: name.to_string(),
8229            manager: "the local service manager",
8230            detail,
8231        }
8232    }
8233}
8234
8235/// `sudo` is the remedy on both Unixes, and the message says which command.
8236#[cfg(unix)]
8237const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
8238     `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
8239     agent not running until you sign in.";
8240
8241// ---------------------------------------------------------------------------
8242// macOS
8243// ---------------------------------------------------------------------------
8244
8245#[cfg(target_os = "macos")]
8246mod sys {
8247    //! One manager, two domains. `--start-at boot` is a LaunchDaemon in
8248    //! `/Library/LaunchDaemons`, loaded into launchd's `system` domain;
8249    //! `--start-at login` is a LaunchAgent in the operator's own
8250    //! `~/Library/LaunchAgents`, loaded into `gui/<uid>`.
8251    //!
8252    //! `launchctl print` is the only inspection command here whose output is
8253    //! parsed, and it is English-only on every macOS release — launchd has no
8254    //! localisation for it. The registration's *existence* is decided by the
8255    //! plist file rather than by that output, so a launchd that changed its
8256    //! wording would cost this module the running/not-running line and nothing
8257    //! else.
8258
8259    use std::ffi::OsStr;
8260    use std::path::PathBuf;
8261    use std::time::Duration;
8262
8263    use runner_manager_domain::model::StartMode;
8264
8265    use super::{
8266        DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
8267        SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
8268        enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
8269        write_definition, xml_value,
8270    };
8271
8272    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
8273        Ok(Box::new(LaunchdControl { mode }))
8274    }
8275
8276    #[derive(Debug)]
8277    struct LaunchdControl {
8278        mode: StartMode,
8279    }
8280
8281    impl LaunchdControl {
8282        /// launchd's domain target for this start mode.
8283        fn domain(&self) -> String {
8284            match self.mode {
8285                StartMode::Boot => "system".to_string(),
8286                // SAFETY: `getuid` reads the calling process's real user id and
8287                // cannot fail; it takes no arguments and touches no memory.
8288                StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
8289            }
8290        }
8291
8292        fn service_target(&self, identity: &ServiceIdentity) -> String {
8293            format!("{}/{}", self.domain(), identity.launchd_label())
8294        }
8295
8296        /// Where this domain's plist lives, when the domain has a home to put
8297        /// it in.
8298        fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
8299            let file = format!("{}.plist", identity.launchd_label());
8300            match self.mode {
8301                StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
8302                StartMode::Login => {
8303                    home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
8304                }
8305            }
8306        }
8307
8308        fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
8309            ServiceError::Control {
8310                operation,
8311                name: name.to_string(),
8312                manager: "launchd",
8313                detail,
8314            }
8315        }
8316
8317        fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
8318            match run("launchctl", arguments) {
8319                Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
8320                Err(error) => (false, error.to_string()),
8321            }
8322        }
8323    }
8324
8325    impl ServiceControl for LaunchdControl {
8326        fn manager(&self) -> DefinitionKind {
8327            DefinitionKind::LaunchdPlist
8328        }
8329
8330        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8331            let name = plan.identity().name().to_string();
8332            let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
8333            let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
8334                return Err(self.failed(
8335                    "install",
8336                    &name,
8337                    "this account has no home directory, so there is nowhere to put a \
8338                     LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
8339                     /Library/LaunchDaemons."
8340                        .to_string(),
8341                ));
8342            };
8343            write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8344            let target = self.domain();
8345            let (ok, message) = self.launchctl(&[
8346                OsStr::new("bootstrap"),
8347                OsStr::new(&target),
8348                path.as_os_str(),
8349            ]);
8350            if !ok {
8351                // Leave nothing behind: a plist launchd refused is a file that
8352                // would make the next `service status` claim a registration
8353                // that does not exist.
8354                let _ = std::fs::remove_file(&path);
8355                if message.to_ascii_lowercase().contains("permission denied") {
8356                    return Err(ServiceError::NeedsElevation {
8357                        operation: "install",
8358                        name,
8359                        detail: message,
8360                        remedy: SUDO_REMEDY,
8361                    });
8362                }
8363                return Err(self.failed("install", &name, message));
8364            }
8365            // A previously disabled label stays disabled through a bootstrap,
8366            // which is a service that is installed and will never start.
8367            let service_target = self.service_target(plan.identity());
8368            enable_launchd_registration(
8369                |arguments| self.launchctl(arguments),
8370                &target,
8371                &service_target,
8372                &path,
8373                &name,
8374                SUDO_REMEDY,
8375            )?;
8376            Ok(definition)
8377        }
8378
8379        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8380            let Some(path) = self.plist_path(identity) else {
8381                return Ok(false);
8382            };
8383            if !path.exists() {
8384                return Ok(false);
8385            }
8386            let target = self.service_target(identity);
8387            let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
8388            if !ok
8389                && !message.to_ascii_lowercase().contains("no such process")
8390                && !message.contains("113")
8391            {
8392                if message.to_ascii_lowercase().contains("permission denied") {
8393                    return Err(ServiceError::NeedsElevation {
8394                        operation: "uninstall",
8395                        name: identity.name().to_string(),
8396                        detail: message,
8397                        remedy: SUDO_REMEDY,
8398                    });
8399                }
8400                return Err(self.failed("uninstall", identity.name(), message));
8401            }
8402            // Only the definition. `05-infrastructure.md` item 5: nothing else
8403            // on this host is touched here or anywhere below it.
8404            std::fs::remove_file(&path).map_err(|error| {
8405                super::definition_error(
8406                    "uninstall",
8407                    identity.name(),
8408                    error,
8409                    SUDO_REMEDY,
8410                    path.as_path(),
8411                )
8412            })?;
8413            Ok(true)
8414        }
8415
8416        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8417            let Some(path) = self.plist_path(identity) else {
8418                return Ok(None);
8419            };
8420            let Ok(document) = std::fs::read_to_string(&path) else {
8421                return Ok(None);
8422            };
8423            let target = self.service_target(identity);
8424            let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
8425            Ok(Some(Registration {
8426                manager: DefinitionKind::LaunchdPlist,
8427                start_mode: self.mode,
8428                command_line: program_arguments(&document),
8429                // A LaunchDaemon without `UserName` runs as root; a LaunchAgent
8430                // runs as whoever signs in, which names no one in particular.
8431                account: plist_string_value(&document, "UserName").or_else(|| {
8432                    (self.mode == StartMode::Boot)
8433                        .then(|| super::ServiceAccount::Root.as_str().to_string())
8434                }),
8435                running: loaded && printed.contains("state = running"),
8436                starts_automatically: document.contains("<key>RunAtLoad</key>")
8437                    && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
8438                restart_delay: xml_value(
8439                    super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
8440                    "integer",
8441                )
8442                .and_then(|value| value.parse::<u64>().ok())
8443                .map(Duration::from_secs),
8444            }))
8445        }
8446
8447        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8448            let target = self.service_target(identity);
8449            let (ok, message) = self.launchctl(&[
8450                OsStr::new("kickstart"),
8451                OsStr::new("-k"),
8452                OsStr::new(&target),
8453            ]);
8454            if ok {
8455                Ok(())
8456            } else {
8457                Err(self.failed("start", identity.name(), message))
8458            }
8459        }
8460
8461        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8462            let running = self
8463                .query(identity)?
8464                .is_some_and(|registration| registration.running);
8465            if !running {
8466                return Ok(false);
8467            }
8468            let target = self.service_target(identity);
8469            let (ok, message) = self.launchctl(&[
8470                OsStr::new("kill"),
8471                OsStr::new("SIGTERM"),
8472                OsStr::new(&target),
8473            ]);
8474            if ok {
8475                Ok(true)
8476            } else {
8477                Err(self.failed("stop", identity.name(), message))
8478            }
8479        }
8480    }
8481
8482    /// Boots the job out and bootstraps it again from `plist`, from a shell
8483    /// that outlives this process.
8484    ///
8485    /// `setsid` puts the shell in a session and process group of its own. On a
8486    /// bootout launchd sends SIGTERM to the job's whole process group, the
8487    /// process that asked for the bootout included, and spares a child in
8488    /// another session (measured on macOS 27). Without `setsid` the helper
8489    /// survived that only by luck: one run in four left the job unloaded. The
8490    /// work runs in a background subshell, so the outer shell exits at once and
8491    /// is reaped here rather than left behind. `bootout` blocks until the daemon
8492    /// has exited; the bootstrap is retried for a minute because launchd
8493    /// refuses it while the old job is still being torn down. When the job is
8494    /// still loaded after a failed bootout, every bootstrap fails and the
8495    /// running daemon carries on as it was.
8496    pub(super) fn reload_detached(
8497        identity: &ServiceIdentity,
8498        mode: StartMode,
8499        plist: &std::path::Path,
8500    ) -> Result<(), ServiceError> {
8501        use std::os::unix::process::CommandExt;
8502
8503        const SCRIPT: &str = r#"(
8504  sleep 1
8505  /bin/launchctl bootout "$1"
8506  tries=0
8507  until /bin/launchctl bootstrap "$2" "$3"; do
8508    tries=$((tries + 1))
8509    if [ "$tries" -ge 60 ]; then
8510      echo "runner-manager: launchd did not load $3 again; run: $4" >&2
8511      exit 1
8512    fi
8513    sleep 1
8514  done
8515) </dev/null &"#;
8516
8517        let control = LaunchdControl { mode };
8518        // Once the job is booted out nothing holds the single-instance lock,
8519        // so a reinstall in the same start mode is the remedy.
8520        let reinstall = match mode {
8521            StartMode::Boot => "sudo runner-manager service install --start-at boot",
8522            StartMode::Login => "runner-manager service install --start-at login",
8523        };
8524        let mut command = std::process::Command::new("/bin/sh");
8525        command
8526            .args(["-c", SCRIPT, "runner-manager-reload"])
8527            .arg(control.service_target(identity))
8528            .arg(control.domain())
8529            .arg(plist)
8530            .arg(reinstall)
8531            .stdin(std::process::Stdio::null());
8532        // SAFETY: one async-signal-safe system call between `fork` and `exec`,
8533        // touching no memory, allocator or lock.
8534        unsafe {
8535            command.pre_exec(|| {
8536                if libc::setsid() == -1 {
8537                    return Err(std::io::Error::last_os_error());
8538                }
8539                Ok(())
8540            });
8541        }
8542        let status = command.status().map_err(|error| {
8543            control.failed("reload", identity.name(), format!("/bin/sh: {error}"))
8544        })?;
8545        if status.success() {
8546            Ok(())
8547        } else {
8548            Err(control.failed(
8549                "reload",
8550                identity.name(),
8551                format!("the reload helper exited with {status}"),
8552            ))
8553        }
8554    }
8555
8556    /// Rebuilds the command line from a plist's `ProgramArguments` array.
8557    fn program_arguments(document: &str) -> String {
8558        let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
8559            return String::new();
8560        };
8561        let Some(end) = rest.find("</array>") else {
8562            return String::new();
8563        };
8564        let mut out = Vec::new();
8565        let mut cursor = &rest[..end];
8566        while let Some(open) = cursor.find("<string>") {
8567            let after = &cursor[open + "<string>".len()..];
8568            let Some(close) = after.find("</string>") else {
8569                break;
8570            };
8571            out.push(quote_argument(&super::xml_unescape(&after[..close])));
8572            cursor = &after[close..];
8573        }
8574        out.join(" ")
8575    }
8576}
8577
8578// ---------------------------------------------------------------------------
8579// Linux and the other Unixes
8580// ---------------------------------------------------------------------------
8581
8582#[cfg(all(unix, not(target_os = "macos")))]
8583mod sys {
8584    //! One manager, two domains. `--start-at boot` is a system unit in
8585    //! `/etc/systemd/system`, wanted by `multi-user.target`; `--start-at login`
8586    //! is a user unit in `~/.config/systemd/user`, wanted by `default.target`.
8587    //!
8588    //! Every inspection here goes through `systemctl cat` and `systemctl show`,
8589    //! whose output is machine-readable `key=value` text rather than a
8590    //! sentence, so nothing in this module depends on the machine's display
8591    //! language.
8592
8593    use std::ffi::OsStr;
8594    use std::path::PathBuf;
8595
8596    use runner_manager_domain::model::StartMode;
8597
8598    use super::{
8599        DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
8600        SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
8601        home_directory, ini_directives, run, systemd_exec_command_line, systemd_timespan,
8602        write_definition,
8603    };
8604
8605    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
8606        Ok(Box::new(SystemdControl { mode }))
8607    }
8608
8609    #[derive(Debug)]
8610    struct SystemdControl {
8611        mode: StartMode,
8612    }
8613
8614    impl SystemdControl {
8615        fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
8616            let file = identity.systemd_unit();
8617            match self.mode {
8618                StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
8619                StartMode::Login => {
8620                    home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
8621                }
8622            }
8623        }
8624
8625        /// `systemctl`, with `--user` for the login domain.
8626        fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
8627            let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
8628            if self.mode == StartMode::Login {
8629                all.push(OsStr::new("--user"));
8630            }
8631            all.extend(arguments.iter().map(OsStr::new));
8632            match run("systemctl", &all) {
8633                Ok((ok, stdout, stderr)) => (
8634                    ok,
8635                    if stdout.trim().is_empty() {
8636                        stderr
8637                    } else {
8638                        stdout
8639                    },
8640                ),
8641                Err(error) => (false, error.to_string()),
8642            }
8643        }
8644
8645        fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
8646            ServiceError::Control {
8647                operation,
8648                name: name.to_string(),
8649                manager: "systemd",
8650                detail,
8651            }
8652        }
8653    }
8654
8655    impl ServiceControl for SystemdControl {
8656        fn manager(&self) -> DefinitionKind {
8657            DefinitionKind::SystemdUnit
8658        }
8659
8660        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8661            use std::os::unix::fs::MetadataExt as _;
8662
8663            let name = plan.identity().name().to_string();
8664            if self.mode == StartMode::Boot {
8665                // The credential store's directory as well as the four: the
8666                // service rewrites the credential there when GitHub rotates it.
8667                let refusals = super::permitted_paths(DefinitionKind::SystemdUnit, plan)
8668                    .iter()
8669                    .filter_map(|path| {
8670                        let path = std::path::Path::new(path);
8671                        let owner = std::fs::metadata(path).ok().map(|metadata| metadata.uid());
8672                        super::boot_directory_refusal(path, owner)
8673                    })
8674                    .collect::<Vec<_>>();
8675                if !refusals.is_empty() {
8676                    return Err(self.failed(
8677                        "install",
8678                        &name,
8679                        format!(
8680                            "{}. Run `{}` without --data-dir, which uses {} and {}; a \
8681                             directory there that root does not own is handed back with \
8682                             `sudo chown -R root:root <directory>`",
8683                            refusals.join("; "),
8684                            super::sudo_command("service install --start-at boot"),
8685                            crate::paths::SYSTEM_DATA_ROOT,
8686                            crate::paths::SYSTEM_LOGS_DIR,
8687                        ),
8688                    ));
8689                }
8690            }
8691            // The accounts the daemon starts its runners as, one per concurrent
8692            // runner. Created here, from the elevated install, since the
8693            // service itself can create no account.
8694            if self.mode == StartMode::Boot
8695                && let Err(detail) = crate::runner_account::RunnerPool::ensure()
8696            {
8697                return Err(self.failed("install", &name, detail));
8698            }
8699            let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
8700            let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
8701                return Err(self.failed(
8702                    "install",
8703                    &name,
8704                    "this account has no home directory, so there is nowhere to put a systemd \
8705                     user unit. Use --start-at boot, which installs a system unit under \
8706                     /etc/systemd/system."
8707                        .to_string(),
8708                ));
8709            };
8710            write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8711            let unit = plan.identity().systemd_unit();
8712            let (reloaded, message) = self.systemctl(&["daemon-reload"]);
8713            if !reloaded {
8714                let _ = std::fs::remove_file(&path);
8715                return Err(self.failed("install", &name, message));
8716            }
8717            let (enabled, message) = self.systemctl(&["enable", &unit]);
8718            if !enabled {
8719                let _ = std::fs::remove_file(&path);
8720                let _ = self.systemctl(&["daemon-reload"]);
8721                return Err(self.failed("install", &name, message));
8722            }
8723            Ok(definition)
8724        }
8725
8726        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8727            let Some(path) = self.unit_path(identity) else {
8728                return Ok(false);
8729            };
8730            if !path.exists() {
8731                return Ok(false);
8732            }
8733            let unit = identity.systemd_unit();
8734            // `--now` stops it as well as disabling it. A failure here is not
8735            // fatal: the unit file is about to go, and refusing to remove it
8736            // because `systemctl` disliked something would leave the host with
8737            // a unit nothing manages.
8738            let _ = self.systemctl(&["disable", "--now", &unit]);
8739            std::fs::remove_file(&path).map_err(|error| {
8740                super::definition_error(
8741                    "uninstall",
8742                    identity.name(),
8743                    error,
8744                    SUDO_REMEDY,
8745                    path.as_path(),
8746                )
8747            })?;
8748            let _ = self.systemctl(&["daemon-reload"]);
8749            Ok(true)
8750        }
8751
8752        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8753            let Some(path) = self.unit_path(identity) else {
8754                return Ok(None);
8755            };
8756            if !path.exists() {
8757                return Ok(None);
8758            }
8759            let unit = identity.systemd_unit();
8760            // `cat` includes administrator drop-ins in systemd's order. Reading
8761            // only our base file hides User/ExecStart/RestartSec overrides, so
8762            // the file is read only when `systemctl` cannot answer.
8763            let effective = match self.systemctl(&["cat", &unit]) {
8764                (true, text) => text,
8765                _ => match std::fs::read_to_string(&path) {
8766                    Ok(document) => document,
8767                    Err(_) => return Ok(None),
8768                },
8769            };
8770            let directives = ini_directives(&effective, "Service");
8771            // One `show` answers what `is-active` and `is-enabled` each did.
8772            let (_, shown) = self.systemctl(&[
8773                "show",
8774                "--property=ActiveState",
8775                "--property=UnitFileState",
8776                &unit,
8777            ]);
8778            let state = |key: &str| {
8779                shown
8780                    .lines()
8781                    .find_map(|line| line.strip_prefix(key)?.strip_prefix('='))
8782                    .map(str::trim)
8783            };
8784            Ok(Some(Registration {
8785                manager: DefinitionKind::SystemdUnit,
8786                start_mode: self.mode,
8787                command_line: directives
8788                    .get("ExecStart")
8789                    .map(|value| systemd_exec_command_line(value))
8790                    .unwrap_or_default(),
8791                // A system unit without `User=` runs as root; a user unit runs
8792                // as whoever owns the session, which names no one in particular.
8793                account: directives
8794                    .get("User")
8795                    .filter(|user| !user.is_empty())
8796                    .cloned()
8797                    .or_else(|| {
8798                        (self.mode == StartMode::Boot)
8799                            .then(|| super::ServiceAccount::Root.as_str().to_string())
8800                    }),
8801                running: state("ActiveState") == Some("active"),
8802                starts_automatically: state("UnitFileState") == Some("enabled"),
8803                restart_delay: directives
8804                    .get("RestartSec")
8805                    .and_then(|value| systemd_timespan(value)),
8806            }))
8807        }
8808
8809        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8810            let unit = identity.systemd_unit();
8811            let (ok, message) = self.systemctl(&["start", &unit]);
8812            if ok {
8813                Ok(())
8814            } else {
8815                Err(self.failed("start", identity.name(), message))
8816            }
8817        }
8818
8819        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8820            let running = self
8821                .query(identity)?
8822                .is_some_and(|registration| registration.running);
8823            if !running {
8824                return Ok(false);
8825            }
8826            let unit = identity.systemd_unit();
8827            let (ok, message) = self.systemctl(&["stop", &unit]);
8828            if ok {
8829                Ok(true)
8830            } else {
8831                Err(self.failed("stop", identity.name(), message))
8832            }
8833        }
8834    }
8835}
8836
8837/// What the macOS tests that load real launch agents share.
8838#[cfg(all(test, target_os = "macos"))]
8839pub(crate) mod launchd_fixture {
8840    use std::path::{Path, PathBuf};
8841
8842    /// Opts a macOS host into the tests that load launch agents into the
8843    /// caller's GUI session. CI's macOS leg sets it, and
8844    /// `privileged_tests_are_wired_into_ci.rs` asserts that it still does.
8845    const ACCEPTANCE: &str = "RUNNER_MANAGER_LAUNCHD_ACCEPTANCE";
8846
8847    /// Whether this run may load launch agents. Says so when it may not, since
8848    /// the test then passes having measured nothing.
8849    pub(crate) fn enabled() -> bool {
8850        let enabled = std::env::var_os(ACCEPTANCE).is_some_and(|value| value == "1");
8851        if !enabled {
8852            eprintln!("skipped: set {ACCEPTANCE}=1 to load test launch agents");
8853        }
8854        enabled
8855    }
8856
8857    /// A copy of the running test binary in `directory`, for a launchd job to
8858    /// run as its program.
8859    ///
8860    /// A copy, because a launchd job whose program lives on an external volume
8861    /// waits on a privacy approval nobody gives before `main` runs (measured
8862    /// with a target directory under `/Volumes`), and the temporary directory
8863    /// is on the boot volume.
8864    pub(crate) fn job_binary(directory: &Path) -> PathBuf {
8865        let binary = directory.join("launch-agent-job");
8866        std::fs::copy(std::env::current_exe().expect("test binary"), &binary)
8867            .expect("test binary copied");
8868        binary
8869    }
8870
8871    /// A launch agent loaded into the caller's GUI domain, booted out on drop.
8872    pub(crate) struct LoadedAgent(String);
8873
8874    impl LoadedAgent {
8875        /// Bootstraps `plist`, whose `Label` is `label`, and fails the test when
8876        /// launchd refuses it.
8877        pub(crate) fn bootstrap(label: &str, plist: &Path) -> Self {
8878            // SAFETY: `getuid` reads the calling process's real user id and
8879            // cannot fail.
8880            let domain = format!("gui/{}", unsafe { libc::getuid() });
8881            // Armed before the bootstrap, so a half-loaded job is removed too.
8882            let loaded = Self(format!("{domain}/{label}"));
8883            let bootstrap = std::process::Command::new("launchctl")
8884                .arg("bootstrap")
8885                .arg(&domain)
8886                .arg(plist)
8887                .output()
8888                .expect("launchctl runs");
8889            assert!(
8890                bootstrap.status.success(),
8891                "launchctl bootstrap {domain} refused {label}, so nothing was measured: {}",
8892                String::from_utf8_lossy(&bootstrap.stderr)
8893            );
8894            loaded
8895        }
8896
8897        /// `gui/<uid>/<label>`.
8898        pub(crate) fn target(&self) -> &str {
8899            &self.0
8900        }
8901    }
8902
8903    impl Drop for LoadedAgent {
8904        fn drop(&mut self) {
8905            let _ = std::process::Command::new("launchctl")
8906                .args(["bootout", &self.0])
8907                .output();
8908        }
8909    }
8910}
8911
8912#[cfg(test)]
8913mod tests {
8914    use super::*;
8915
8916    use std::collections::BTreeMap;
8917
8918    #[test]
8919    fn task_scheduler_omitting_default_enabled_still_means_automatic() {
8920        assert!(windows_login_task_starts_automatically(
8921            "<Task><Triggers><LogonTrigger></LogonTrigger></Triggers></Task>"
8922        ));
8923        assert!(windows_login_task_starts_automatically(
8924            "<Task><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Task>"
8925        ));
8926        assert!(!windows_login_task_starts_automatically(
8927            "<Task><LogonTrigger><Enabled>false</Enabled></LogonTrigger></Task>"
8928        ));
8929        assert!(!windows_login_task_starts_automatically(
8930            "<Task><BootTrigger></BootTrigger></Task>"
8931        ));
8932    }
8933
8934    // -----------------------------------------------------------------------
8935    // Fixtures
8936    // -----------------------------------------------------------------------
8937
8938    /// A plan against paths that exist on no platform, so that every renderer
8939    /// can be asserted on every leg of the CI matrix.
8940    fn linux_plan(mode: StartMode) -> InstallPlan {
8941        InstallPlan::unchecked(
8942            ServiceIdentity::product(),
8943            mode,
8944            "/opt/runner-manager/bin/runner-manager",
8945            ServiceDirectories {
8946                config: PathBuf::from("/var/lib/runner-manager/config"),
8947                state: PathBuf::from("/var/lib/runner-manager/state"),
8948                runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
8949                logs: PathBuf::from("/var/log/runner-manager"),
8950            },
8951        )
8952        .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
8953    }
8954
8955    fn windows_plan(mode: StartMode) -> InstallPlan {
8956        InstallPlan::unchecked(
8957            ServiceIdentity::product(),
8958            mode,
8959            "C:\\Program Files\\runner-manager\\runner-manager.exe",
8960            ServiceDirectories {
8961                config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
8962                state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
8963                runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
8964                logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
8965            },
8966        )
8967    }
8968
8969    /// Replaces one fragment of a rendered definition, and **fails the test if
8970    /// the fragment was not there**.
8971    ///
8972    /// Without the assertion a renderer change would quietly turn every
8973    /// "widened definition is rejected" test below into a test that reviews the
8974    /// unmodified definition and passes for the wrong reason. This is the
8975    /// single guard that keeps that class of vacuity out of this file.
8976    fn edited(text: &str, from: &str, to: &str) -> String {
8977        assert!(
8978            text.contains(from),
8979            "the rendered definition does not contain `{from}`, so this test would assert \
8980             nothing about a widened one"
8981        );
8982        text.replace(from, to)
8983    }
8984
8985    /// Every file under `root`, keyed by its path relative to `root`.
8986    fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
8987        fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
8988            let Ok(entries) = std::fs::read_dir(directory) else {
8989                return;
8990            };
8991            for entry in entries.flatten() {
8992                let path = entry.path();
8993                if path.is_dir() {
8994                    walk(&path, out);
8995                } else if let Ok(bytes) = std::fs::read(&path) {
8996                    out.insert(path, bytes);
8997                }
8998            }
8999        }
9000        let mut out = BTreeMap::new();
9001        for root in roots {
9002            walk(root, &mut out);
9003        }
9004        out
9005    }
9006
9007    struct Host {
9008        _root: tempfile::TempDir,
9009        paths: AppPaths,
9010        binary: PathBuf,
9011        /// A runner root inside this host's own temporary tree.
9012        ///
9013        /// Without it every `install` in this module would create and
9014        /// re-permission the **real** `%SystemDrive%\rman` on the machine
9015        /// running the tests, and would fail outright on any machine where that
9016        /// directory already exists with the access control `C:\` gives it.
9017        /// A sibling of the four application-data directories rather than a
9018        /// child of one, so `b1`'s overlap check has nothing to object to.
9019        runner_root: LocalAbsolutePath,
9020        controls: RecordingControls,
9021    }
9022
9023    impl Host {
9024        fn new() -> Self {
9025            let root = tempfile::tempdir().expect("a temporary directory");
9026            let paths = AppPaths::rooted_at(root.path());
9027            paths.create_all().expect("the four directories");
9028            let binary = root.path().join(if cfg!(windows) {
9029                "runner-manager.exe"
9030            } else {
9031                "runner-manager"
9032            });
9033            std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
9034            let runner_root = LocalAbsolutePath::new(
9035                root.path()
9036                    .join("runner-root")
9037                    .to_str()
9038                    .expect("a unicode temporary path"),
9039            )
9040            .expect("a local absolute path");
9041            Self {
9042                _root: root,
9043                paths,
9044                binary,
9045                runner_root,
9046                controls: RecordingControls::new(),
9047            }
9048        }
9049
9050        fn operations(&self) -> ServiceOperations {
9051            ServiceOperations::with_controls(
9052                self.paths.clone(),
9053                ServiceIdentity::product(),
9054                std::sync::Arc::new(self.controls.clone()),
9055            )
9056            .with_runner_root(self.runner_root.clone())
9057        }
9058
9059        fn request(&self, mode: StartMode) -> InstallRequest {
9060            InstallRequest::new(mode).for_binary(&self.binary)
9061        }
9062    }
9063
9064    #[cfg(windows)]
9065    #[test]
9066    fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
9067        let probes = std::cell::Cell::new(0);
9068        let absent =
9069            super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
9070                let next = probes.get() + 1;
9071                probes.set(next);
9072                Ok(next == 3)
9073            })
9074            .expect("the simulated SCM probe succeeds");
9075
9076        assert!(absent);
9077        assert_eq!(
9078            probes.get(),
9079            3,
9080            "uninstall must recheck after transient presence instead of treating it as a leak"
9081        );
9082    }
9083
9084    #[cfg(windows)]
9085    #[test]
9086    fn windows_recognises_an_already_pending_service_deletion() {
9087        let error = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(1072));
9088
9089        assert!(super::sys::is_marked_for_delete(&error));
9090    }
9091
9092    #[test]
9093    fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
9094        let root = tempfile::tempdir().expect("a temporary directory");
9095        let plist = root.path().join("fixture.plist");
9096        std::fs::write(&plist, b"fixture").expect("a plist fixture");
9097        let calls = std::cell::RefCell::new(Vec::new());
9098
9099        let error = enable_launchd_registration(
9100            |arguments| {
9101                let call = arguments
9102                    .iter()
9103                    .map(|argument| argument.to_string_lossy().into_owned())
9104                    .collect::<Vec<_>>();
9105                let operation = call[0].clone();
9106                calls.borrow_mut().push(call);
9107                if operation == "enable" {
9108                    (false, "label remains disabled".to_string())
9109                } else {
9110                    (true, String::new())
9111                }
9112            },
9113            "system",
9114            "system/com.openai.runner-manager-selftest",
9115            &plist,
9116            "runner-manager-selftest",
9117            "rerun with administrative rights",
9118        )
9119        .expect_err("enable failure must fail the install");
9120
9121        assert!(
9122            matches!(
9123                error,
9124                ServiceError::Control {
9125                    operation: "enable",
9126                    ..
9127                }
9128            ),
9129            "{error}"
9130        );
9131        assert_eq!(calls.borrow().len(), 2);
9132        assert_eq!(calls.borrow()[0][0], "enable");
9133        assert_eq!(calls.borrow()[1][0], "bootout");
9134        assert!(!plist.exists(), "rollback must remove the plist");
9135    }
9136
9137    // -----------------------------------------------------------------------
9138    // Quoting, and reading a command line back
9139    // -----------------------------------------------------------------------
9140
9141    #[test]
9142    fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
9143        let plan = windows_plan(StartMode::Boot);
9144        let command_line = plan.command_line();
9145        assert!(
9146            command_line.starts_with('"'),
9147            "a path with a space must be quoted, got {command_line}"
9148        );
9149        assert_eq!(
9150            executable_from_command_line(&command_line).as_deref(),
9151            Some(plan.binary())
9152        );
9153    }
9154
9155    #[test]
9156    fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
9157        let plan = linux_plan(StartMode::Boot);
9158        let command_line = plan.command_line();
9159        assert!(!command_line.starts_with('"'), "got {command_line}");
9160        assert_eq!(
9161            executable_from_command_line(&command_line).as_deref(),
9162            Some(plan.binary())
9163        );
9164    }
9165
9166    #[test]
9167    fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
9168        // Not a path anybody has, but it is the input that separates a real
9169        // implementation of Windows' quoting rules from a `split_whitespace`.
9170        let awkward = r#"C:\odd "name"\rm.exe"#;
9171        let quoted = quote_argument(awkward);
9172        assert_eq!(
9173            executable_from_command_line(&format!("{quoted} daemon run"))
9174                .as_deref()
9175                .map(Path::to_string_lossy)
9176                .as_deref(),
9177            Some(awkward)
9178        );
9179    }
9180
9181    #[test]
9182    fn an_empty_command_line_has_no_executable() {
9183        assert_eq!(executable_from_command_line("   "), None);
9184        assert_eq!(executable_from_command_line(""), None);
9185    }
9186
9187    #[test]
9188    fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
9189        let awkward = r#"DOMAIN\R&D <team> "ops""#;
9190        assert_eq!(
9191            xml_escape(awkward),
9192            "DOMAIN\\R&amp;D &lt;team&gt; &quot;ops&quot;"
9193        );
9194        assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
9195    }
9196
9197    // -----------------------------------------------------------------------
9198    // The restart policy is bounded at both ends
9199    // -----------------------------------------------------------------------
9200
9201    #[test]
9202    fn a_restart_delay_under_the_floor_is_refused() {
9203        let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
9204            .expect_err("half a second is under the one-second floor");
9205        assert!(
9206            matches!(error, ServiceError::RestartDelay { .. }),
9207            "{error}"
9208        );
9209    }
9210
9211    #[test]
9212    fn a_restart_delay_over_the_ceiling_is_refused() {
9213        let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
9214            .expect_err("an hour is over the five-minute ceiling");
9215        assert!(
9216            matches!(error, ServiceError::RestartDelay { .. }),
9217            "{error}"
9218        );
9219    }
9220
9221    #[test]
9222    fn a_reset_window_no_longer_than_the_delay_is_refused() {
9223        let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
9224            .expect_err("a window equal to the delay can never elapse between restarts");
9225        assert!(
9226            matches!(error, ServiceError::RestartResetWindow { .. }),
9227            "{error}"
9228        );
9229    }
9230
9231    #[test]
9232    fn a_delay_inside_the_bound_is_accepted() {
9233        let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
9234            .expect("twenty seconds is inside the bound");
9235        assert_eq!(policy.delay(), Duration::from_secs(20));
9236        assert_eq!(policy.reset_after(), Duration::from_secs(300));
9237    }
9238
9239    // -----------------------------------------------------------------------
9240    // Identity
9241    // -----------------------------------------------------------------------
9242
9243    #[test]
9244    fn a_fixture_identity_can_never_be_the_product_identity() {
9245        let fixture = ServiceIdentity::fixture("abc123");
9246        assert!(fixture.is_fixture());
9247        assert!(!ServiceIdentity::product().is_fixture());
9248        assert_ne!(fixture.name(), ServiceIdentity::product().name());
9249        assert_ne!(
9250            fixture.launchd_label(),
9251            ServiceIdentity::product().launchd_label()
9252        );
9253        assert_ne!(
9254            fixture.systemd_unit(),
9255            ServiceIdentity::product().systemd_unit()
9256        );
9257    }
9258
9259    #[test]
9260    fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
9261        let fixture = ServiceIdentity::fixture("A b/c:\\d");
9262        assert!(
9263            fixture
9264                .name()
9265                .chars()
9266                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
9267            "got {}",
9268            fixture.name()
9269        );
9270    }
9271
9272    // -----------------------------------------------------------------------
9273    // The systemd unit
9274    // -----------------------------------------------------------------------
9275
9276    #[test]
9277    fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
9278        let unit = systemd_unit(&linux_plan(StartMode::Boot));
9279        assert!(unit.contains("KillMode=process\n"), "{unit}");
9280        assert!(unit.contains("Restart=on-failure\n"), "{unit}");
9281        assert!(unit.contains("RestartSec=15\n"), "{unit}");
9282        assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
9283        assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
9284        assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
9285    }
9286
9287    #[test]
9288    fn the_boot_unit_reads_the_live_store_instead_of_a_frozen_systemd_copy() {
9289        let unit = systemd_unit(&linux_plan(StartMode::Boot));
9290        assert!(
9291            !unit.contains("LoadCredential="),
9292            "a startup snapshot would shadow every rotated credential until restart:\n{unit}"
9293        );
9294    }
9295
9296    #[test]
9297    fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
9298        let unit = systemd_unit(&linux_plan(StartMode::Login));
9299        assert!(
9300            !unit.contains("LoadCredential="),
9301            "a user unit must not name a root-owned credential file, got:\n{unit}"
9302        );
9303        assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
9304    }
9305
9306    #[test]
9307    fn the_unit_can_atomically_replace_the_credential_and_write_only_required_directories() {
9308        let plan = linux_plan(StartMode::Boot);
9309        let unit = systemd_unit(&plan);
9310        let directives = ini_directives(&unit, "Service");
9311        let listed = split_quoted(
9312            directives
9313                .get("ReadWritePaths")
9314                .expect("the unit names its writable paths"),
9315        );
9316        assert_eq!(listed.len(), 5, "{listed:?}");
9317        for path in plan.directories().all() {
9318            assert!(
9319                listed.iter().any(|entry| entry == &path.to_string_lossy()),
9320                "{} is missing from {listed:?}",
9321                path.display()
9322            );
9323        }
9324        assert!(
9325            listed
9326                .iter()
9327                .any(|entry| entry == "/var/lib/runner-manager/secrets"),
9328            "atomic credential replacement needs its parent directory in {listed:?}"
9329        );
9330    }
9331
9332    #[test]
9333    fn the_unit_records_the_absolute_binary_path() {
9334        let plan = linux_plan(StartMode::Boot);
9335        let unit = systemd_unit(&plan);
9336        assert!(
9337            unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
9338            "{unit}"
9339        );
9340    }
9341
9342    // -----------------------------------------------------------------------
9343    // The launchd property list
9344    // -----------------------------------------------------------------------
9345
9346    #[test]
9347    fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
9348        let plist = launchd_plist(&linux_plan(StartMode::Boot));
9349        // A bare `KeepAlive` would also restart a job the operator stopped
9350        // deliberately, which turns `service stop` into a fight with launchd.
9351        assert!(
9352            plist.contains(
9353                "<key>KeepAlive</key>\n  <dict>\n    <key>SuccessfulExit</key>\n    <false/>\n"
9354            ),
9355            "{plist}"
9356        );
9357        assert!(
9358            plist.contains("<key>ThrottleInterval</key>\n  <integer>15</integer>"),
9359            "{plist}"
9360        );
9361    }
9362
9363    #[test]
9364    fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
9365        let daemon = launchd_plist(&linux_plan(StartMode::Boot));
9366        assert_eq!(
9367            plist_string_value(&daemon, "UserName").as_deref(),
9368            Some("root")
9369        );
9370        assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
9371
9372        let agent = launchd_plist(&linux_plan(StartMode::Login));
9373        assert_eq!(
9374            plist_string_value(&agent, "UserName"),
9375            None,
9376            "a LaunchAgent already runs as the operator:\n{agent}"
9377        );
9378    }
9379
9380    #[test]
9381    fn the_plist_runs_the_daemon_and_its_runners_as_an_interactive_job() {
9382        for mode in [StartMode::Boot, StartMode::Login] {
9383            let plan = linux_plan(mode);
9384            let plist = launchd_plist(&plan);
9385            assert_eq!(
9386                plist_string_value(&plist, "ProcessType").as_deref(),
9387                Some("Interactive"),
9388                "launchd applies ProcessType to every runner the daemon starts:\n{plist}"
9389            );
9390            let review = review_least_privilege(
9391                &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, plist),
9392                &plan,
9393            );
9394            assert!(
9395                review
9396                    .controls()
9397                    .iter()
9398                    .any(|control| control == "ProcessType=Interactive"),
9399                "{review}"
9400            );
9401            assert!(
9402                !review
9403                    .findings()
9404                    .iter()
9405                    .any(|finding| finding.subject == "ProcessType"),
9406                "{review}"
9407            );
9408        }
9409    }
9410
9411    /// The value 0.4.31 and earlier installed, and launchd's default, both
9412    /// throttle every runner. That is a shortfall the update repairs, not an
9413    /// excess: a scheduling class grants no authority.
9414    #[test]
9415    fn a_plist_that_throttles_its_runners_is_a_shortfall() {
9416        let plan = linux_plan(StartMode::Login);
9417        let rendered = launchd_plist(&plan);
9418        let interactive = "<key>ProcessType</key>\n  <string>Interactive</string>\n";
9419        for (label, throttled) in [
9420            (
9421                "Background",
9422                edited(
9423                    &rendered,
9424                    interactive,
9425                    "<key>ProcessType</key>\n  <string>Background</string>\n",
9426                ),
9427            ),
9428            ("absent", edited(&rendered, interactive, "")),
9429        ] {
9430            let review = review_least_privilege(
9431                &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, throttled),
9432                &plan,
9433            );
9434            assert!(review.is_least_privilege(), "{label}: {review}");
9435            assert!(
9436                review.findings().iter().any(|finding| {
9437                    finding.kind == FindingKind::Shortfall
9438                        && finding.subject == "ProcessType"
9439                        && finding.detail.contains(label)
9440                }),
9441                "{label}: {review}"
9442            );
9443        }
9444    }
9445
9446    #[test]
9447    fn a_plist_with_an_old_process_type_is_drift_and_an_operator_edit_is_not() {
9448        let host = Host::new();
9449        let identity = ServiceIdentity::product();
9450        let plan = InstallPlan::resolve(
9451            identity.clone(),
9452            &host.request(StartMode::Login),
9453            ServiceDirectories::of(&host.paths),
9454        )
9455        .expect("a resolvable plan");
9456        let installed = launchd_plist(&plan);
9457        let definition =
9458            ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, installed.clone());
9459        let mut record = InstallRecord::of(&plan, &definition, Utc::now());
9460        record.installed_by_version = "0.4.31".into();
9461        let path = PathBuf::from("/Users/op/Library/LaunchAgents/runner-manager.plist");
9462
9463        assert_eq!(
9464            launchd_definition_drift(&identity, &record, path.clone(), &installed),
9465            None
9466        );
9467        let edited_by_operator = edited(
9468            &installed,
9469            "</dict>\n</plist>\n",
9470            "  <key>Nice</key>\n  <integer>0</integer>\n</dict>\n</plist>\n",
9471        );
9472        assert_eq!(
9473            launchd_definition_drift(&identity, &record, path.clone(), &edited_by_operator),
9474            None,
9475            "the daemon repairs this drift itself, so it must not key on edits it did not cause"
9476        );
9477
9478        let written_by_0_4_31 = edited(
9479            &installed,
9480            "<string>Interactive</string>",
9481            "<string>Background</string>",
9482        );
9483        let drift = launchd_definition_drift(&identity, &record, path, &written_by_0_4_31)
9484            .expect("a Background plist is drift");
9485        assert_eq!(drift.manager, DefinitionKind::LaunchdPlist);
9486        assert_eq!(drift.start_mode, StartMode::Login);
9487        assert_eq!(
9488            drift.rendered, installed,
9489            "the repair must write exactly what `service install` writes"
9490        );
9491
9492        let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
9493            .with_definition_drift(Some(drift));
9494        let problem = status
9495            .problems()
9496            .iter()
9497            .find(|problem| problem.subject == "definition")
9498            .expect("the drift is reported");
9499        // The remedy is a restart: a running daemon holds the lock that refuses
9500        // `service install`.
9501        assert!(
9502            problem.detail.contains("0.4.31")
9503                && problem.detail.contains(
9504                    "launchctl kickstart -k gui/$(id -u)/io.github.IvanMurzak.runner-manager`"
9505                )
9506                && !problem.detail.contains("service install")
9507                && !problem.detail.contains("systemctl")
9508                && !problem.detail.contains("sudo"),
9509            "{}",
9510            problem.detail
9511        );
9512    }
9513
9514    #[cfg(unix)]
9515    #[test]
9516    fn a_replaced_definition_is_the_rendering_and_readable_by_launchd() {
9517        use std::os::unix::fs::PermissionsExt as _;
9518
9519        let root = tempfile::tempdir().expect("a temporary directory");
9520        let path = root
9521            .path()
9522            .join("io.github.IvanMurzak.runner-manager.plist");
9523        std::fs::write(&path, "<string>Background</string>").expect("old plist");
9524        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
9525        let drift = DefinitionDrift {
9526            path: path.clone(),
9527            installed_by_version: "0.4.31".into(),
9528            rendered: launchd_plist(&linux_plan(StartMode::Login)),
9529            start_mode: StartMode::Login,
9530            manager: DefinitionKind::LaunchdPlist,
9531        };
9532
9533        replace_definition("runner-manager", &drift).expect("replaced");
9534
9535        assert_eq!(std::fs::read_to_string(&path).unwrap(), drift.rendered);
9536        assert_eq!(
9537            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
9538            0o644
9539        );
9540        assert_eq!(
9541            std::fs::read_dir(root.path()).unwrap().count(),
9542            1,
9543            "no staging file is left beside the plist"
9544        );
9545    }
9546
9547    /// The job of [`a_job_reloads_itself_from_its_rewritten_plist`], run by
9548    /// launchd and by nothing else.
9549    ///
9550    /// It does what the daemon does when it starts: when its own plist (kept
9551    /// in its working directory) does not carry [`LAUNCHD_PROCESS_TYPE`], it
9552    /// rewrites the plist and asks for the reload from inside the job that the
9553    /// reload boots out. Either way it then stays up, to be booted out by the
9554    /// helper or, once reloaded, by the test.
9555    #[cfg(target_os = "macos")]
9556    #[test]
9557    #[ignore = "run only by launchd, as the job of the detached-reload test"]
9558    fn detached_reload_job() {
9559        let label = std::env::var("XPC_SERVICE_NAME").unwrap_or_default();
9560        let tag = label
9561            .split_once("-selftest-")
9562            .map(|(_, tag)| tag.to_string())
9563            .unwrap_or_else(|| panic!("run only by launchd for the reload test, not {label:?}"));
9564        let identity = ServiceIdentity::fixture(&tag);
9565        assert_eq!(identity.launchd_label(), label);
9566        let path = std::env::current_dir()
9567            .expect("working directory")
9568            .join(format!("{label}.plist"));
9569        let installed = std::fs::read_to_string(&path).expect("own plist");
9570        if plist_string_value(&installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE) {
9571            let drift = DefinitionDrift {
9572                path: path.clone(),
9573                installed_by_version: "0.4.31".into(),
9574                rendered: installed.replace(
9575                    "<string>Background</string>",
9576                    &format!("<string>{LAUNCHD_PROCESS_TYPE}</string>"),
9577                ),
9578                start_mode: StartMode::Login,
9579                manager: DefinitionKind::LaunchdPlist,
9580            };
9581            replace_definition(identity.name(), &drift).expect("plist replaced");
9582            super::sys::reload_detached(&identity, StartMode::Login, &path)
9583                .expect("helper started");
9584        }
9585        std::thread::sleep(Duration::from_secs(120));
9586    }
9587
9588    /// A job that rewrites its own plist and asks for the reload comes back
9589    /// under the rewritten plist.
9590    ///
9591    /// The request comes from inside the job, as the daemon's does, so the
9592    /// helper has to outlive the bootout of the very job that started it.
9593    /// launchd sends SIGTERM to the job's whole process group, so a helper that
9594    /// stayed in it would sometimes die before its bootstrap and leave the job
9595    /// unloaded. Measured with `setsid` removed, that failed one run in four
9596    /// here: this test catches it, though not on every run.
9597    #[cfg(target_os = "macos")]
9598    #[test]
9599    fn a_job_reloads_itself_from_its_rewritten_plist() {
9600        if !super::launchd_fixture::enabled() {
9601            return;
9602        }
9603        let root = tempfile::tempdir().expect("a temporary directory");
9604        let identity = ServiceIdentity::fixture(&format!("reload-{}", std::process::id()));
9605        let label = identity.launchd_label();
9606        let path = root.path().join(format!("{label}.plist"));
9607        let binary = super::launchd_fixture::job_binary(root.path());
9608        let mut plist = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
9609        plist.push_str("<plist version=\"1.0\">\n<dict>\n");
9610        plist.push_str(&plist_string("Label", &label));
9611        plist.push_str("  <key>ProgramArguments</key>\n  <array>\n");
9612        for argument in [
9613            binary.to_string_lossy().as_ref(),
9614            "--ignored",
9615            "--exact",
9616            "service::tests::detached_reload_job",
9617            "--test-threads=1",
9618        ] {
9619            plist.push_str(&format!("    <string>{}</string>\n", xml_escape(argument)));
9620        }
9621        plist.push_str("  </array>\n  <key>RunAtLoad</key>\n  <true/>\n");
9622        plist.push_str(&plist_string(
9623            "WorkingDirectory",
9624            &root.path().to_string_lossy(),
9625        ));
9626        plist.push_str(&plist_string("ProcessType", "Background"));
9627        plist.push_str("</dict>\n</plist>\n");
9628        std::fs::write(&path, plist).expect("plist written");
9629
9630        let loaded = super::launchd_fixture::LoadedAgent::bootstrap(&label, &path);
9631        let spawn_type = || {
9632            let printed = std::process::Command::new("launchctl")
9633                .args(["print", loaded.target()])
9634                .output()
9635                .expect("launchctl runs");
9636            String::from_utf8_lossy(&printed.stdout)
9637                .lines()
9638                .find_map(|line| {
9639                    line.trim()
9640                        .strip_prefix("spawn type = ")
9641                        .map(str::to_string)
9642                })
9643        };
9644        assert_eq!(spawn_type().as_deref(), Some("background (5)"));
9645
9646        let deadline = std::time::Instant::now() + Duration::from_secs(90);
9647        while spawn_type().as_deref() != Some("interactive (4)") {
9648            assert!(
9649                std::time::Instant::now() < deadline,
9650                "the job did not come back under its rewritten plist; launchd reports {:?}",
9651                spawn_type()
9652            );
9653            std::thread::sleep(Duration::from_millis(250));
9654        }
9655        let rewritten = std::fs::read_to_string(&path).expect("plist readable");
9656        assert_eq!(
9657            plist_string_value(&rewritten, "ProcessType").as_deref(),
9658            Some(LAUNCHD_PROCESS_TYPE)
9659        );
9660    }
9661
9662    #[test]
9663    fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
9664        let plist = launchd_plist(&linux_plan(StartMode::Boot));
9665        assert!(
9666            plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
9667            "{plist}"
9668        );
9669        assert!(plist.contains("<string>daemon</string>"), "{plist}");
9670        assert!(plist.contains("<string>run</string>"), "{plist}");
9671    }
9672
9673    #[test]
9674    fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
9675        assert_eq!(
9676            ServiceIdentity::product().launchd_label(),
9677            "io.github.IvanMurzak.runner-manager"
9678        );
9679    }
9680
9681    // -----------------------------------------------------------------------
9682    // The Task Scheduler document
9683    // -----------------------------------------------------------------------
9684
9685    #[test]
9686    fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
9687        let xml = windows_scheduled_task_xml(
9688            &windows_plan(StartMode::Login),
9689            &TaskPrincipal::named("HOST\\operator"),
9690        );
9691        assert!(xml.contains("<LogonTrigger>"), "{xml}");
9692        assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
9693        assert!(
9694            xml.contains("<LogonType>InteractiveToken</LogonType>"),
9695            "{xml}"
9696        );
9697        assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
9698        assert!(
9699            xml.contains("<Interval>PT1M</Interval>"),
9700            "Task Scheduler takes whole minutes only, and rejects the registration outright \
9701             for anything finer:\n{xml}"
9702        );
9703    }
9704
9705    #[test]
9706    fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
9707        // Measured against the real Task Scheduler, which answers `PT15S` with
9708        // "The task XML contains a value which is incorrectly formatted or out
9709        // of range". Rounding *up* is what keeps "does not restart-loop faster
9710        // than that bound" true on this manager.
9711        for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
9712            let policy =
9713                RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
9714                    .expect("inside the supported range");
9715            assert_eq!(
9716                policy
9717                    .effective_delay(DefinitionKind::WindowsScheduledTask)
9718                    .as_secs(),
9719                enforced,
9720                "a {asked}s delay must be enforced as {enforced}s"
9721            );
9722            assert!(
9723                policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
9724                "rounding must never shorten the bound"
9725            );
9726        }
9727    }
9728
9729    #[test]
9730    fn every_other_manager_enforces_the_delay_exactly_as_configured() {
9731        let policy = RestartPolicy::default();
9732        for kind in [
9733            DefinitionKind::WindowsService,
9734            DefinitionKind::LaunchdPlist,
9735            DefinitionKind::SystemdUnit,
9736        ] {
9737            assert_eq!(
9738                policy.effective_delay(kind),
9739                policy.delay(),
9740                "{kind:?} takes seconds and enforces exactly what it is given"
9741            );
9742        }
9743    }
9744
9745    #[test]
9746    fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
9747        let host = Host::new();
9748        let operations = host.operations();
9749        operations
9750            .install(&host.request(StartMode::Login))
9751            .expect("an install at login");
9752        // What a real Task Scheduler reports back for a 15-second policy.
9753        host.controls.edit("runner-manager", |registration| {
9754            registration.manager = DefinitionKind::WindowsScheduledTask;
9755            registration.restart_delay = Some(Duration::from_secs(60));
9756        });
9757
9758        let status = operations.status().expect("a status");
9759        assert!(
9760            status.is_healthy(),
9761            "minute granularity is the manager's, not a mis-registration: {status}"
9762        );
9763        assert!(
9764            status
9765                .notes()
9766                .iter()
9767                .any(|note| note.contains("whole minutes")),
9768            "but the operator must be told why 15 became 60: {status}"
9769        );
9770
9771        // The discriminator: a delay that is neither the configured one nor its
9772        // rounding is still a fault.
9773        host.controls.edit("runner-manager", |registration| {
9774            registration.restart_delay = Some(Duration::from_secs(1));
9775        });
9776        assert!(
9777            !operations.status().expect("a status").is_healthy(),
9778            "a one-second delay is not what any manager was asked for"
9779        );
9780    }
9781
9782    #[test]
9783    fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
9784        let plan = windows_plan(StartMode::Login);
9785        let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
9786        assert_eq!(
9787            xml_value(&xml, "Command").as_deref(),
9788            Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
9789            "{xml}"
9790        );
9791        assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
9792    }
9793
9794    #[test]
9795    fn an_account_name_holding_xml_punctuation_is_escaped() {
9796        let xml = windows_scheduled_task_xml(
9797            &windows_plan(StartMode::Login),
9798            &TaskPrincipal::named("R&D\\ops"),
9799        );
9800        assert!(xml.contains("<UserId>R&amp;D\\ops</UserId>"), "{xml}");
9801        assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
9802    }
9803
9804    // -----------------------------------------------------------------------
9805    // The Windows service descriptor
9806    // -----------------------------------------------------------------------
9807
9808    #[test]
9809    fn the_service_starts_automatically_under_the_account_the_store_admits() {
9810        let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
9811        let directives = ini_directives(&text, "windows-service");
9812        assert_eq!(
9813            directives.get("StartType").map(String::as_str),
9814            Some("AutoStart")
9815        );
9816        assert_eq!(
9817            directives.get("Account").map(String::as_str),
9818            Some("NT AUTHORITY\\SYSTEM")
9819        );
9820        assert_eq!(
9821            directives.get("ServiceType").map(String::as_str),
9822            Some("OWN_PROCESS")
9823        );
9824        assert_eq!(
9825            directives
9826                .get("FailureActionRestartDelaySecs")
9827                .map(String::as_str),
9828            Some("15")
9829        );
9830        assert_eq!(
9831            directives
9832                .get("FailureActionsOnNonCrashFailures")
9833                .map(String::as_str),
9834            Some("true"),
9835            "without this flag a non-zero exit is not a failure the manager restarts"
9836        );
9837    }
9838
9839    #[test]
9840    fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
9841        let spec = windows_service_spec(&windows_plan(StartMode::Boot));
9842        assert_eq!(spec.account, None);
9843        assert!(spec.automatic_start);
9844        assert!(
9845            spec.command_line.contains("daemon run"),
9846            "{}",
9847            spec.command_line
9848        );
9849    }
9850
9851    // -----------------------------------------------------------------------
9852    // Where each definition is installed
9853    // -----------------------------------------------------------------------
9854
9855    #[test]
9856    fn each_definition_goes_where_its_platform_expects_it() {
9857        let home = PathBuf::from("/home/op");
9858        assert_eq!(
9859            ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
9860            Some(Path::new(
9861                "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
9862            ))
9863        );
9864        assert_eq!(
9865            ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
9866            Some(Path::new(
9867                "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
9868            ))
9869        );
9870        assert_eq!(
9871            ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
9872            Some(Path::new("/etc/systemd/system/runner-manager.service"))
9873        );
9874        assert_eq!(
9875            ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
9876            Some(Path::new(
9877                "/home/op/.config/systemd/user/runner-manager.service"
9878            ))
9879        );
9880        assert_eq!(
9881            ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
9882            None,
9883            "the Service Control Manager has no file"
9884        );
9885    }
9886
9887    #[test]
9888    fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
9889        assert_eq!(
9890            ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
9891            None
9892        );
9893        assert_eq!(
9894            ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
9895            None
9896        );
9897    }
9898
9899    // -----------------------------------------------------------------------
9900    // The least-privilege review: the passing case, then every failing one
9901    // -----------------------------------------------------------------------
9902
9903    #[test]
9904    fn the_rendered_definitions_are_all_least_privilege() {
9905        let linux = linux_plan(StartMode::Boot);
9906        let windows = windows_plan(StartMode::Boot);
9907        for (definition, plan) in [
9908            (ServiceDefinition::systemd(&linux, None), &linux),
9909            (ServiceDefinition::launchd(&linux, None), &linux),
9910            (ServiceDefinition::windows_service(&windows), &windows),
9911        ] {
9912            let review = review_least_privilege(&definition, plan);
9913            assert!(
9914                review.is_least_privilege(),
9915                "{:?} should be least privilege, got:\n{review}",
9916                definition.kind()
9917            );
9918            assert!(
9919                !review.controls().is_empty(),
9920                "a review that confirms nothing proves nothing: {:?}",
9921                definition.kind()
9922            );
9923        }
9924    }
9925
9926    #[test]
9927    fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
9928        let plan = windows_plan(StartMode::Login);
9929        let definition =
9930            ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
9931        let review = review_least_privilege(&definition, &plan);
9932        assert!(review.is_least_privilege(), "{review}");
9933        assert!(
9934            review
9935                .controls()
9936                .iter()
9937                .any(|control| control.contains("LeastPrivilege")),
9938            "{review}"
9939        );
9940    }
9941
9942    #[test]
9943    fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
9944        let plan = linux_plan(StartMode::Boot);
9945        let rendered = systemd_unit(&plan);
9946        let widened = edited(
9947            &rendered,
9948            "ReadWritePaths=/var/lib/runner-manager/config",
9949            "ReadWritePaths=/etc /var/lib/runner-manager/config",
9950        );
9951        let review = review_least_privilege(
9952            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
9953            &plan,
9954        );
9955        assert!(!review.is_least_privilege(), "{review}");
9956        assert!(
9957            review
9958                .excesses()
9959                .iter()
9960                .any(|finding| finding.detail.contains("/etc")),
9961            "the review must name the directory it objects to: {review}"
9962        );
9963    }
9964
9965    #[test]
9966    fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
9967        let plan = linux_plan(StartMode::Boot);
9968        let rendered = systemd_unit(&plan);
9969        let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
9970        let review = review_least_privilege(
9971            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
9972            &plan,
9973        );
9974        assert!(!review.is_least_privilege(), "{review}");
9975        assert!(
9976            review
9977                .excesses()
9978                .iter()
9979                .any(|finding| finding.subject == "NoNewPrivileges"),
9980            "{review}"
9981        );
9982    }
9983
9984    /// Whether reviewing `unit` against `plan` reports `kind` for `subject`.
9985    fn reports(unit: &str, plan: &InstallPlan, subject: &str, kind: FindingKind) -> bool {
9986        review_least_privilege(
9987            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, unit.to_string()),
9988            plan,
9989        )
9990        .findings()
9991        .iter()
9992        .any(|finding| finding.subject == subject && finding.kind == kind)
9993    }
9994
9995    #[test]
9996    fn protected_paths_are_inaccessible_and_the_review_keeps_them_so() {
9997        let plan = linux_plan(StartMode::Boot).with_protected_paths(vec![
9998            PathBuf::from("/srv/keys"),
9999            PathBuf::from("/data/my backups"),
10000        ]);
10001        let unit = systemd_unit(&plan);
10002        let directives = ini_directives(&unit, "Service");
10003        assert_eq!(
10004            split_quoted(&directives["InaccessiblePaths"]),
10005            ["-/srv/keys", "-/data/my backups"]
10006        );
10007        assert!(!reports(
10008            &unit,
10009            &plan,
10010            "InaccessiblePaths",
10011            FindingKind::Excess
10012        ));
10013        let dropped = unit.replace(
10014            &format!("InaccessiblePaths={}\n", directives["InaccessiblePaths"]),
10015            "",
10016        );
10017        assert!(reports(
10018            &dropped,
10019            &plan,
10020            "InaccessiblePaths",
10021            FindingKind::Excess
10022        ));
10023        assert!(
10024            !ini_directives(&systemd_unit(&linux_plan(StartMode::Boot)), "Service")
10025                .contains_key("InaccessiblePaths")
10026        );
10027    }
10028
10029    // Unix paths: protected paths are a systemd mechanism.
10030    #[cfg(unix)]
10031    #[test]
10032    fn a_path_every_job_or_the_service_needs_cannot_be_protected() {
10033        let own = [PathBuf::from("/var/lib/runner-manager/config")];
10034        for refused in [
10035            "/",
10036            "/usr",
10037            "/etc",
10038            "/tmp",
10039            "relative",
10040            "/srv/../etc",
10041            "/var/lib",
10042            "/var/lib/runner-manager/config/x",
10043        ] {
10044            assert!(
10045                ProtectedPaths::refusal(Path::new(refused), &own).is_some(),
10046                "{refused}"
10047            );
10048        }
10049        for accepted in ["/srv/keys", "/home/ivan/.ssh", "/var/lib/postgresql"] {
10050            assert_eq!(
10051                ProtectedPaths::refusal(Path::new(accepted), &own),
10052                None,
10053                "{accepted}"
10054            );
10055        }
10056        let directory = tempfile::tempdir().unwrap();
10057        assert_eq!(
10058            ProtectedPaths::load(directory.path()).unwrap(),
10059            ProtectedPaths::default()
10060        );
10061        let list = ProtectedPaths {
10062            paths: vec![PathBuf::from("/srv/keys")],
10063        };
10064        list.save(directory.path()).unwrap();
10065        assert_eq!(ProtectedPaths::load(directory.path()).unwrap(), list);
10066    }
10067
10068    #[test]
10069    fn no_unit_breaks_tar_by_answering_openat2_with_enosys() {
10070        for mode in [StartMode::Boot, StartMode::Login] {
10071            let unit = systemd_unit(&linux_plan(mode));
10072            assert!(
10073                !unit.contains("RestrictSUIDSGID"),
10074                "RestrictSUIDSGID fails every tar extraction in a job: {unit}"
10075            );
10076        }
10077    }
10078
10079    #[test]
10080    fn only_a_boot_daemon_keeps_capabilities_and_boot_units_hide_home_trees() {
10081        let boot = systemd_unit(&linux_plan(StartMode::Boot));
10082        let directives = ini_directives(&boot, "Service");
10083        assert_eq!(directives["CapabilityBoundingSet"], BOOT_CAPABILITIES);
10084        assert_eq!(directives["AmbientCapabilities"], "");
10085        assert_eq!(directives["NoNewPrivileges"], "yes");
10086        assert_eq!(directives["ProtectHome"], "tmpfs");
10087        assert!(!directives.contains_key("BindPaths"));
10088
10089        let login = systemd_unit(&linux_plan(StartMode::Login));
10090        let directives = ini_directives(&login, "Service");
10091        assert_eq!(directives["CapabilityBoundingSet"], "");
10092        assert!(!directives.contains_key("ProtectHome"));
10093
10094        let unprotected = boot.replace("ProtectHome=tmpfs\n", "");
10095        assert!(reports(
10096            &unprotected,
10097            &linux_plan(StartMode::Boot),
10098            "ProtectHome",
10099            FindingKind::Excess
10100        ));
10101        let boot_set = format!("CapabilityBoundingSet={BOOT_CAPABILITIES}\n");
10102        let inverted = boot.replace(&boot_set, "CapabilityBoundingSet=~CAP_SYS_ADMIN\n");
10103        assert!(reports(
10104            &inverted,
10105            &linux_plan(StartMode::Boot),
10106            "CapabilityBoundingSet",
10107            FindingKind::Excess
10108        ));
10109        let widened = boot.replace(
10110            &boot_set,
10111            &format!("CapabilityBoundingSet={BOOT_CAPABILITIES} CAP_SYS_ADMIN\n"),
10112        );
10113        assert!(reports(
10114            &widened,
10115            &linux_plan(StartMode::Boot),
10116            "CapabilityBoundingSet",
10117            FindingKind::Excess
10118        ));
10119        let narrowed = boot.replace(&boot_set, "CapabilityBoundingSet=CAP_SETUID\n");
10120        assert!(reports(
10121            &narrowed,
10122            &linux_plan(StartMode::Boot),
10123            "CapabilityBoundingSet",
10124            FindingKind::Shortfall
10125        ));
10126    }
10127
10128    #[test]
10129    fn status_reports_the_registered_account_and_falls_back_to_the_record() {
10130        let recorded = ServiceAccount::for_start_mode(StartMode::Boot).to_string();
10131        for (registered, shown) in [(Some("ivan"), "ivan"), (None, recorded.as_str())] {
10132            let host = Host::new();
10133            let operations = host.operations();
10134            operations.install(&host.request(StartMode::Boot)).unwrap();
10135            host.controls.edit("runner-manager", |registration| {
10136                registration.account = registered.map(str::to_string);
10137            });
10138            let status = operations.status().unwrap().to_string();
10139            assert!(
10140                status.contains(&format!("account                   {shown}\n")),
10141                "{status}"
10142            );
10143        }
10144    }
10145
10146    #[cfg(target_os = "linux")]
10147    #[test]
10148    fn a_boot_install_refuses_a_unit_a_drop_in_runs_as_another_account() {
10149        let host = Host::new();
10150        let operations = host.operations();
10151        operations.install(&host.request(StartMode::Boot)).unwrap();
10152        host.controls.edit("runner-manager", |registration| {
10153            registration.account = Some("ivan".to_string());
10154        });
10155        let refused = operations
10156            .install(&host.request(StartMode::Boot))
10157            .expect_err("a unit running as ivan cannot use root's tree");
10158        let message = refused.to_string();
10159        assert!(message.contains("run as ivan"), "{message}");
10160        assert!(message.contains("Remove `User=`"), "{message}");
10161    }
10162
10163    #[test]
10164    fn manager_account_spellings_match_the_record() {
10165        assert_eq!(
10166            windows_service_account("LocalSystem"),
10167            ServiceAccount::LocalSystem.as_str()
10168        );
10169        assert_eq!(windows_service_account(".\\svc-runner"), ".\\svc-runner");
10170        assert_eq!(
10171            scheduled_task_account(Some("S-1-5-21-1-2-3-1001".into())),
10172            None
10173        );
10174        assert_eq!(
10175            scheduled_task_account(Some("HOST\\op".into())),
10176            Some("HOST\\op".to_string())
10177        );
10178    }
10179
10180    #[test]
10181    fn exec_start_prefixes_are_not_part_of_the_binary() {
10182        for (value, expected) in [
10183            ("/opt/rm daemon run", "/opt/rm daemon run"),
10184            ("-/opt/rm daemon run", "/opt/rm daemon run"),
10185            ("+!/opt/rm daemon run", "/opt/rm daemon run"),
10186            ("!!/opt/rm daemon run", "/opt/rm daemon run"),
10187            ("@/opt/rm runner-manager daemon run", "/opt/rm daemon run"),
10188            ("-@\"/opt/my rm\" rm daemon", "\"/opt/my rm\" daemon"),
10189        ] {
10190            assert_eq!(systemd_exec_command_line(value), expected, "{value}");
10191        }
10192        assert_eq!(
10193            executable_from_command_line(&systemd_exec_command_line("-/opt/rm daemon")),
10194            Some(PathBuf::from("/opt/rm"))
10195        );
10196    }
10197
10198    #[test]
10199    fn restart_sec_accepts_systemd_time_spans() {
10200        for (value, expected) in [
10201            ("15", Duration::from_secs(15)),
10202            ("15s", Duration::from_secs(15)),
10203            ("1min", Duration::from_secs(60)),
10204            ("1min 30s", Duration::from_secs(90)),
10205            ("1min30s", Duration::from_secs(90)),
10206            ("2h", Duration::from_secs(7_200)),
10207            ("500ms", Duration::from_millis(500)),
10208            ("1.5s", Duration::from_millis(1_500)),
10209        ] {
10210            assert_eq!(systemd_timespan(value), Some(expected), "{value}");
10211        }
10212        for value in ["", "infinity", "soon", "5 parsecs", "s"] {
10213            assert_eq!(systemd_timespan(value), None, "{value}");
10214        }
10215    }
10216
10217    #[cfg(unix)]
10218    #[test]
10219    fn the_system_tree_is_reported_as_roots_installation_under_var_lib() {
10220        let system = ServiceDirectories::of(&AppPaths::system());
10221        assert_eq!(system.root(), Path::new("/var/lib/runner-manager"));
10222        assert_eq!(
10223            data_root_owner(&system.root(), Some(Path::new("/home/op"))).as_deref(),
10224            Some("root")
10225        );
10226    }
10227
10228    #[test]
10229    fn a_boot_service_refuses_directories_it_could_not_open() {
10230        let var = Path::new("/var/lib/runner-manager/state");
10231        assert_eq!(boot_directory_refusal(var, Some(0)), None);
10232        let refused = boot_directory_refusal(var, Some(1000)).expect("another owner");
10233        assert!(refused.contains("uid 1000"), "{refused}");
10234        assert!(boot_directory_refusal(var, None).is_some());
10235        for hidden in ["/home/op/.config/runner-manager", "/root/.local/state/rm"] {
10236            let refused =
10237                boot_directory_refusal(Path::new(hidden), Some(0)).expect("a hidden tree");
10238            assert!(refused.contains("cannot see"), "{refused}");
10239        }
10240    }
10241
10242    #[cfg(unix)]
10243    #[test]
10244    fn a_moved_tree_keeps_files_modes_and_links_but_not_the_skipped_entry() {
10245        use std::os::unix::fs::PermissionsExt as _;
10246
10247        let from = tempfile::tempdir().unwrap();
10248        let to = tempfile::tempdir().unwrap();
10249        let target = to.path().join("state");
10250        std::fs::create_dir_all(from.path().join("journal/2026")).unwrap();
10251        std::fs::write(from.path().join("journal/2026/attempt"), b"entry").unwrap();
10252        std::fs::write(from.path().join("secret"), b"x").unwrap();
10253        std::fs::set_permissions(
10254            from.path().join("secret"),
10255            std::fs::Permissions::from_mode(0o600),
10256        )
10257        .unwrap();
10258        std::os::unix::fs::symlink("journal", from.path().join("latest")).unwrap();
10259        std::fs::create_dir(from.path().join("bin")).unwrap();
10260
10261        copy_tree(from.path(), &target, Some("bin")).unwrap();
10262
10263        assert_eq!(
10264            std::fs::read(target.join("journal/2026/attempt")).unwrap(),
10265            b"entry"
10266        );
10267        let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
10268        assert_eq!(mode(&target.join("secret")), 0o600);
10269        assert_eq!(mode(&target.join("journal")), 0o700);
10270        assert_eq!(
10271            std::fs::read_link(target.join("latest")).unwrap(),
10272            Path::new("journal")
10273        );
10274        assert!(!target.join("bin").exists());
10275
10276        // An occupied target is set aside whole, not merged into or deleted.
10277        std::fs::write(target.join("stale.sqlite3-wal"), b"stale").unwrap();
10278        set_aside(&target, "20261110T000000Z").unwrap();
10279        assert!(!target.exists());
10280        let aside = to.path().join("state.before-move-20261110T000000Z");
10281        assert_eq!(
10282            std::fs::read(aside.join("stale.sqlite3-wal")).unwrap(),
10283            b"stale"
10284        );
10285        copy_tree(from.path(), &target, Some("bin")).unwrap();
10286        assert!(!target.join("stale.sqlite3-wal").exists());
10287        set_aside(&to.path().join("absent"), "x").unwrap();
10288    }
10289
10290    #[test]
10291    fn systemd_drop_ins_replace_base_service_directives() {
10292        let base = systemd_unit(&linux_plan(StartMode::Boot));
10293        let combined = format!(
10294            "# /etc/systemd/system/runner-manager.service\n{base}\n\
10295             # /etc/systemd/system/runner-manager.service.d/account.conf\n\
10296             [Service]\nUser=ivan\nRestartSec=30\nExecStart=\n\
10297             ExecStart=/opt/runner-manager daemon run\n"
10298        );
10299        let directives = ini_directives(&combined, "Service");
10300        assert_eq!(directives["User"], "ivan");
10301        assert_eq!(directives["RestartSec"], "30");
10302        assert_eq!(directives["ExecStart"], "/opt/runner-manager daemon run");
10303        assert_eq!(directives["ProtectSystem"], "strict");
10304    }
10305
10306    #[test]
10307    fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
10308        let plan = linux_plan(StartMode::Login);
10309        let rendered = systemd_unit(&plan);
10310        let widened = edited(
10311            &rendered,
10312            "CapabilityBoundingSet=\n",
10313            "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
10314        );
10315        let review = review_least_privilege(
10316            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10317            &plan,
10318        );
10319        assert!(!review.is_least_privilege(), "{review}");
10320        assert!(
10321            review
10322                .excesses()
10323                .iter()
10324                .any(|finding| finding.subject == "CapabilityBoundingSet"),
10325            "{review}"
10326        );
10327    }
10328
10329    #[test]
10330    fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
10331        let plan = linux_plan(StartMode::Boot);
10332        let rendered = systemd_unit(&plan);
10333        let widened = edited(
10334            &rendered,
10335            "[Install]",
10336            "ListenStream=127.0.0.1:9000\n\n[Install]",
10337        );
10338        let review = review_least_privilege(
10339            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10340            &plan,
10341        );
10342        assert!(
10343            !review.is_least_privilege(),
10344            "07-security.md rule 2 forbids any inbound surface: {review}"
10345        );
10346    }
10347
10348    #[test]
10349    fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
10350        let plan = linux_plan(StartMode::Boot);
10351        let rendered = systemd_unit(&plan);
10352        let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
10353        let review = review_least_privilege(
10354            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
10355            &plan,
10356        );
10357        assert!(
10358            review.is_least_privilege(),
10359            "too little authority is not an excess: {review}"
10360        );
10361        assert!(
10362            review
10363                .findings()
10364                .iter()
10365                .any(|finding| finding.kind == FindingKind::Shortfall
10366                    && finding.detail.contains("runtime")),
10367            "{review}"
10368        );
10369    }
10370
10371    #[test]
10372    fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
10373        let plan = linux_plan(StartMode::Login);
10374        let rendered = launchd_plist(&plan);
10375        let widened = edited(
10376            &rendered,
10377            "<key>ProcessType</key>",
10378            "<key>UserName</key>\n  <string>root</string>\n  <key>ProcessType</key>",
10379        );
10380        let review = review_least_privilege(
10381            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10382            &plan,
10383        );
10384        assert!(!review.is_least_privilege(), "{review}");
10385        assert!(
10386            review
10387                .excesses()
10388                .iter()
10389                .any(|finding| finding.subject == "UserName"),
10390            "{review}"
10391        );
10392    }
10393
10394    #[test]
10395    fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
10396        let plan = linux_plan(StartMode::Boot);
10397        let rendered = launchd_plist(&plan);
10398        let widened = edited(
10399            &rendered,
10400            "<key>SessionCreate</key>\n  <false/>",
10401            "<key>SessionCreate</key>\n  <true/>",
10402        );
10403        let review = review_least_privilege(
10404            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10405            &plan,
10406        );
10407        assert!(!review.is_least_privilege(), "{review}");
10408        assert!(
10409            review
10410                .excesses()
10411                .iter()
10412                .any(|finding| finding.subject == "SessionCreate"),
10413            "{review}"
10414        );
10415    }
10416
10417    #[test]
10418    fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
10419        let plan = linux_plan(StartMode::Boot);
10420        let rendered = launchd_plist(&plan);
10421        let widened = edited(
10422            &rendered,
10423            "<key>ProcessType</key>",
10424            "<key>MachServices</key>\n  <dict/>\n  <key>ProcessType</key>",
10425        );
10426        let review = review_least_privilege(
10427            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10428            &plan,
10429        );
10430        assert!(!review.is_least_privilege(), "{review}");
10431    }
10432
10433    #[test]
10434    fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
10435        let plan = windows_plan(StartMode::Login);
10436        let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
10437        let widened = edited(
10438            &rendered,
10439            "<RunLevel>LeastPrivilege</RunLevel>",
10440            "<RunLevel>HighestAvailable</RunLevel>",
10441        );
10442        let review = review_least_privilege(
10443            &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
10444            &plan,
10445        );
10446        assert!(!review.is_least_privilege(), "{review}");
10447        assert!(
10448            review
10449                .excesses()
10450                .iter()
10451                .any(|finding| finding.subject == "RunLevel"),
10452            "{review}"
10453        );
10454    }
10455
10456    #[test]
10457    fn a_task_that_would_store_a_password_is_not_least_privilege() {
10458        let plan = windows_plan(StartMode::Login);
10459        let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
10460        let widened = edited(
10461            &rendered,
10462            "<LogonType>InteractiveToken</LogonType>",
10463            "<LogonType>Password</LogonType>",
10464        );
10465        let review = review_least_privilege(
10466            &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
10467            &plan,
10468        );
10469        assert!(!review.is_least_privilege(), "{review}");
10470    }
10471
10472    #[test]
10473    fn an_interactive_windows_service_is_not_least_privilege() {
10474        let plan = windows_plan(StartMode::Boot);
10475        let rendered = windows_service_descriptor(&plan);
10476        let widened = edited(
10477            &rendered,
10478            "ServiceType=OWN_PROCESS",
10479            "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
10480        );
10481        let review = review_least_privilege(
10482            &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
10483            &plan,
10484        );
10485        assert!(!review.is_least_privilege(), "{review}");
10486        assert!(
10487            review
10488                .excesses()
10489                .iter()
10490                .any(|finding| finding.subject == "ServiceType"),
10491            "{review}"
10492        );
10493    }
10494
10495    #[test]
10496    fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
10497        let plan = windows_plan(StartMode::Boot);
10498        let rendered = windows_service_descriptor(&plan);
10499        let changed = edited(
10500            &rendered,
10501            "Account=NT AUTHORITY\\SYSTEM",
10502            "Account=NT AUTHORITY\\LocalService",
10503        );
10504        let review = review_least_privilege(
10505            &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
10506            &plan,
10507        );
10508        // LocalService is *less* privileged, so this is not an excess. It is
10509        // still wrong, because `d2`'s DACL names SY, BA and OW and nothing else:
10510        // the daemon would start and find no credential. Reporting it as a
10511        // shortfall rather than as an excess is the difference between a check
10512        // that understands the requirement and one that counts adjectives.
10513        assert!(review.is_least_privilege(), "{review}");
10514        assert!(
10515            review.findings().iter().any(|finding| {
10516                finding.kind == FindingKind::Shortfall && finding.subject == "Account"
10517            }),
10518            "{review}"
10519        );
10520    }
10521
10522    // -----------------------------------------------------------------------
10523    // The recorded absolute path, and what became of it
10524    // -----------------------------------------------------------------------
10525
10526    #[test]
10527    fn a_recorded_path_that_is_still_there_is_current() {
10528        let host = Host::new();
10529        let state = inspect_binary(&host.binary, Some(&host.binary));
10530        assert!(!state.is_error(), "{state}");
10531        assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
10532    }
10533
10534    #[test]
10535    fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
10536        let host = Host::new();
10537        // An `npm i -g` binary lives under the active Node installation's
10538        // global prefix. Switching Node versions moves the prefix, and the
10539        // recorded path stops existing while the registration survives.
10540        let recorded = host.binary.clone();
10541        let healthy = inspect_binary(&recorded, Some(&recorded));
10542        assert!(
10543            !healthy.is_error(),
10544            "the discriminator: before the binary moves, this must be healthy"
10545        );
10546
10547        std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
10548
10549        let state = inspect_binary(&recorded, Some(&recorded));
10550        assert!(state.is_error(), "{state}");
10551        assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
10552        assert!(
10553            state.to_string().contains("npm"),
10554            "the message must name the cause an operator will not otherwise connect: {state}"
10555        );
10556    }
10557
10558    #[test]
10559    fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
10560        let root = tempfile::tempdir().expect("a temporary directory");
10561        let state = inspect_binary(root.path(), None);
10562        assert!(state.is_error(), "{state}");
10563        assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
10564    }
10565
10566    #[test]
10567    fn a_registration_naming_a_different_binary_is_a_divergence() {
10568        let host = Host::new();
10569        let other = host.binary.with_file_name("something-else");
10570        let state = inspect_binary(&host.binary, Some(&other));
10571        assert!(state.is_error(), "{state}");
10572        assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
10573    }
10574
10575    #[test]
10576    fn absence_is_reported_before_divergence() {
10577        let host = Host::new();
10578        let recorded = host.binary.clone();
10579        std::fs::remove_file(&recorded).expect("removable");
10580        let other = recorded.with_file_name("something-else");
10581        // Both faults are true at once. The operator needs to hear about the
10582        // missing file, not about a disagreement between two paths of which one
10583        // does not exist.
10584        assert!(matches!(
10585            inspect_binary(&recorded, Some(&other)),
10586            BinaryPath::Missing { .. }
10587        ));
10588    }
10589
10590    // -----------------------------------------------------------------------
10591    // The record
10592    // -----------------------------------------------------------------------
10593
10594    #[test]
10595    fn the_record_round_trips_through_toml() {
10596        let host = Host::new();
10597        let plan = InstallPlan::resolve(
10598            ServiceIdentity::product(),
10599            &host.request(StartMode::Boot),
10600            ServiceDirectories::of(&host.paths),
10601        )
10602        .expect("a resolvable plan");
10603        let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
10604        let record = InstallRecord::of(&plan, &definition, Utc::now());
10605        record.write(&host.paths).expect("a writable record");
10606        let read = InstallRecord::read(&host.paths)
10607            .expect("a readable record")
10608            .expect("a record is there");
10609        assert_eq!(read, record);
10610        assert_eq!(read.binary, host.binary);
10611        assert!(read.binary.is_absolute());
10612    }
10613
10614    #[test]
10615    fn a_unit_written_by_an_older_build_is_reported_and_a_current_one_is_not() {
10616        let host = Host::new();
10617        let identity = ServiceIdentity::product();
10618        let plan = InstallPlan::resolve(
10619            identity.clone(),
10620            &host.request(StartMode::Boot),
10621            ServiceDirectories::of(&host.paths),
10622        )
10623        .expect("a resolvable plan");
10624        let installed = systemd_unit(&plan);
10625        let definition =
10626            ServiceDefinition::from_text(DefinitionKind::SystemdUnit, installed.clone());
10627        let mut record = InstallRecord::of(&plan, &definition, Utc::now());
10628        let path = PathBuf::from("/etc/systemd/system/runner-manager.service");
10629
10630        assert_eq!(
10631            systemd_definition_drift(&identity, &record, path.clone(), &installed),
10632            None,
10633            "the record must rebuild exactly the unit `service install` wrote"
10634        );
10635
10636        record.installed_by_version = "0.4.7".into();
10637        let frozen = installed.replace(
10638            "[Service]\n",
10639            "[Service]\nLoadCredential=runner-manager.user-access-token:/var/lib/runner-manager/secrets/user-access-token\n",
10640        );
10641        let drift = systemd_definition_drift(&identity, &record, path, &frozen)
10642            .expect("an outdated unit is drift");
10643        assert_eq!(drift.installed_by_version, "0.4.7");
10644        assert_eq!(drift.rendered, installed);
10645
10646        let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
10647            .with_definition_drift(Some(drift));
10648        assert!(!status.is_healthy());
10649        assert!(
10650            status
10651                .problems()
10652                .iter()
10653                .any(|problem| problem.subject == "definition" && problem.detail.contains("0.4.7")),
10654            "{status}"
10655        );
10656    }
10657
10658    #[test]
10659    fn a_rejected_credential_keeps_its_first_moment_and_makes_the_service_unhealthy() {
10660        let host = Host::new();
10661        assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
10662        let first = Utc::now() - chrono::Duration::hours(9);
10663        record_github_credential_rejected(&host.paths, first).unwrap();
10664        record_github_credential_rejected(&host.paths, Utc::now()).unwrap();
10665        let since = github_credential_rejected_since(&host.paths).unwrap();
10666        assert_eq!(since, Some(first));
10667
10668        let status = ServiceStatus::compose(
10669            ServiceIdentity::product(),
10670            None,
10671            None,
10672            None,
10673            None,
10674            &host.paths,
10675        )
10676        .with_credential_rejection(since);
10677        assert!(!status.is_healthy());
10678        assert_eq!(status.credential_rejected_since(), Some(first));
10679        assert!(status.to_string().contains("auth login"), "{status}");
10680
10681        clear_github_credential_rejection(&host.paths).unwrap();
10682        clear_github_credential_rejection(&host.paths).unwrap();
10683        assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
10684    }
10685
10686    /// A guest that started nothing for 13 days while this said `healthy`.
10687    #[test]
10688    fn blocked_launches_make_the_service_unhealthy_with_their_remedy() {
10689        use crate::launch_health::{
10690            BlockCause, Viewpoint, clear_launches_blocked, launches_blocked,
10691            record_launches_blocked,
10692        };
10693
10694        let host = Host::new();
10695        let compose = || {
10696            ServiceStatus::compose(
10697                ServiceIdentity::product(),
10698                None,
10699                None,
10700                None,
10701                None,
10702                &host.paths,
10703            )
10704            .with_launches_blocked(launches_blocked(&host.paths, Utc::now()))
10705        };
10706        assert!(
10707            !compose()
10708                .problems()
10709                .iter()
10710                .any(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
10711        );
10712        let blocked = BlockCause::FenceHeld {
10713            directory: PathBuf::from("launch-fence"),
10714            owner: None,
10715        }
10716        .blocked_since(Utc::now(), Viewpoint::Guest);
10717        record_launches_blocked(&host.paths, &blocked, Utc::now()).unwrap();
10718
10719        let status = compose();
10720        assert!(!status.is_healthy());
10721        assert_eq!(status.launches_blocked(), Some(&blocked));
10722        let problem = status
10723            .problems()
10724            .iter()
10725            .find(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
10726            .expect("reported under its own subject");
10727        assert!(problem.detail.contains("systemctl restart"), "{status}");
10728
10729        clear_launches_blocked(&host.paths).unwrap();
10730        assert_eq!(compose().launches_blocked(), None);
10731    }
10732
10733    /// The daemon's own "I cannot read my credential", which used to exist
10734    /// only in its log while `service status` said `healthy`.
10735    #[test]
10736    fn an_unreadable_credential_keeps_its_first_moment_and_names_the_exact_command() {
10737        let host = Host::new();
10738        assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
10739        let first = Utc::now() - chrono::Duration::minutes(40);
10740        record_credential_unreadable(&host.paths, first).unwrap();
10741        record_credential_unreadable(&host.paths, Utc::now()).unwrap();
10742        let since = credential_unreadable_since(&host.paths).unwrap();
10743        assert_eq!(since, Some(first), "a restart loop must not move the start");
10744
10745        let status = ServiceStatus::compose(
10746            ServiceIdentity::product(),
10747            None,
10748            None,
10749            None,
10750            None,
10751            &host.paths,
10752        )
10753        .with_credential_unreadable(since);
10754        assert!(!status.is_healthy());
10755        let problem = status
10756            .problems()
10757            .iter()
10758            .find(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT)
10759            .expect("reported under its own subject");
10760        // No record, so the default start mode, whose exact command is named.
10761        assert!(
10762            problem
10763                .detail
10764                .contains(&sign_in_instruction(StartMode::default())),
10765            "{status}"
10766        );
10767
10768        clear_credential_unreadable(&host.paths).unwrap();
10769        clear_credential_unreadable(&host.paths).unwrap();
10770        assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
10771        let healthy = ServiceStatus::compose(
10772            ServiceIdentity::product(),
10773            None,
10774            None,
10775            None,
10776            None,
10777            &host.paths,
10778        )
10779        .with_credential_unreadable(None);
10780        assert!(
10781            !healthy
10782                .problems()
10783                .iter()
10784                .any(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT),
10785            "{healthy}"
10786        );
10787    }
10788
10789    #[test]
10790    fn a_sudo_command_names_a_binary_sudo_would_not_find() {
10791        assert_eq!(sudo_program(None), "runner-manager");
10792        assert_eq!(
10793            sudo_program(Some(Path::new("/usr/local/bin/runner-manager"))),
10794            "runner-manager"
10795        );
10796        assert_eq!(
10797            sudo_program(Some(Path::new(
10798                "/home/op/.nvm/versions/node/v26.13.0/lib/node_modules/@ivan-murzak/\
10799                 runner-manager-linux-x64/bin/runner-manager"
10800            ))),
10801            "/home/op/.nvm/versions/node/v26.13.0/lib/node_modules/@ivan-murzak/\
10802             runner-manager-linux-x64/bin/runner-manager"
10803        );
10804        assert_eq!(
10805            sudo_program(Some(Path::new("/home/o'p/my bin/runner-manager"))),
10806            "'/home/o'\\''p/my bin/runner-manager'"
10807        );
10808        assert_eq!(
10809            sudo_command("service status"),
10810            "sudo runner-manager service status"
10811        );
10812    }
10813
10814    #[test]
10815    fn the_sign_in_instruction_names_the_start_mode_it_writes() {
10816        assert!(sign_in_instruction(StartMode::Login).contains("auth login --start-at login`"));
10817        assert!(sign_in_instruction(StartMode::Boot).contains("auth login --start-at boot`"));
10818        assert_eq!(
10819            sign_in_instruction(StartMode::Boot).starts_with("`sudo "),
10820            cfg!(unix),
10821            "a machine-scoped store needs root on Unix"
10822        );
10823        assert_eq!(
10824            sign_in_instruction(StartMode::Login).contains("SSH"),
10825            cfg!(target_os = "macos"),
10826            "only the macOS login keychain is out of an SSH session's reach"
10827        );
10828    }
10829
10830    /// The record is readable by the account whose directory it is in.
10831    ///
10832    /// A boot-mode `service install` runs under `sudo`, and `NamedTempFile`
10833    /// creates at `0600`, so the record landed `root`-owned and unreadable by
10834    /// the operator — `service status` then failed on their own host with
10835    /// `Permission denied`. Ownership cannot be reproduced in an unprivileged
10836    /// test, but the mode that made ownership fatal can, and it is the half
10837    /// this code controls.
10838    #[cfg(unix)]
10839    #[test]
10840    fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
10841        use std::os::unix::fs::PermissionsExt as _;
10842
10843        let host = Host::new();
10844        let plan = InstallPlan::resolve(
10845            ServiceIdentity::product(),
10846            &host.request(StartMode::Boot),
10847            ServiceDirectories::of(&host.paths),
10848        )
10849        .expect("a resolvable plan");
10850        let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
10851        InstallRecord::of(&plan, &definition, Utc::now())
10852            .write(&host.paths)
10853            .expect("a writable record");
10854
10855        let mode = std::fs::metadata(InstallRecord::path(&host.paths))
10856            .expect("the record is there")
10857            .permissions()
10858            .mode()
10859            & 0o777;
10860        assert_eq!(
10861            mode, 0o644,
10862            "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
10863             service install` wrote, and `service status` fails on their own host. It holds no \
10864             credential and sits in a 0700 directory, so 0644 discloses nothing"
10865        );
10866    }
10867
10868    /// A record written before the service ran a copy of its own must still
10869    /// load. It reads as `None`, which is the truth about it: that registration
10870    /// names the package manager's file and cannot be upgraded under itself.
10871    #[test]
10872    fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
10873        let host = Host::new();
10874        let path = InstallRecord::path(&host.paths);
10875        std::fs::write(
10876            &path,
10877            format!(
10878                "schema_version = {RECORD_SCHEMA_VERSION}
10879service_name = \"runner-manager\"
10880                 manager = \"systemd\"
10881start_mode = \"boot\"
10882account = \"root\"
10883                 binary = \"/x\"
10884arguments = []
10885restart_delay_secs = 15
10886                 restart_reset_secs = 600
10887log_file = \"/x\"
10888                 installed_at = \"2026-01-01T00:00:00Z\"
10889installed_by_version = \"0.1.0\"
10890                 [directories]
10891config = \"/a\"
10892state = \"/b\"
10893runtime = \"/c\"
10894logs = \"/d\"
10895"
10896            ),
10897        )
10898        .expect("a writable record");
10899        let read = InstallRecord::read(&host.paths)
10900            .expect("a record missing an optional field is still readable")
10901            .expect("a record is there");
10902        assert_eq!(
10903            read.source_binary, None,
10904            "the legacy layout has no source, and must not invent one"
10905        );
10906    }
10907
10908    /// The field that makes an upgrade possible survives the write.
10909    #[test]
10910    fn a_registration_remembers_the_file_it_was_copied_from() {
10911        let host = Host::new();
10912        let source = host.binary.with_file_name("npm-installed-runner-manager");
10913        std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
10914        let plan = InstallPlan::resolve(
10915            ServiceIdentity::product(),
10916            &host.request(StartMode::Boot).copied_from(&source),
10917            ServiceDirectories::of(&host.paths),
10918        )
10919        .expect("a resolvable plan");
10920        let definition = ServiceDefinition::from_text(
10921            DefinitionKind::SystemdUnit,
10922            "[Service]
10923",
10924        );
10925        let record = InstallRecord::of(&plan, &definition, Utc::now());
10926        record.write(&host.paths).expect("a writable record");
10927
10928        let read = InstallRecord::read(&host.paths)
10929            .expect("a readable record")
10930            .expect("a record is there");
10931        assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
10932        assert_ne!(
10933            read.source_binary.as_deref(),
10934            Some(read.binary.as_path()),
10935            "the whole point is that the two are different files: one the service holds open,              one the package manager is free to replace"
10936        );
10937    }
10938
10939    #[test]
10940    fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
10941        let host = Host::new();
10942        let path = InstallRecord::path(&host.paths);
10943        std::fs::write(
10944            &path,
10945            format!(
10946                "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
10947                 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
10948                 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
10949                 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
10950                 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
10951                RECORD_SCHEMA_VERSION + 1
10952            ),
10953        )
10954        .expect("a writable record");
10955        let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
10956        assert!(
10957            matches!(error, ServiceError::RecordUnreadable { .. }),
10958            "{error}"
10959        );
10960        assert!(
10961            error.to_string().contains("service uninstall"),
10962            "the message must say how to recover: {error}"
10963        );
10964    }
10965
10966    #[test]
10967    fn no_record_is_not_an_error() {
10968        let host = Host::new();
10969        assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
10970        assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
10971    }
10972
10973    // -----------------------------------------------------------------------
10974    // The last successful GitHub contact
10975    // -----------------------------------------------------------------------
10976
10977    #[test]
10978    fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
10979        let host = Host::new();
10980        assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
10981    }
10982
10983    #[test]
10984    fn the_heartbeat_round_trips_to_the_second() {
10985        let host = Host::new();
10986        let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
10987            .expect("a valid timestamp")
10988            .with_timezone(&Utc);
10989        record_github_contact(&host.paths, at).expect("a writable heartbeat");
10990        assert_eq!(
10991            last_github_contact(&host.paths).expect("readable"),
10992            Some(at)
10993        );
10994    }
10995
10996    #[test]
10997    fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
10998        let host = Host::new();
10999        std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
11000        let error = last_github_contact(&host.paths)
11001            .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
11002        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11003    }
11004
11005    /// The surface the three-hour outage did not have.
11006    ///
11007    /// A root that refuses a launch does so before any attempt row exists, and
11008    /// the daemon's log scrubs the paths out of the sentence, so this file is
11009    /// the only place the directory and the remediation can reach the operator.
11010    #[test]
11011    fn a_runner_root_refusal_round_trips_for_service_status() {
11012        let host = Host::new();
11013        let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
11014
11015        assert!(
11016            runner_root_refusals(&host.paths)
11017                .expect("readable")
11018                .is_empty(),
11019            "no record means every policy is placing runners"
11020        );
11021
11022        record_runner_root_refusal(
11023            &host.paths,
11024            "policy-a",
11025            at,
11026            "denied_by_privacy_policy",
11027            "/Volumes/NVME/runners",
11028            "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
11029        )
11030        .expect("a writable record");
11031
11032        let refusals = runner_root_refusals(&host.paths).expect("readable");
11033        assert_eq!(refusals.len(), 1);
11034        assert_eq!(refusals[0].policy, "policy-a");
11035        assert_eq!(refusals[0].at, at);
11036        assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
11037        assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
11038        assert!(
11039            refusals[0].detail.contains("/Volumes/NVME/runners")
11040                && refusals[0].detail.contains("Full Disk Access"),
11041            "the path and the remediation are the whole point of this file: {refusals:?}"
11042        );
11043    }
11044
11045    /// The regression that would put the outage straight back.
11046    ///
11047    /// A host runs several policies and they do not share a fate. When the
11048    /// record was one host-wide slot, the policy that placed a runner deleted
11049    /// the record of the policy that could not — on the same reconcile pass —
11050    /// and `service status` reported a healthy host that had started zero
11051    /// runners for an entire target.
11052    #[test]
11053    fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
11054        let host = Host::new();
11055        let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
11056        record_runner_root_refusal(
11057            &host.paths,
11058            "broken",
11059            at,
11060            "denied_by_privacy_policy",
11061            "/Volumes/NVME/runners",
11062            "detail",
11063        )
11064        .expect("a writable record");
11065        record_runner_root_refusal(
11066            &host.paths,
11067            "also-broken",
11068            at,
11069            "not_writable",
11070            "/srv/other",
11071            "detail",
11072        )
11073        .expect("a writable record");
11074
11075        // The policy with its own working root succeeds and clears only itself.
11076        clear_runner_root_refusal(&host.paths, "healthy")
11077            .expect("clearing an absent policy is fine");
11078        clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
11079
11080        let refusals = runner_root_refusals(&host.paths).expect("readable");
11081        assert_eq!(
11082            refusals
11083                .iter()
11084                .map(|r| r.policy.as_str())
11085                .collect::<Vec<_>>(),
11086            vec!["broken"],
11087            "the policy that is still refused must keep its record"
11088        );
11089    }
11090
11091    /// Clearing is what keeps a fixed host from reporting a stale fault, and the
11092    /// file goes when the last entry does.
11093    #[test]
11094    fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
11095        let host = Host::new();
11096        record_runner_root_refusal(
11097            &host.paths,
11098            "p",
11099            Utc::now(),
11100            "not_writable",
11101            "/srv/x",
11102            "detail",
11103        )
11104        .expect("a writable record");
11105
11106        clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
11107        assert!(
11108            !root_refusal_path(&host.paths).exists(),
11109            "a host with nothing refused leaves nothing behind"
11110        );
11111        clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
11112    }
11113
11114    /// Same rule as the heartbeat: absence means "every policy is placing
11115    /// runners", so a record that cannot be parsed must not be reported as one.
11116    #[test]
11117    fn a_malformed_refusal_is_an_error_and_not_silently_none() {
11118        let host = Host::new();
11119        std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
11120        let error = runner_root_refusals(&host.paths)
11121            .expect_err("an unparseable record is not the same as no record");
11122        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11123    }
11124
11125    /// The half of this feature that turns the file into a diagnosis, and the
11126    /// half that had no test: `service status` must actually read it.
11127    ///
11128    /// A **note** and not a problem, deliberately. `service status` runs as
11129    /// whoever typed it, must stay readable on a host with nothing installed,
11130    /// and drives an exit code -- and nothing an operator can type clears this
11131    /// record, so a problem would fail that exit code on a host whose root was
11132    /// fixed but which has had no queued job since, with a printed remedy
11133    /// (`service uninstall && service install`) that does not touch `state/`.
11134    #[test]
11135    fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
11136        let host = Host::new();
11137        record_runner_root_refusal(
11138            &host.paths,
11139            "policy-a",
11140            DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
11141            "denied_by_privacy_policy",
11142            "/Volumes/NVME/runners",
11143            "Grant Full Disk Access to the program that runs the service",
11144        )
11145        .expect("a writable record");
11146
11147        let status = host.operations().status().expect("a readable status");
11148        let notes = status.notes().join("\n");
11149
11150        assert!(
11151            notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
11152            "the directory and the remediation the log had to scrub must appear here: {notes}"
11153        );
11154        assert!(
11155            notes.contains("policy-a"),
11156            "the operator has to know which target placed no runner: {notes}"
11157        );
11158        assert!(
11159            !status
11160                .problems()
11161                .iter()
11162                .any(|problem| problem.subject == "runner root"),
11163            "a record nothing an operator types can clear must not drive the exit code"
11164        );
11165    }
11166
11167    // -----------------------------------------------------------------------
11168    // -----------------------------------------------------------------------
11169    // Install
11170    // -----------------------------------------------------------------------
11171
11172    #[test]
11173    fn install_records_the_absolute_binary_path_and_the_four_directories() {
11174        let host = Host::new();
11175        let installed = host
11176            .operations()
11177            .install(&host.request(StartMode::Boot))
11178            .expect("an install against the recording controls");
11179
11180        assert_eq!(installed.record.binary, host.binary);
11181        assert!(installed.record.binary.is_absolute());
11182        assert_eq!(installed.record.start_mode, StartMode::Boot);
11183        assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
11184        assert_eq!(
11185            installed.record.directories,
11186            ServiceDirectories::of(&host.paths)
11187        );
11188        assert_eq!(
11189            installed.record.log_file,
11190            host.paths.logs_dir().join(LOG_FILE_STEM)
11191        );
11192        assert_eq!(installed.record.restart_delay_secs, 15);
11193
11194        let registrations = host.controls.registrations();
11195        assert_eq!(registrations.len(), 1);
11196        assert_eq!(registrations[0].0, StartMode::Boot);
11197        assert_eq!(registrations[0].1, "runner-manager");
11198    }
11199
11200    #[test]
11201    fn install_is_refused_while_the_single_instance_lock_is_held() {
11202        let host = Host::new();
11203        // The discriminator: with the lock free, the same call succeeds.
11204        {
11205            let operations = host.operations();
11206            operations
11207                .install(&host.request(StartMode::Boot))
11208                .expect("an install with the lock free");
11209            operations.uninstall().expect("a clean slate");
11210        }
11211
11212        let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
11213            .expect("this process takes the lock first");
11214
11215        let error = host
11216            .operations()
11217            .install(&host.request(StartMode::Boot))
11218            .expect_err("a second agent must not be registered while one is running");
11219        assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
11220        assert!(
11221            error.to_string().contains("already running"),
11222            "the message must be actionable: {error}"
11223        );
11224        assert!(
11225            host.controls.registrations().is_empty(),
11226            "a refused install must register nothing"
11227        );
11228        assert_eq!(
11229            InstallRecord::read(&host.paths).expect("readable"),
11230            None,
11231            "a refused install must write no record"
11232        );
11233    }
11234
11235    /// Installing over the same start mode replaces the registration.
11236    ///
11237    /// It used to be refused, and the refusal is what broke a real host: the
11238    /// caller has already swapped the binary the service runs by the time this
11239    /// is reached — that is the documented way to upgrade by hand — so refusing
11240    /// left a daemon running an unregistered binary and, on macOS, one the
11241    /// stored credential's keychain grant did not name.
11242    #[test]
11243    fn installing_over_the_same_start_mode_replaces_the_registration() {
11244        let host = Host::new();
11245        let operations = host.operations();
11246        operations
11247            .install(&host.request(StartMode::Boot))
11248            .expect("the first install");
11249
11250        let again = operations
11251            .install(&host.request(StartMode::Boot))
11252            .expect("an install over the same mode replaces rather than refusing");
11253
11254        assert!(
11255            again.replaced_existing,
11256            "the operator is told this replaced something rather than made it"
11257        );
11258        assert_eq!(
11259            host.controls.registrations().len(),
11260            1,
11261            "replacing must not leave two registrations behind"
11262        );
11263        assert_eq!(
11264            InstallRecord::read(&host.paths)
11265                .expect("readable")
11266                .expect("a record")
11267                .start_mode,
11268            StartMode::Boot
11269        );
11270    }
11271
11272    /// The *other* mode is still refused, because changing it moves the
11273    /// registration between two service managers and changes the account and
11274    /// the secret store with it. `set_start_mode` is what does that.
11275    #[test]
11276    fn installing_over_the_other_start_mode_is_refused() {
11277        let host = Host::new();
11278        let operations = host.operations();
11279        operations
11280            .install(&host.request(StartMode::Boot))
11281            .expect("the first install");
11282
11283        let error = operations
11284            .install(&host.request(StartMode::Login))
11285            .expect_err("a mode change is not an install");
11286        assert!(
11287            matches!(
11288                error,
11289                ServiceError::AlreadyInstalled {
11290                    existing: StartMode::Boot,
11291                    requested: StartMode::Login,
11292                    ..
11293                }
11294            ),
11295            "{error}"
11296        );
11297        assert!(
11298            !error
11299                .to_string()
11300                .contains("switch the start mode in place,"),
11301            "the old remedy named a capability no command offers; the terminal UI is where \
11302             the start mode moves: {error}"
11303        );
11304        assert_eq!(host.controls.registrations().len(), 1);
11305    }
11306
11307    #[test]
11308    fn install_rolls_back_the_registration_when_record_persistence_fails() {
11309        let host = Host::new();
11310        let record_path = InstallRecord::path(&host.paths);
11311        std::fs::create_dir(&record_path).expect("a directory blocks the record file");
11312
11313        let error = host
11314            .operations()
11315            .install(&host.request(StartMode::Boot))
11316            .expect_err("record persistence must fail");
11317
11318        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11319        assert!(
11320            host.controls.registrations().is_empty(),
11321            "a failed install must not leave a live unrecorded registration"
11322        );
11323        assert!(
11324            host.controls
11325                .calls()
11326                .iter()
11327                .any(|call| call == "uninstall runner-manager (boot)"),
11328            "the registration must be explicitly rolled back: {:?}",
11329            host.controls.calls()
11330        );
11331        assert!(
11332            !host.runner_root.as_path().exists(),
11333            "the rollback must take the runner root this install created with it; a directory \
11334             prepared for a registration that does not exist is litter, and on Windows it is \
11335             litter with a security descriptor"
11336        );
11337    }
11338
11339    // -----------------------------------------------------------------------
11340    // The runner root (b2)
11341    // -----------------------------------------------------------------------
11342
11343    #[test]
11344    fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
11345        use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
11346
11347        // The mapping `prepare_runner_root` reads, asserted against the same
11348        // function the registration's own principal comes from — which is the
11349        // point of deriving it there rather than restating it.
11350        assert_eq!(
11351            ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
11352            ServiceAccount::LocalSystem
11353        );
11354        assert_eq!(
11355            ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
11356            ServiceAccount::InvokingUser
11357        );
11358
11359        let boot = default_root_sddl(&RootAdmission::LocalSystem);
11360        assert!(!grants_broad_write(&boot), "{boot}");
11361        assert!(
11362            !boot.contains("S-1-5-21"),
11363            "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
11364        );
11365
11366        // A login task runs under a *filtered* token — `RunLevel` is
11367        // `LeastPrivilege`, see `windows_scheduled_task_xml` — in which
11368        // Administrators is deny-only. Without an ace of its own the account
11369        // the task runs as would be admitted by nothing at all.
11370        let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
11371        assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
11372        assert!(!grants_broad_write(&login), "{login}");
11373    }
11374
11375    #[test]
11376    fn an_install_reports_the_runner_root_it_prepared() {
11377        let host = Host::new();
11378        let installed = host
11379            .operations()
11380            .install(&host.request(StartMode::Boot))
11381            .expect("an install");
11382        let rendered = installed.runner_root.to_string();
11383        assert!(
11384            !rendered.contains("S-1-5-21"),
11385            "the report must add no identity to the output: {rendered}"
11386        );
11387        if cfg!(windows) {
11388            assert_eq!(
11389                installed.runner_root.path(),
11390                Some(host.runner_root.as_path())
11391            );
11392            assert!(
11393                host.runner_root.as_path().is_dir(),
11394                "the directory jobs would run in has to exist once the service is registered"
11395            );
11396        } else {
11397            assert_eq!(
11398                installed.runner_root,
11399                crate::runner_root_access::RootAccessSummary::NotApplicable,
11400                "macOS and Linux keep the runtime directory they have always used"
11401            );
11402        }
11403    }
11404
11405    #[test]
11406    fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
11407        let host = Host::new();
11408        let operations = host.operations();
11409        operations
11410            .install(&host.request(StartMode::Boot))
11411            .expect("an install at boot");
11412
11413        let change = operations
11414            .set_start_mode(StartMode::Login)
11415            .expect("a switch to login");
11416
11417        assert!(change.changed);
11418        if cfg!(windows) {
11419            assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
11420            assert!(
11421                host.runner_root.as_path().is_dir(),
11422                "the switch must not remove the directory it reconciled"
11423            );
11424        }
11425        // The operator is told, because the account that may write there has
11426        // moved with the mode.
11427        assert!(
11428            change.to_string().contains("runner root"),
11429            "{}",
11430            change.to_string()
11431        );
11432    }
11433
11434    #[test]
11435    fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
11436        let host = Host::new();
11437        let operations = host.operations();
11438        operations
11439            .install(&host.request(StartMode::Boot))
11440            .expect("an install at boot");
11441
11442        let change = operations
11443            .set_start_mode(StartMode::Boot)
11444            .expect("a switch to the mode already in force");
11445
11446        assert!(!change.changed);
11447        assert_eq!(
11448            change.runner_root,
11449            crate::runner_root_access::RootAccessSummary::NotApplicable,
11450            "nothing moves, so nothing about the root's access control has to; reconciling here \
11451             would turn a no-op command into one that can fail on a permission it does not need"
11452        );
11453    }
11454
11455    #[cfg(windows)]
11456    #[test]
11457    fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
11458        let host = Host::new();
11459        host.controls
11460            .fail_next_install(StartMode::Boot, "injected registration failure");
11461
11462        let error = host
11463            .operations()
11464            .install(&host.request(StartMode::Boot))
11465            .expect_err("the manager refuses the registration");
11466
11467        assert!(matches!(error, ServiceError::Control { .. }), "{error}");
11468        assert!(
11469            !host.runner_root.as_path().exists(),
11470            "the directory was created for a registration that does not exist"
11471        );
11472    }
11473
11474    #[cfg(windows)]
11475    #[test]
11476    fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
11477        let host = Host::new();
11478        // What a directory created below `C:\` with inheritance left on looks
11479        // like, built deliberately because a per-account `%TEMP%` never
11480        // produces one by accident.
11481        crate::runner_root_access::create_with_descriptor_for_tests(
11482            host.runner_root.as_path(),
11483            "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
11484        )
11485        .expect("a deliberately open runner root");
11486        let before = crate::runner_root_access::report(host.runner_root.as_path());
11487
11488        let error = host
11489            .operations()
11490            .install(&host.request(StartMode::Boot))
11491            .expect_err("an open runner root is refused");
11492
11493        assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
11494        assert!(
11495            error.to_string().contains("nothing was registered"),
11496            "{error}"
11497        );
11498        assert!(
11499            host.controls.registrations().is_empty(),
11500            "the refusal has to come before the platform is asked to register anything: {:?}",
11501            host.controls.calls()
11502        );
11503        assert_eq!(
11504            crate::runner_root_access::report(host.runner_root.as_path()),
11505            before,
11506            "an open directory is refused rather than tightened: its contents cannot be trusted, \
11507             so adopting it would be worse than declining it"
11508        );
11509    }
11510
11511    #[cfg(windows)]
11512    #[test]
11513    fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
11514        let host = Host::new();
11515        let operations = host.operations();
11516        operations
11517            .install(&host.request(StartMode::Boot))
11518            .expect("an install");
11519        assert!(host.runner_root.as_path().is_dir());
11520
11521        operations.uninstall().expect("an uninstall");
11522
11523        assert!(
11524            host.runner_root.as_path().is_dir(),
11525            "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
11526             runner root may hold an operator's retained workspaces."
11527        );
11528    }
11529
11530    #[test]
11531    fn install_reviews_what_it_registered() {
11532        let host = Host::new();
11533        let installed = host
11534            .operations()
11535            .install(&host.request(StartMode::Boot))
11536            .expect("an install");
11537        assert!(
11538            installed.review.is_least_privilege(),
11539            "{}",
11540            installed.review
11541        );
11542        assert!(
11543            !installed.review.controls().is_empty(),
11544            "a review that confirms nothing proves nothing: {}",
11545            installed.review
11546        );
11547        assert_eq!(
11548            installed.review.kind(),
11549            host_definition_kind(StartMode::Boot),
11550            "the review must be of the definition this host's manager was given"
11551        );
11552        assert!(
11553            !installed.review.account().justification().is_empty(),
11554            "a privileged account with no stated reason is an unreviewed one"
11555        );
11556    }
11557
11558    // -----------------------------------------------------------------------
11559    // Uninstall deletes nothing else
11560    // -----------------------------------------------------------------------
11561
11562    #[test]
11563    fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
11564        let host = Host::new();
11565        let operations = host.operations();
11566        operations
11567            .install(&host.request(StartMode::Boot))
11568            .expect("an install");
11569
11570        // The files `05-infrastructure.md` says must survive. Written *after*
11571        // the install so that the snapshot below is of a host in the state an
11572        // operator's would be in.
11573        let config = host.paths.config_dir();
11574        std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
11575        std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
11576        std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
11577        std::fs::write(
11578            host.paths
11579                .state_dir()
11580                .join("packages/2.330.0/runner.tar.gz"),
11581            b"cached package",
11582        )
11583        .expect("writable");
11584        std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
11585        std::fs::write(
11586            host.paths.state_dir().join("secrets/user-access-token"),
11587            b"a stand-in for the stored credential",
11588        )
11589        .expect("writable");
11590        std::fs::write(
11591            host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
11592            b"diagnostics",
11593        )
11594        .expect("writable");
11595
11596        let roots: Vec<PathBuf> = host
11597            .paths
11598            .all()
11599            .iter()
11600            .map(|(_, path)| (*path).to_path_buf())
11601            .collect();
11602        let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
11603        let before = snapshot(&roots);
11604
11605        // Non-vacuity: a comparison of two empty maps would pass whatever
11606        // `uninstall` did.
11607        assert!(
11608            before.len() >= 6,
11609            "the fixture must actually contain the files this test is about, got {before:#?}"
11610        );
11611        let record_path = InstallRecord::path(&host.paths);
11612        assert!(
11613            before.contains_key(&record_path),
11614            "the install record must be present before uninstall"
11615        );
11616
11617        let uninstalled = operations.uninstall().expect("an uninstall");
11618        assert!(uninstalled.removed_registration);
11619        assert!(uninstalled.removed_record);
11620
11621        let after = snapshot(&roots);
11622
11623        // Exactly one thing changed, and it is the registration's own record.
11624        let mut expected = before.clone();
11625        expected.remove(&record_path);
11626        assert_eq!(
11627            after, expected,
11628            "uninstall must remove its own record and nothing else"
11629        );
11630        assert!(
11631            !record_path.exists(),
11632            "the record itself must go, or `uninstall` did nothing at all"
11633        );
11634        assert!(
11635            uninstalled
11636                .preserved
11637                .iter()
11638                .all(|path| roots.contains(&path.as_path())),
11639            "the preserved list must name the four directories: {uninstalled}"
11640        );
11641    }
11642
11643    #[test]
11644    fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
11645        let host = Host::new();
11646        let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
11647        assert!(!uninstalled.removed_registration);
11648        assert!(!uninstalled.removed_record);
11649    }
11650
11651    #[test]
11652    fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
11653        let host = Host::new();
11654        let operations = host.operations();
11655        operations
11656            .install(&host.request(StartMode::Boot))
11657            .expect("an install");
11658        std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
11659
11660        let uninstalled = operations.uninstall().expect("an uninstall");
11661        assert!(
11662            uninstalled.removed_registration,
11663            "a lost record must not strand a registration"
11664        );
11665        assert!(host.controls.registrations().is_empty());
11666    }
11667
11668    // -----------------------------------------------------------------------
11669    // Switching start mode
11670    // -----------------------------------------------------------------------
11671
11672    #[test]
11673    fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
11674        let host = Host::new();
11675        let operations = host.operations();
11676        operations
11677            .install(&host.request(StartMode::Boot))
11678            .expect("an install at boot");
11679
11680        // The discriminator. If the switch re-resolved the binary the way
11681        // `install` does, it would either fail here or silently record the test
11682        // binary instead. Removing the file makes the difference visible.
11683        std::fs::remove_file(&host.binary).expect("the installed binary goes away");
11684
11685        let change = operations
11686            .set_start_mode(StartMode::Login)
11687            .expect("a switch that does not reinstall the product");
11688        assert!(change.changed);
11689        assert_eq!(change.from, StartMode::Boot);
11690        assert_eq!(change.to, StartMode::Login);
11691        assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
11692
11693        let record = InstallRecord::read(&host.paths)
11694            .expect("readable")
11695            .expect("a record");
11696        assert_eq!(record.start_mode, StartMode::Login);
11697        assert_eq!(
11698            record.binary, host.binary,
11699            "the recorded path must survive the switch untouched"
11700        );
11701
11702        let registrations = host.controls.registrations();
11703        assert_eq!(registrations.len(), 1, "{registrations:?}");
11704        assert_eq!(registrations[0].0, StartMode::Login);
11705        assert!(
11706            registrations[0]
11707                .2
11708                .command_line
11709                .contains(&host.binary.to_string_lossy().into_owned()),
11710            "{:?}",
11711            registrations[0].2
11712        );
11713    }
11714
11715    #[test]
11716    fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
11717        let host = Host::new();
11718        let operations = host.operations();
11719        operations
11720            .install(&host.request(StartMode::Boot))
11721            .expect("an install at boot");
11722        let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
11723        host.controls
11724            .fail_next_install(StartMode::Login, "injected target failure");
11725
11726        let error = operations
11727            .set_start_mode(StartMode::Login)
11728            .expect_err("the target manager refuses the install");
11729
11730        assert!(matches!(error, ServiceError::Control { .. }), "{error}");
11731        assert_eq!(
11732            std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
11733            record_before
11734        );
11735        let registrations = host.controls.registrations();
11736        assert_eq!(registrations.len(), 1, "{registrations:?}");
11737        assert_eq!(registrations[0].0, StartMode::Boot);
11738    }
11739
11740    #[test]
11741    fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
11742        let host = Host::new();
11743        let operations = host.operations();
11744        operations
11745            .install(&host.request(StartMode::Boot))
11746            .expect("an install at boot");
11747        let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
11748        let config = host.paths.config_dir().to_path_buf();
11749        let hidden = config.with_file_name("config-hidden-by-fault");
11750        host.controls.hide_directory_after_install(
11751            StartMode::Login,
11752            config.clone(),
11753            hidden.clone(),
11754        );
11755
11756        let error = operations
11757            .set_start_mode(StartMode::Login)
11758            .expect_err("the injected filesystem fault prevents persistence");
11759
11760        std::fs::remove_file(&config).expect("remove the injected blocker");
11761        std::fs::rename(&hidden, &config).expect("restore the record directory");
11762        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11763        assert_eq!(
11764            std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
11765            record_before
11766        );
11767        let registrations = host.controls.registrations();
11768        assert_eq!(registrations.len(), 1, "{registrations:?}");
11769        assert_eq!(registrations[0].0, StartMode::Boot);
11770        assert!(
11771            host.controls
11772                .calls()
11773                .iter()
11774                .any(|call| call == "uninstall runner-manager (login)"),
11775            "the target must be rolled back: {:?}",
11776            host.controls.calls()
11777        );
11778    }
11779
11780    #[test]
11781    fn switching_to_the_mode_already_in_force_registers_nothing_again() {
11782        let host = Host::new();
11783        let operations = host.operations();
11784        operations
11785            .install(&host.request(StartMode::Boot))
11786            .expect("an install");
11787        let before = host.controls.calls().len();
11788
11789        let change = operations
11790            .set_start_mode(StartMode::Boot)
11791            .expect("a no-op switch");
11792        assert!(!change.changed);
11793        assert_eq!(
11794            host.controls.calls().len(),
11795            before,
11796            "a no-op switch must not touch the service manager"
11797        );
11798    }
11799
11800    #[test]
11801    fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
11802        let host = Host::new();
11803        let error = host
11804            .operations()
11805            .set_start_mode(StartMode::Login)
11806            .expect_err("there is nothing to switch");
11807        assert!(
11808            matches!(error, ServiceError::NotInstalled { .. }),
11809            "{error}"
11810        );
11811    }
11812
11813    // -----------------------------------------------------------------------
11814    // Status
11815    // -----------------------------------------------------------------------
11816
11817    #[test]
11818    fn status_reports_the_four_facts_journey_five_asks_for() {
11819        let host = Host::new();
11820        let operations = host.operations();
11821        operations
11822            .install(&host.request(StartMode::Boot))
11823            .expect("an install");
11824        let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
11825            .expect("a valid timestamp")
11826            .with_timezone(&Utc);
11827        record_github_contact(&host.paths, at).expect("a heartbeat");
11828
11829        let status = operations.status().expect("a status");
11830        assert_eq!(status.start_mode(), Some(StartMode::Boot));
11831        assert_eq!(
11832            status.binary().map(BinaryPath::recorded),
11833            Some(host.binary.as_path())
11834        );
11835        assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
11836        assert_eq!(status.last_github_contact(), Some(at));
11837        assert!(status.is_installed());
11838        assert!(status.is_healthy(), "{status}");
11839
11840        let printed = status.to_string();
11841        for fragment in [
11842            "start mode",
11843            "diagnostic log",
11844            "last GitHub contact",
11845            "binary",
11846        ] {
11847            assert!(printed.contains(fragment), "{printed}");
11848        }
11849    }
11850
11851    /// A record this account may not read is reported, and `status` keeps
11852    /// going.
11853    ///
11854    /// It used to end the command with `Permission denied`, which is exactly
11855    /// what an operator met on a boot-mode host: `sudo service install` wrote
11856    /// the record `0600` and `root`-owned, and `service status` — a command
11857    /// that asks no privilege of anybody — then printed nothing at all about a
11858    /// registration the service manager could describe perfectly well.
11859    ///
11860    /// Ownership cannot be reproduced without privileges; the mode that made it
11861    /// fatal can.
11862    #[cfg(unix)]
11863    #[test]
11864    fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
11865        use std::os::unix::fs::PermissionsExt as _;
11866
11867        // `root` is not subject to the mode bits and would read the record
11868        // regardless, leaving the assertions below testing nothing.
11869        // SAFETY: `geteuid` takes no argument and cannot fail.
11870        if unsafe { libc::geteuid() } == 0 {
11871            return;
11872        }
11873
11874        let host = Host::new();
11875        let operations = host.operations();
11876        operations
11877            .install(&host.request(StartMode::Boot))
11878            .expect("an install");
11879        assert!(
11880            operations.status().expect("a status").is_healthy(),
11881            "the discriminator: healthy before the record is made unreadable"
11882        );
11883
11884        let record = InstallRecord::path(&host.paths);
11885        std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
11886            .expect("the mode is applied");
11887
11888        let status = operations
11889            .status()
11890            .expect("a record this account may not read is reported, not thrown");
11891        assert!(status.is_installed(), "{status}");
11892        assert!(!status.is_healthy(), "{status}");
11893
11894        let printed = status.to_string();
11895        assert!(
11896            printed.contains("this account may not read it"),
11897            "the operator is told which of the two states this is: {printed}"
11898        );
11899        assert!(
11900            !printed.contains("there is no install record"),
11901            "a record that is there and unreadable is not a record that is missing, and the \
11902             missing one's remedy starts with `service uninstall`: {printed}"
11903        );
11904
11905        // Left readable so the fixture's own cleanup is not the thing that
11906        // fails on a host where TempDir removal walks the tree.
11907        std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
11908            .expect("the mode is restored");
11909    }
11910
11911    #[test]
11912    fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
11913        let host = Host::new();
11914        let operations = host.operations();
11915        operations
11916            .install(&host.request(StartMode::Boot))
11917            .expect("an install");
11918
11919        // The discriminator: healthy first, so a `is_healthy` that always
11920        // returned false could not pass this test.
11921        assert!(
11922            operations.status().expect("a status").is_healthy(),
11923            "the freshly installed host must be healthy"
11924        );
11925
11926        std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
11927
11928        let status = operations.status().expect("a status");
11929        assert!(!status.is_healthy(), "{status}");
11930        assert!(
11931            status
11932                .problems()
11933                .iter()
11934                .any(|problem| problem.subject == "binary"),
11935            "{status}"
11936        );
11937        assert!(status.to_string().contains("STALE"), "{status}");
11938    }
11939
11940    #[test]
11941    fn status_reports_a_registration_that_would_not_start_at_boot() {
11942        let host = Host::new();
11943        let operations = host.operations();
11944        operations
11945            .install(&host.request(StartMode::Boot))
11946            .expect("an install");
11947        assert!(operations.status().expect("a status").is_healthy());
11948
11949        host.controls.edit("runner-manager", |registration| {
11950            registration.starts_automatically = false;
11951        });
11952
11953        let status = operations.status().expect("a status");
11954        assert!(!status.is_healthy(), "{status}");
11955        assert!(
11956            status
11957                .problems()
11958                .iter()
11959                .any(|problem| problem.detail.contains("after a reboot")),
11960            "{status}"
11961        );
11962    }
11963
11964    #[test]
11965    fn install_starts_the_registration_and_a_later_stop_is_unhealthy() {
11966        let host = Host::new();
11967        let operations = host.operations();
11968        operations
11969            .install(&host.request(StartMode::Login))
11970            .expect("install and immediate start");
11971
11972        assert!(
11973            operations.status().expect("running status").is_running(),
11974            "install must not wait for the next login trigger"
11975        );
11976        operations.stop().expect("stop the registration");
11977        let status = operations.status().expect("stopped status");
11978        assert!(!status.is_healthy(), "{status}");
11979        assert!(
11980            status
11981                .problems()
11982                .iter()
11983                .any(|problem| problem.subject == "runtime"),
11984            "{status}"
11985        );
11986        let rendered = status.to_string();
11987        assert!(
11988            rendered.contains("runner-manager service install --start-at login"),
11989            "{rendered}"
11990        );
11991        assert!(
11992            !rendered.contains("runner-manager service start"),
11993            "{rendered}"
11994        );
11995    }
11996
11997    #[test]
11998    fn status_reports_a_restart_policy_something_else_edited() {
11999        let host = Host::new();
12000        let operations = host.operations();
12001        operations
12002            .install(&host.request(StartMode::Boot))
12003            .expect("an install");
12004        assert!(operations.status().expect("a status").is_healthy());
12005
12006        host.controls.edit("runner-manager", |registration| {
12007            registration.restart_delay = Some(Duration::from_secs(1));
12008        });
12009
12010        let status = operations.status().expect("a status");
12011        assert!(!status.is_healthy(), "{status}");
12012        assert!(
12013            status
12014                .problems()
12015                .iter()
12016                .any(|problem| problem.subject == "restart policy"),
12017            "{status}"
12018        );
12019    }
12020
12021    #[test]
12022    fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
12023        let host = Host::new();
12024        let operations = host.operations();
12025        operations
12026            .install(&host.request(StartMode::Boot))
12027            .expect("an install");
12028        let other = host.binary.with_file_name("someone-elses.exe");
12029        std::fs::write(&other, b"x").expect("writable");
12030
12031        host.controls.edit("runner-manager", |registration| {
12032            registration.command_line = quote_argument(&other.to_string_lossy());
12033        });
12034
12035        let status = operations.status().expect("a status");
12036        assert!(!status.is_healthy(), "{status}");
12037        assert!(
12038            matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
12039            "{status}"
12040        );
12041    }
12042
12043    #[test]
12044    fn status_reports_a_record_no_service_manager_knows_about() {
12045        let host = Host::new();
12046        let operations = host.operations();
12047        operations
12048            .install(&host.request(StartMode::Boot))
12049            .expect("an install");
12050        // Something removed the registration behind this product's back.
12051        for mode in [StartMode::Boot, StartMode::Login] {
12052            host.controls
12053                .control(mode)
12054                .expect("a control")
12055                .uninstall(&ServiceIdentity::product())
12056                .expect("removed");
12057        }
12058
12059        let status = operations.status().expect("a status");
12060        assert!(!status.is_healthy(), "{status}");
12061        assert!(
12062            status
12063                .problems()
12064                .iter()
12065                .any(|problem| problem.subject == "registration"),
12066            "{status}"
12067        );
12068    }
12069
12070    #[test]
12071    fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
12072        let host = Host::new();
12073        let status = host.operations().status().expect("a status");
12074        assert!(!status.is_installed());
12075        assert!(
12076            status.is_healthy(),
12077            "a host that never installed the service has no fault to report: {status}"
12078        );
12079        assert!(status.to_string().contains("installed"), "{status}");
12080    }
12081
12082    #[test]
12083    fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
12084        let host = Host::new();
12085        let operations = host.operations();
12086        operations
12087            .install(&host.request(StartMode::Login))
12088            .expect("an install at login");
12089        let status = operations.status().expect("a status");
12090        assert!(
12091            status
12092                .notes()
12093                .iter()
12094                .any(|note| note.contains("does not run until the operator signs in")),
12095            "05-infrastructure.md requires `service status` to say so: {status}"
12096        );
12097    }
12098
12099    /// A Mac that signs its runner account in automatically does resume, and
12100    /// `service status` used to tell it otherwise.
12101    #[test]
12102    fn a_mac_with_automatic_login_as_the_service_account_resumes_after_a_reboot() {
12103        use crate::unattended_login::{AutoLogin, UnattendedLogin};
12104        let automatic = UnattendedLogin {
12105            auto_login: AutoLogin::As("ivan".into()),
12106            filevault_on: Some(false),
12107        };
12108        assert_eq!(
12109            unattended_gap(StartMode::Login, "ivan", Some(&automatic)),
12110            None
12111        );
12112
12113        let off = UnattendedLogin {
12114            auto_login: AutoLogin::Off,
12115            filevault_on: Some(false),
12116        };
12117        let gap = unattended_gap(StartMode::Login, "ivan", Some(&off)).expect("a gap");
12118        assert!(gap.reason.contains("automatic login is off"), "{gap:?}");
12119        assert!(gap.reason.contains("Automatically log in as"), "{gap:?}");
12120
12121        let locked = UnattendedLogin {
12122            auto_login: AutoLogin::As("ivan".into()),
12123            filevault_on: Some(true),
12124        };
12125        let gap = unattended_gap(StartMode::Boot, "root", Some(&locked)).expect("a gap");
12126        assert!(gap.reason.contains("FileVault is on"), "{gap:?}");
12127
12128        // An unanswered probe is no gap: `host doctor` says what it could not
12129        // read.
12130        let unread = UnattendedLogin {
12131            auto_login: AutoLogin::As("ivan".into()),
12132            filevault_on: None,
12133        };
12134        assert_eq!(
12135            unattended_gap(StartMode::Login, "ivan", Some(&unread)),
12136            None
12137        );
12138
12139        // Where nothing can be probed, a login registration still says so.
12140        let gap = unattended_gap(StartMode::Login, "ivan", None).expect("a gap");
12141        assert!(
12142            gap.reason
12143                .contains("does not run until the operator signs in")
12144        );
12145        assert_eq!(unattended_gap(StartMode::Boot, "root", None), None);
12146    }
12147
12148    /// A daemon blocked in a file system call is still "running" to the
12149    /// service manager; its heartbeat is what says it is stuck.
12150    #[test]
12151    fn a_daemon_that_stopped_beating_makes_the_service_unhealthy() {
12152        let host = Host::new();
12153        let operations = host.operations();
12154        operations
12155            .install(&host.request(StartMode::Login))
12156            .expect("an install at login");
12157        let stalled_problem = |operations: &ServiceOperations| {
12158            operations
12159                .status()
12160                .expect("a status")
12161                .problems()
12162                .iter()
12163                .find(|problem| problem.subject == DAEMON_STALLED_SUBJECT)
12164                .map(|problem| problem.detail.clone())
12165        };
12166        crate::daemon_heartbeat::beat(&operations.paths, Utc::now()).unwrap();
12167        assert_eq!(stalled_problem(&operations), None, "beating");
12168
12169        crate::daemon_heartbeat::beat(
12170            &operations.paths,
12171            Utc::now() - chrono::Duration::minutes(20),
12172        )
12173        .unwrap();
12174        let detail = stalled_problem(&operations).expect("a stall is a problem");
12175        assert!(detail.contains("has made no progress since"), "{detail}");
12176        // The restart is the service manager's command for this login-mode
12177        // registration: the product has no `service stop` or `service start`.
12178        let restart = ServiceIdentity::product().restart_command(StartMode::Login);
12179        assert!(detail.contains(&restart), "{detail}");
12180        assert!(!detail.contains("runner-manager service stop"), "{detail}");
12181        assert!(!detail.contains("runner-manager service start"), "{detail}");
12182    }
12183
12184    /// Each platform's restart is its service manager's own command for the
12185    /// registration's start mode, never a `runner-manager service` subcommand
12186    /// that does not exist.
12187    #[test]
12188    fn the_restart_command_is_the_service_managers_own() {
12189        let identity = ServiceIdentity::product();
12190        let boot = identity.restart_command(StartMode::Boot);
12191        let login = identity.restart_command(StartMode::Login);
12192        for command in [&boot, &login] {
12193            assert!(!command.contains("runner-manager service"), "{command}");
12194            assert!(command.contains(identity.name()), "{command}");
12195        }
12196        assert_ne!(boot, login);
12197        if cfg!(windows) {
12198            assert!(
12199                boot.starts_with("Restart-Service -Name runner-manager"),
12200                "{boot}"
12201            );
12202            assert!(
12203                login.contains("schtasks /Run /TN runner-manager"),
12204                "{login}"
12205            );
12206        } else if cfg!(target_os = "macos") {
12207            assert!(
12208                boot.starts_with("sudo launchctl kickstart -k system/"),
12209                "{boot}"
12210            );
12211            assert!(login.starts_with("launchctl kickstart -k gui/"), "{login}");
12212        } else {
12213            assert_eq!(boot, "sudo systemctl restart runner-manager.service");
12214            assert_eq!(login, "systemctl --user restart runner-manager.service");
12215        }
12216    }
12217
12218    /// A daemon refusing every runner is not a healthy service.
12219    #[test]
12220    fn a_daemon_that_finds_the_host_unfit_makes_the_service_unhealthy() {
12221        let host = Host::new();
12222        let operations = host.operations();
12223        operations
12224            .install(&host.request(StartMode::Login))
12225            .expect("an install at login");
12226        let paths = operations.paths.clone();
12227        crate::host_fitness::record_host_unfit(
12228            &paths,
12229            &[crate::host_fitness::UnfitFinding {
12230                id: "host.runner_root_responsive".into(),
12231                detail: "macOS has not let the service list /Volumes/NVME/runners".into(),
12232                remedy: Some("click Allow".into()),
12233            }],
12234            Utc::now(),
12235        )
12236        .unwrap();
12237        let status = operations.status().expect("a status");
12238        assert!(!status.is_healthy(), "{status}");
12239        let problem = status
12240            .problems()
12241            .iter()
12242            .find(|problem| problem.subject == HOST_UNFIT_SUBJECT)
12243            .expect("the refusal is a problem");
12244        assert!(
12245            problem.detail.contains("/Volumes/NVME/runners"),
12246            "{problem:?}"
12247        );
12248        assert!(problem.detail.contains("Fix: click Allow"), "{problem:?}");
12249
12250        // A record nobody has re-stamped for a while is a stopped daemon's,
12251        // not a refusal in force.
12252        crate::host_fitness::clear_host_unfit(&paths).unwrap();
12253        crate::host_fitness::record_host_unfit(
12254            &paths,
12255            &[],
12256            Utc::now() - chrono::Duration::hours(2),
12257        )
12258        .unwrap();
12259        let status = operations.status().expect("a status");
12260        assert!(
12261            !status
12262                .problems()
12263                .iter()
12264                .any(|problem| problem.subject == HOST_UNFIT_SUBJECT),
12265            "{status}"
12266        );
12267    }
12268
12269    // -----------------------------------------------------------------------
12270    // Start and stop
12271    // -----------------------------------------------------------------------
12272
12273    #[test]
12274    fn start_and_stop_reach_the_domain_that_holds_the_registration() {
12275        let host = Host::new();
12276        let operations = host.operations();
12277        operations
12278            .install(&host.request(StartMode::Login))
12279            .expect("an install at login");
12280        operations.start().expect("a start");
12281        assert!(operations.status().expect("a status").is_running());
12282        assert!(operations.stop().expect("a stop"));
12283        assert!(!operations.status().expect("a status").is_running());
12284    }
12285
12286    #[test]
12287    fn starting_a_host_with_no_registration_is_refused() {
12288        let host = Host::new();
12289        let error = host.operations().start().expect_err("nothing to start");
12290        assert!(
12291            matches!(error, ServiceError::NotInstalled { .. }),
12292            "{error}"
12293        );
12294    }
12295
12296    // -----------------------------------------------------------------------
12297    // The seam with `d2`
12298    // -----------------------------------------------------------------------
12299
12300    /// Requirement 2 is *"an account that can **read the secret store**"*, and
12301    /// on Windows what an account may read is decided by a DACL in a file this
12302    /// task does not own. Asserting the account name alone would be asserting
12303    /// this module against itself; this reads `d2`'s protection back off a real
12304    /// store and checks that it admits the account this installer registers.
12305    ///
12306    /// No privileges are needed. `d2` documents that a rooted machine-scoped
12307    /// store *"is protected and encrypted exactly as the standard one is"* —
12308    /// the Windows backend picks its DACL from the scope, not from the site —
12309    /// so a store in a temporary directory carries the access control the real
12310    /// one does.
12311    #[cfg(windows)]
12312    #[test]
12313    fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
12314        use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
12315
12316        let root = tempfile::tempdir().expect("a temporary directory");
12317        let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
12318            .expect("a rooted machine-scoped store");
12319        store
12320            .store(&secrecy::SecretString::from("a stand-in for the token"))
12321            .expect("the store accepts a value");
12322        let protection = store.protection().expect("the DACL can be read back");
12323
12324        assert!(
12325            protection.description().contains(";;;SY)"),
12326            "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
12327             read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
12328            protection.description()
12329        );
12330        assert_eq!(
12331            ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
12332            ServiceAccount::LocalSystem,
12333            "and that is the account this installer registers, which is why SY is what matters"
12334        );
12335        assert!(
12336            !protection.readable_by_other_local_users(),
12337            "the same DACL must still exclude ordinary local users: {}",
12338            protection.description()
12339        );
12340
12341        // The other half of the analysis in `docs/service-account.md`, and the
12342        // reason LocalService and NetworkService are not used: the DACL names
12343        // three trustees and neither of them is among them.
12344        for rejected in [";;;LS)", ";;;NS)"] {
12345            assert!(
12346                !protection.description().contains(rejected),
12347                "if the store ever admitted {rejected}, the least-privilege analysis in \
12348                 docs/service-account.md would need redoing: {}",
12349                protection.description()
12350            );
12351        }
12352    }
12353
12354    // -- another installation's service ---------------------------------------
12355
12356    /// A request that records `host`'s own four directories on the command
12357    /// line, the way `service install` registers one.
12358    fn request_with_directories(host: &Host, mode: StartMode) -> InstallRequest {
12359        let mut arguments: Vec<OsString> = DAEMON_ARGUMENTS.iter().map(OsString::from).collect();
12360        arguments.extend(ServiceDirectories::of(&host.paths).arguments());
12361        host.request(mode).with_arguments(arguments)
12362    }
12363
12364    /// The operations a second account on the same machine gets: its own
12365    /// directories, and the machine's one service manager.
12366    fn second_account(account: &Host, machine: &Host) -> ServiceOperations {
12367        ServiceOperations::with_controls(
12368            account.paths.clone(),
12369            ServiceIdentity::product(),
12370            std::sync::Arc::new(machine.controls.clone()),
12371        )
12372        .with_runner_root(account.runner_root.clone())
12373    }
12374
12375    /// The owner's boot service, and a second account holding the record of
12376    /// an earlier login install of its own -- the layout an owner reported on
12377    /// a Windows host with two local accounts.
12378    fn a_machine_with_a_stale_second_account() -> (Host, Host) {
12379        let owner = Host::new();
12380        owner
12381            .operations()
12382            .install(&request_with_directories(&owner, StartMode::Boot))
12383            .expect("the owner's boot install");
12384        let earlier = Host::new();
12385        earlier
12386            .operations()
12387            .install(&request_with_directories(&earlier, StartMode::Login))
12388            .expect("the second account's earlier login install");
12389        assert!(
12390            InstallRecord::read(&earlier.paths).unwrap().is_some(),
12391            "the earlier install left its record behind"
12392        );
12393        (owner, earlier)
12394    }
12395
12396    /// The advice that, followed from the second account, breaks or replaces
12397    /// the owner's healthy service.
12398    const DESTRUCTIVE_ADVICE: [&str; 4] = [
12399        "service uninstall",
12400        "service install`",
12401        "service install --",
12402        "auth login",
12403    ];
12404
12405    #[test]
12406    fn a_service_installed_from_another_data_root_is_reported_as_that_installations() {
12407        let (owner, earlier) = a_machine_with_a_stale_second_account();
12408
12409        let status = second_account(&earlier, &owner).status().expect("a status");
12410
12411        let foreign = status.foreign().expect("the service is the owner's");
12412        assert_eq!(foreign.directories, ServiceDirectories::of(&owner.paths));
12413        assert_eq!(foreign.root, ServiceDirectories::of(&owner.paths).root());
12414        assert!(foreign.unreadable.is_none(), "{status}");
12415        assert!(
12416            status.is_healthy(),
12417            "the owner's service is healthy, and a stale record elsewhere is not its fault: \
12418             {status}"
12419        );
12420        for subject in [
12421            "binary",
12422            "start mode",
12423            "secret store",
12424            "record",
12425            "registration",
12426        ] {
12427            assert!(
12428                !status
12429                    .problems()
12430                    .iter()
12431                    .any(|problem| problem.subject == subject),
12432                "{subject} compared the second account's record against the owner's \
12433                 registration: {status}"
12434            );
12435        }
12436        let rendered = status.to_string();
12437        for advice in DESTRUCTIVE_ADVICE {
12438            assert!(!rendered.contains(advice), "{advice}: {rendered}");
12439        }
12440        assert!(rendered.contains("another installation's"), "{rendered}");
12441        assert!(
12442            status
12443                .notes()
12444                .iter()
12445                .any(|note| note.contains("unused local configuration")
12446                    && note.contains("host forget-local")),
12447            "{status}"
12448        );
12449        // What the owner recorded, not what the second account did.
12450        assert_eq!(status.start_mode(), Some(StartMode::Boot));
12451    }
12452
12453    #[test]
12454    fn a_foreign_service_whose_records_cannot_be_read_reports_only_what_the_manager_says() {
12455        let (owner, earlier) = a_machine_with_a_stale_second_account();
12456        // Stands in for a profile this account may not open: there is nothing
12457        // to read where the registration points.
12458        InstallRecord::remove(&owner.paths).expect("the owner's record removed");
12459
12460        let operations = second_account(&earlier, &owner);
12461        let status = operations.status().expect("a status");
12462        let foreign = status.foreign().expect("the service is the owner's");
12463        assert!(foreign.unreadable.is_some(), "{status}");
12464        assert!(
12465            status.is_healthy(),
12466            "running, and nothing it could judge: {status}"
12467        );
12468        let rendered = status.to_string();
12469        for advice in DESTRUCTIVE_ADVICE {
12470            assert!(!rendered.contains(advice), "{advice}: {rendered}");
12471        }
12472        assert!(rendered.contains("running; installed from"), "{rendered}");
12473        assert!(!rendered.contains("last GitHub contact"), "{rendered}");
12474
12475        // Stopped, it is a problem -- with a place to look, not a reinstall.
12476        owner.operations().stop().expect("stopped");
12477        let stopped = operations.status().expect("a status");
12478        assert!(!stopped.is_healthy(), "{stopped}");
12479        let rendered = stopped.to_string();
12480        for advice in DESTRUCTIVE_ADVICE {
12481            assert!(!rendered.contains(advice), "{advice}: {rendered}");
12482        }
12483    }
12484
12485    #[test]
12486    fn this_accounts_own_registration_still_reports_every_mismatch() {
12487        let host = Host::new();
12488        let operations = host.operations();
12489        operations
12490            .install(&request_with_directories(&host, StartMode::Boot))
12491            .expect("an install");
12492        let other = host.binary.with_file_name("someone-elses");
12493        std::fs::write(&other, b"x").expect("writable");
12494        // Something edited the registration, keeping its directories.
12495        host.controls.edit("runner-manager", |registration| {
12496            let mut arguments = split_command_line(&registration.command_line);
12497            arguments[0] = other.to_string_lossy().into_owned();
12498            registration.command_line = arguments
12499                .iter()
12500                .map(|argument| quote_argument(argument))
12501                .collect::<Vec<_>>()
12502                .join(" ");
12503            registration.start_mode = StartMode::Login;
12504        });
12505
12506        let status = operations.status().expect("a status");
12507        assert!(status.foreign().is_none(), "{status}");
12508        assert!(
12509            matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
12510            "{status}"
12511        );
12512        assert!(
12513            status
12514                .problems()
12515                .iter()
12516                .any(|problem| problem.subject == "start mode"),
12517            "{status}"
12518        );
12519        assert!(status.to_string().contains("service uninstall"), "{status}");
12520    }
12521
12522    #[test]
12523    fn directories_read_back_from_a_command_line_with_spaces() {
12524        let root = Path::new("/Users/someone/Library/Application Support/runner manager");
12525        let directories = ServiceDirectories {
12526            config: root.join("config"),
12527            state: root.join("data").join("state"),
12528            runtime: root.join("data").join("runtime"),
12529            logs: root.join("data").join("logs"),
12530        };
12531        let mut command_line = quote_argument("/usr/local/bin/runner-manager");
12532        for argument in ["daemon", "run"]
12533            .map(OsString::from)
12534            .into_iter()
12535            .chain(directories.arguments())
12536        {
12537            command_line.push(' ');
12538            command_line.push_str(&quote_argument(&argument.to_string_lossy()));
12539        }
12540        assert_eq!(
12541            ServiceDirectories::from_command_line(&command_line),
12542            Some(directories.clone())
12543        );
12544        assert_eq!(directories.root(), root);
12545        assert_eq!(
12546            ServiceDirectories::from_command_line("runner-manager daemon run"),
12547            None,
12548            "a registration that names no directories cannot be judged foreign"
12549        );
12550    }
12551
12552    #[test]
12553    fn the_owner_of_a_data_root_is_read_from_its_path() {
12554        let users = Path::new(if cfg!(windows) { r"C:\Users" } else { "/home" });
12555        let home = users.join("ivand");
12556        assert_eq!(
12557            data_root_owner(&users.join("yuriv").join("data"), Some(&home)).as_deref(),
12558            Some("yuriv")
12559        );
12560        assert_eq!(
12561            data_root_owner(
12562                Path::new(if cfg!(windows) { r"D:\rm" } else { "/opt/rm" }),
12563                Some(&home)
12564            ),
12565            None
12566        );
12567        if cfg!(windows) {
12568            assert_eq!(
12569                data_root_owner(
12570                    Path::new(r"C:\Windows\System32\config\systemprofile\AppData"),
12571                    Some(&home)
12572                )
12573                .as_deref(),
12574                Some("LocalSystem")
12575            );
12576        } else {
12577            assert_eq!(
12578                data_root_owner(Path::new("/root/.config/runner-manager"), Some(&home)).as_deref(),
12579                Some("root")
12580            );
12581        }
12582    }
12583
12584    #[test]
12585    fn forget_local_archives_the_unused_record_and_never_touches_the_service_root() {
12586        let (owner, earlier) = a_machine_with_a_stale_second_account();
12587        let owner_before = snapshot(&[owner._root.path()]);
12588        let config = earlier.paths.config_dir().to_path_buf();
12589        let record = std::fs::read(InstallRecord::path(&earlier.paths)).unwrap();
12590
12591        let forgotten = second_account(&earlier, &owner)
12592            .forget_local(Utc::now())
12593            .expect("archived");
12594
12595        assert_eq!(
12596            snapshot(&[owner._root.path()]),
12597            owner_before,
12598            "the owner's directories must not change by one byte"
12599        );
12600        let archive = forgotten.archive.expect("something was archived");
12601        assert!(archive.starts_with(&config), "{}", archive.display());
12602        assert_eq!(
12603            std::fs::read(archive.join(RECORD_FILE)).unwrap(),
12604            record,
12605            "moved, not rewritten or deleted"
12606        );
12607        assert!(InstallRecord::read(&earlier.paths).unwrap().is_none());
12608        assert_eq!(
12609            forgotten.service_root,
12610            Some(ServiceDirectories::of(&owner.paths).root())
12611        );
12612        // Afterwards the second account's status has no unused record to note.
12613        let status = second_account(&earlier, &owner).status().expect("status");
12614        assert!(
12615            !status
12616                .notes()
12617                .iter()
12618                .any(|note| note.contains("unused local configuration")),
12619            "{status}"
12620        );
12621    }
12622
12623    #[test]
12624    fn forget_local_refuses_a_configuration_a_service_runs_against() {
12625        let (owner, earlier) = a_machine_with_a_stale_second_account();
12626        let before = snapshot(&[owner._root.path()]);
12627        let refused = owner.operations().forget_local(Utc::now());
12628        assert!(
12629            matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
12630            "{refused:?}"
12631        );
12632        assert_eq!(snapshot(&[owner._root.path()]), before);
12633
12634        // Nor while an agent holds the second account's own lock.
12635        let _agent = HostLock::try_acquire(&earlier.paths, LockKind::SingleInstance)
12636            .expect("the agent's lock");
12637        let refused = second_account(&earlier, &owner).forget_local(Utc::now());
12638        assert!(
12639            matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
12640            "{refused:?}"
12641        );
12642        assert!(InstallRecord::read(&earlier.paths).unwrap().is_some());
12643    }
12644
12645    #[test]
12646    fn a_readable_foreign_service_keeps_its_diagnoses_and_loses_its_remedies() {
12647        let (owner, earlier) = a_machine_with_a_stale_second_account();
12648        // The owner's service has every problem whose remedy acts on the
12649        // directories of whoever runs it.
12650        owner.operations().stop().expect("stopped");
12651        record_github_credential_rejected(&owner.paths, Utc::now()).expect("rejected");
12652        record_credential_unreadable(&owner.paths, Utc::now()).expect("unreadable");
12653
12654        let status = second_account(&earlier, &owner).status().expect("a status");
12655        assert!(status.foreign().is_some_and(|foreign| !foreign.is_unread()));
12656        let subjects: Vec<_> = status.problems().iter().map(|p| p.subject).collect();
12657        for subject in [
12658            "runtime",
12659            "github credential",
12660            CREDENTIAL_UNREADABLE_SUBJECT,
12661        ] {
12662            assert!(subjects.contains(&subject), "{subject}: {status}");
12663        }
12664        let rendered = status.to_string();
12665        for advice in DESTRUCTIVE_ADVICE {
12666            assert!(!rendered.contains(advice), "{advice}: {rendered}");
12667        }
12668        for problem in status.problems() {
12669            assert!(problem.detail.contains("Act on it"), "{problem}");
12670        }
12671    }
12672
12673    #[test]
12674    fn no_command_of_this_account_changes_another_installations_service() {
12675        let (owner, earlier) = a_machine_with_a_stale_second_account();
12676        let operations = second_account(&earlier, &owner);
12677        let foreign = |result: Result<(), ServiceError>| {
12678            assert!(
12679                matches!(result, Err(ServiceError::ForeignInstallation { .. })),
12680                "{result:?}"
12681            );
12682        };
12683        foreign(
12684            operations
12685                .install(&request_with_directories(&earlier, StartMode::Boot))
12686                .map(drop),
12687        );
12688        foreign(operations.uninstall().map(drop));
12689        foreign(operations.set_start_mode(StartMode::Boot).map(drop));
12690        foreign(operations.start());
12691        foreign(operations.stop().map(drop));
12692
12693        let registration = owner
12694            .controls
12695            .control(StartMode::Boot)
12696            .unwrap()
12697            .query(&ServiceIdentity::product())
12698            .unwrap()
12699            .expect("the owner's registration is still there");
12700        assert_eq!(
12701            ServiceDirectories::from_command_line(&registration.command_line),
12702            Some(ServiceDirectories::of(&owner.paths)),
12703            "and still runs against the owner's directories"
12704        );
12705        assert!(registration.running, "and was never stopped");
12706        assert!(
12707            InstallRecord::read(&earlier.paths).unwrap().is_some(),
12708            "the refused uninstall kept this account's record"
12709        );
12710    }
12711
12712    #[test]
12713    fn forget_local_leaves_agent_directories_nested_in_the_configuration_alone() {
12714        // macOS puts state, runtime and logs inside the configuration
12715        // directory: `config_local_dir` and `data_local_dir` are one folder.
12716        let root = tempfile::tempdir().unwrap();
12717        let config = root.path().join("Application Support");
12718        let paths = AppPaths::from_directories(
12719            &config,
12720            config.join("state"),
12721            config.join("runtime"),
12722            config.join("logs"),
12723        );
12724        paths.create_all().unwrap();
12725        std::fs::write(config.join(RECORD_FILE), b"an earlier install").unwrap();
12726        std::fs::write(paths.logs_dir().join("runner-manager.log"), b"log").unwrap();
12727        let operations = ServiceOperations::with_controls(
12728            paths.clone(),
12729            ServiceIdentity::product(),
12730            std::sync::Arc::new(RecordingControls::new()),
12731        );
12732
12733        let forgotten = operations.forget_local(Utc::now()).expect("archived");
12734
12735        assert_eq!(forgotten.moved, vec![config.join(RECORD_FILE)]);
12736        for kept in [paths.state_dir(), paths.runtime_dir(), paths.logs_dir()] {
12737            assert!(kept.is_dir(), "{} was moved", kept.display());
12738        }
12739        assert!(paths.logs_dir().join("runner-manager.log").is_file());
12740    }
12741
12742    #[test]
12743    fn a_remedy_is_cut_from_every_detail_a_builder_writes() {
12744        for (detail, diagnosis) in [
12745            (
12746                "x holds an automatic registration, but the daemon is stopped. Run `runner-manager \
12747                 service install --start-at boot`; if the service manager denies replacement",
12748                "x holds an automatic registration, but the daemon is stopped",
12749            ),
12750            (
12751                "store -- MISMATCH. Run `auth login` again so the token is stored",
12752                "store -- MISMATCH",
12753            ),
12754            (
12755                "unit differs, so the daemon runs under outdated settings. Re-register it with \
12756                 `sudo runner-manager service install --start-at boot`",
12757                "unit differs, so the daemon runs under outdated settings",
12758            ),
12759            (
12760                "it starts no runner; the log has it. Sign in again: `runner-manager auth login`",
12761                "it starts no runner; the log has it",
12762            ),
12763            (
12764                "C:\\a\\runner-manager.exe -- STALE: moved. Run `service uninstall` and `service \
12765                 install`",
12766                "C:\\a\\runner-manager.exe -- STALE: moved",
12767            ),
12768            (
12769                "a new builder: run `runner-manager service install` here, or not",
12770                "the installation's own records report this",
12771            ),
12772        ] {
12773            assert_eq!(diagnosis_only(detail), diagnosis, "{detail}");
12774        }
12775    }
12776}