Skip to main content

runner_manager_platform/
service.rs

1// owner: d3-service-installers
2
3//! Registering `daemon run` with the operating system, so that a home host
4//! resumes work by itself after a reboot with nobody logged in.
5//!
6//! Journey 5 is the whole of the requirement: *"the machine reboots with nobody
7//! logged in; the boot-start service starts the agent, which reads the user
8//! access token from the machine-scoped secret store"*. Everything in this file
9//! exists to make that sentence true on Windows, macOS, and Linux, and to make
10//! `service status` say so — or say precisely why not.
11//!
12//! # The shape: render, then apply
13//!
14//! A service manager is the one dependency a `cargo test` run cannot have. So
15//! this module is split in two, and the split is the reason most of it is
16//! testable on a developer's laptop whatever OS that laptop runs:
17//!
18//! * **Rendering** is pure. [`ServiceDefinition`] turns an [`InstallPlan`] into
19//!   the exact text the platform consumes — a systemd unit, a launchd property
20//!   list, a Task Scheduler XML document, or a canonical descriptor of the
21//!   Windows service parameters. No `cfg`, no privileges, no I/O. A Windows
22//!   developer renders and asserts the systemd unit; a Linux CI leg renders and
23//!   asserts the launchd plist.
24//! * **Applying** is behind [`ServiceControl`], one trait with a backend per
25//!   platform and a public in-memory double ([`RecordingControls`]) that `f3`
26//!   and this crate's own tests drive without touching the host.
27//!
28//! [`ServiceOperations`] is the layer above both, and it is where the logic
29//! that is *not* platform-specific lives: refusing an install while the
30//! single-instance lock is held, recording the resolved absolute binary path,
31//! detecting a stale one, switching start mode without reinstalling, and
32//! uninstalling without deleting a byte of configuration, secrets, or cache.
33//!
34//! # Two domains, not two products
35//!
36//! `--start-at boot` and `--start-at login` are the same daemon registered in
37//! two different *domains*:
38//!
39//! | | boot | login |
40//! |---|---|---|
41//! | Windows | a service in the Service Control Manager, `LocalSystem` | a Task Scheduler task with a logon trigger, running at `LeastPrivilege` |
42//! | macOS | a LaunchDaemon in `/Library/LaunchDaemons` | a LaunchAgent in `~/Library/LaunchAgents` |
43//! | Linux | a system unit in `/etc/systemd/system` | a user unit in `~/.config/systemd/user` |
44//!
45//! The Windows row is the one that is not symmetric, and it is worth saying why
46//! rather than leaving a reader to wonder. **Windows services cannot start at
47//! logon.** Service trigger-start covers domain join, an IP address becoming
48//! available, a device arriving, a firewall port event and a group-policy
49//! change; there is no logon trigger, and there is no user-session service
50//! type this product could use instead. Task Scheduler is the mechanism Windows
51//! actually provides for "run this when the operator signs in", so that is what
52//! `--start-at login` uses there. It is registered, inspected, and removed
53//! through the same [`ServiceControl`] trait as everything else, so nothing
54//! above this module has to know.
55//!
56//! # What the account can reach, and why it is not more
57//!
58//! `05-infrastructure.md` requires *"a least-privilege account that can read the
59//! machine-scoped secret store and write its configured cache and runtime
60//! directories"*. Those two clauses pull in opposite directions on every
61//! platform, and the resolution is recorded per platform in
62//! `docs/service-account.md` and checked by [`review_least_privilege`], which
63//! reads the rendered definition back and reports anything it grants beyond the
64//! requirement.
65//!
66//! The Windows resolution is the one that surprises people. `d2` protects the
67//! machine-scoped store with `D:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;OW)` and
68//! documents that the DACL *is* the access control, because a machine-scope
69//! DPAPI blob is decryptable by any process on the host. `NT AUTHORITY\
70//! LocalService` and `NT AUTHORITY\NetworkService` — the two accounts a
71//! "least privilege service" reflex reaches for — are named by none of those
72//! three ACEs and therefore **cannot read the token at all**. Widening the DACL
73//! to reach them would grant every service on the host read access to the one
74//! credential this product holds, which is strictly worse than running as
75//! `LocalSystem`. `secrets.rs` is not this task's file and is not widened; the
76//! service runs as `LocalSystem`, and [`review_least_privilege`] records the
77//! account together with the reason it is the minimum that satisfies the
78//! requirement rather than pretending it is small.
79//!
80//! # What is deliberately not claimed
81//!
82//! **A reboot is not something a test suite can have.** Every assertion here is
83//! about configuration a boot-time start depends on — the start type, the
84//! account, the recorded absolute path, the restart policy the service manager
85//! reports back — and about a store that is readable outside any login session,
86//! which `d2` proves separately. That the machine actually comes back up and
87//! the agent actually resumes is human gate 3 in `06-migration-rollout.md`, and
88//! nothing in this file is evidence for it.
89
90use std::collections::{BTreeMap, BTreeSet};
91use std::ffi::OsString;
92use std::fmt;
93use std::path::{Path, PathBuf};
94use std::time::Duration;
95
96use chrono::{DateTime, Utc};
97use runner_manager_domain::model::StartMode;
98use runner_manager_domain::path::LocalAbsolutePath;
99use serde::{Deserialize, Serialize};
100
101use crate::lock::{HostLock, LockError, LockKind};
102use crate::paths::AppPaths;
103#[cfg(windows)]
104use crate::runner_root_access::RootAdmission;
105use crate::runner_root_access::{
106    Reversal, RootAccessChange, RootAccessError, RootAccessReport, RootAccessSummary,
107};
108
109// ---------------------------------------------------------------------------
110// Names
111// ---------------------------------------------------------------------------
112
113/// The product's own service name, on every platform that wants a short one.
114pub const SERVICE_NAME: &str = "runner-manager";
115/// Hidden CLI marker carried only by Windows boot-service registrations.
116///
117/// Application-data arguments are shared by every service manager and cannot
118/// distinguish SCM from Task Scheduler. This marker is the durable routing
119/// contract between the Windows installer and the shipping binary.
120pub const WINDOWS_SCM_HOST_ARGUMENT: &str = "--windows-service-host";
121
122/// What an operator sees in `services.msc`, `launchctl list`, or
123/// `systemctl status`.
124pub const DISPLAY_NAME: &str = "GitHub Actions Runner Manager";
125
126/// One line of explanation, for the same three places.
127pub const DESCRIPTION: &str =
128    "Starts ephemeral GitHub Actions self-hosted runners on this machine on demand.";
129
130/// The arguments the installed command line carries.
131///
132/// `05-infrastructure.md`: *"`service install` registers `daemon run` for the
133/// current host"*. Spelled once, here, so the installer and `f3`'s command
134/// surface cannot disagree about what was registered.
135pub const DAEMON_ARGUMENTS: [&str; 2] = ["daemon", "run"];
136
137/// The file `install` writes and `status` reads, inside `config/`.
138///
139/// `config/` and not `state/`: `05-infrastructure.md` gives `config/` to
140/// *"non-secret TOML"*, and this is exactly that — a record of what was
141/// registered, holding no credential. It is also the one file `uninstall`
142/// removes, which is what keeps "uninstall deletes no configuration" honest:
143/// the record is not configuration, it is the registration's own footprint.
144pub const RECORD_FILE: &str = "service.toml";
145
146/// The file the daemon touches after every successful GitHub call, inside
147/// `state/`.
148///
149/// See [`record_github_contact`] for the contract; `service status` reads it
150/// and Journey 5 step 4 requires it.
151pub const CONTACT_FILE: &str = "github-contact.toml";
152
153/// Present while GitHub is rejecting the daemon's credential, inside `state/`.
154///
155/// See [`record_github_credential_rejected`] for the contract.
156pub const CREDENTIAL_REJECTION_FILE: &str = "github-credential-rejected.toml";
157
158/// Present while the daemon cannot read its own stored credential, inside
159/// `state/`.
160///
161/// See [`record_credential_unreadable`] for the contract.
162pub const CREDENTIAL_UNREADABLE_FILE: &str = "credential-unreadable.toml";
163
164/// The `service status` subject a [`CREDENTIAL_UNREADABLE_FILE`] is reported
165/// under, which the TUI matches to offer the same command.
166pub const CREDENTIAL_UNREADABLE_SUBJECT: &str = "stored credential";
167
168/// The `service status` subject blocked runner launches are reported under.
169pub const LAUNCHES_BLOCKED_SUBJECT: &str = "runner launches";
170
171/// The [`StatusProblem::subject`] of a daemon that has stopped making progress.
172pub const DAEMON_STALLED_SUBJECT: &str = "daemon progress";
173
174/// The [`StatusProblem::subject`] of a daemon that refuses to start runners
175/// because a required host check fails from its own session.
176pub const HOST_UNFIT_SUBJECT: &str = "host fitness";
177
178/// The agent's last runner-root refusal, for `service status` to report.
179///
180/// See [`record_runner_root_refusal`] for the contract.
181pub const ROOT_REFUSAL_FILE: &str = "runner-root-refusal.toml";
182
183/// The rotating diagnostic log the **daemon** writes, inside `logs/`.
184///
185/// The appender adds its own date suffix, so this is the stem rather than a
186/// file that exists. `service status` reports the directory and the stem,
187/// because that is what an operator needs in order to find today's file and
188/// yesterday's.
189///
190/// It is [`crate::logging::SERVICE_LOG_STEM`] and not the operator's, because a
191/// boot-mode daemon runs under a different account and writes a different file.
192/// [`crate::logging::LogRole`] says why the two were separated.
193pub const LOG_FILE_STEM: &str = crate::logging::SERVICE_LOG_STEM;
194
195// ---------------------------------------------------------------------------
196// Identity
197// ---------------------------------------------------------------------------
198
199/// What the operating system calls this registration.
200///
201/// A type rather than three constants, for one reason that is not tidiness: the
202/// privileged installer tests register a **real** service on a **real** machine,
203/// and they must not be able to collide with — or remove — an operator's
204/// installation. [`ServiceIdentity::fixture`] produces a name that is
205/// unmistakably a test artefact, and every backend takes its name from here, so
206/// there is no path by which a test reaches the product's own registration.
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct ServiceIdentity {
209    name: String,
210    display_name: String,
211    description: String,
212}
213
214impl ServiceIdentity {
215    /// The registration an operator installs.
216    #[must_use]
217    pub fn product() -> Self {
218        Self {
219            name: SERVICE_NAME.to_string(),
220            display_name: DISPLAY_NAME.to_string(),
221            description: DESCRIPTION.to_string(),
222        }
223    }
224
225    /// A disposable registration for a privileged test, named so that it cannot
226    /// be mistaken for — or collide with — [`ServiceIdentity::product`].
227    ///
228    /// `tag` distinguishes concurrent runs; it is reduced to ASCII alphanumerics
229    /// and `-` so that it is a legal service name, launchd label, systemd unit
230    /// name, and Task Scheduler task name at once.
231    #[must_use]
232    pub fn fixture(tag: &str) -> Self {
233        let tag: String = tag
234            .chars()
235            .map(|c| {
236                if c.is_ascii_alphanumeric() {
237                    c.to_ascii_lowercase()
238                } else {
239                    '-'
240                }
241            })
242            .collect();
243        let name = format!("{SERVICE_NAME}-selftest-{tag}");
244        Self {
245            display_name: format!("{DISPLAY_NAME} (self-test fixture {tag})"),
246            description: "Disposable fixture created by runner-manager's own installer tests. \
247                          Safe to remove."
248                .to_string(),
249            name,
250        }
251    }
252
253    /// Whether this identity is a disposable test fixture.
254    ///
255    /// The privileged tests assert this before they delete anything, which is
256    /// the mechanical half of *"never touch a service you did not create"*.
257    #[must_use]
258    pub fn is_fixture(&self) -> bool {
259        self.name.starts_with(&format!("{SERVICE_NAME}-selftest-"))
260    }
261
262    /// The Windows service name, the systemd unit stem, and the Task Scheduler
263    /// task name.
264    #[must_use]
265    pub fn name(&self) -> &str {
266        &self.name
267    }
268
269    /// The human-readable name.
270    #[must_use]
271    pub fn display_name(&self) -> &str {
272        &self.display_name
273    }
274
275    /// One line of explanation.
276    #[must_use]
277    pub fn description(&self) -> &str {
278        &self.description
279    }
280
281    /// The launchd label — reverse-domain, as launchd expects.
282    ///
283    /// Built from the same three product segments [`crate::paths`] resolves the
284    /// application-data directories with, so a label and a directory cannot
285    /// drift apart.
286    #[must_use]
287    pub fn launchd_label(&self) -> String {
288        format!(
289            "{}.{}.{}",
290            crate::paths::QUALIFIER,
291            crate::paths::ORGANIZATION,
292            self.name
293        )
294    }
295
296    /// The systemd unit file name.
297    #[must_use]
298    pub fn systemd_unit(&self) -> String {
299        format!("{}.service", self.name)
300    }
301
302    /// The service manager's own command that restarts this registration.
303    ///
304    /// The product has no `service stop` or `service start`, and
305    /// `service install` is no restart either: it refuses while the running
306    /// daemon holds the agent lock. So a remedy that restarts the daemon names
307    /// the service manager's command. A forced restart does not wait for jobs;
308    /// it is for a daemon that has stopped making progress anyway.
309    #[must_use]
310    pub fn restart_command(&self, mode: StartMode) -> String {
311        let name = &self.name;
312        if cfg!(windows) {
313            match mode {
314                StartMode::Boot => {
315                    format!("Restart-Service -Name {name}")
316                }
317                StartMode::Login => format!("schtasks /End /TN {name}; schtasks /Run /TN {name}"),
318            }
319        } else if cfg!(target_os = "macos") {
320            let label = self.launchd_label();
321            match mode {
322                StartMode::Boot => format!("sudo launchctl kickstart -k system/{label}"),
323                StartMode::Login => format!("launchctl kickstart -k gui/$(id -u)/{label}"),
324            }
325        } else {
326            let unit = self.systemd_unit();
327            match mode {
328                StartMode::Boot => format!("sudo systemctl restart {unit}"),
329                StartMode::Login => format!("systemctl --user restart {unit}"),
330            }
331        }
332    }
333}
334
335impl fmt::Display for ServiceIdentity {
336    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
337        f.write_str(&self.name)
338    }
339}
340
341// ---------------------------------------------------------------------------
342// Restart policy
343// ---------------------------------------------------------------------------
344
345/// The restart-on-failure policy, and the bound on how fast it may retry.
346///
347/// `05-infrastructure.md` item 3: *"set a restart-on-failure policy with bounded
348/// delay"*. Both halves matter and they are different requirements. A service
349/// that does not come back after a crash defeats Journey 5; a service that comes
350/// back instantly, forever, turns one bad configuration into a fork bomb against
351/// GitHub's rate limit. So the delay has a floor as well as a ceiling, and the
352/// floor is what `does not restart-loop faster than that bound` is measured
353/// against.
354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355pub struct RestartPolicy {
356    delay: Duration,
357    reset_after: Duration,
358}
359
360impl RestartPolicy {
361    /// The shortest delay this will accept.
362    ///
363    /// One second is not a tuning preference. Below it, launchd's own throttle
364    /// and systemd's start-limit logic both take over and the configured number
365    /// stops being the number in force, so a policy under this floor would be a
366    /// value `service status` reported and the platform ignored.
367    pub const MIN_DELAY: Duration = Duration::from_secs(1);
368
369    /// The longest delay this will accept. Past five minutes an operator
370    /// watching a restart would reasonably conclude the service is simply gone.
371    pub const MAX_DELAY: Duration = Duration::from_secs(300);
372
373    /// What `service install` uses when nothing is said.
374    pub const DEFAULT_DELAY: Duration = Duration::from_secs(15);
375
376    /// How long the service must stay up before its failure count is forgotten.
377    pub const DEFAULT_RESET_AFTER: Duration = Duration::from_secs(600);
378
379    /// # Errors
380    ///
381    /// [`ServiceError::RestartDelay`] when `delay` is outside
382    /// [`RestartPolicy::MIN_DELAY`]..=[`RestartPolicy::MAX_DELAY`], or when
383    /// `reset_after` is not longer than `delay` — a reset window shorter than
384    /// the delay can never elapse between two restarts, so the failure count
385    /// would reset on every attempt and no start-limit could ever trip.
386    pub fn new(delay: Duration, reset_after: Duration) -> Result<Self, ServiceError> {
387        if delay < Self::MIN_DELAY || delay > Self::MAX_DELAY {
388            return Err(ServiceError::RestartDelay {
389                requested_secs: delay.as_secs(),
390                min_secs: Self::MIN_DELAY.as_secs(),
391                max_secs: Self::MAX_DELAY.as_secs(),
392            });
393        }
394        if reset_after <= delay {
395            return Err(ServiceError::RestartResetWindow {
396                reset_secs: reset_after.as_secs(),
397                delay_secs: delay.as_secs(),
398            });
399        }
400        Ok(Self { delay, reset_after })
401    }
402
403    /// How long the service manager waits before restarting a failed service.
404    #[must_use]
405    pub const fn delay(&self) -> Duration {
406        self.delay
407    }
408
409    /// How long the service must run before its failure count resets.
410    #[must_use]
411    pub const fn reset_after(&self) -> Duration {
412        self.reset_after
413    }
414
415    /// The delay a given manager can actually express.
416    ///
417    /// Three of the four take seconds and enforce exactly what they are given.
418    /// **Windows Task Scheduler does not**: `RestartOnFailure/Interval` is
419    /// expressed in whole minutes with a one-minute floor, and it *rejects* the
420    /// registration outright rather than rounding — `PT15S` comes back as
421    /// "The task XML contains a value which is incorrectly formatted or out of
422    /// range", which is how this was found.
423    ///
424    /// So the interval is rounded **up**, never down, and never below one
425    /// minute. The direction is the point: the requirement is that the service
426    /// *"does not restart-loop faster than that bound"*, and a delay longer
427    /// than the configured one still satisfies it, while a shorter one would
428    /// not. `service status` reports the difference as a note so that an
429    /// operator reading `15s` in the record and `60s` from the manager is told
430    /// why rather than left to wonder.
431    #[must_use]
432    pub const fn effective_delay(&self, kind: DefinitionKind) -> Duration {
433        match kind {
434            DefinitionKind::WindowsScheduledTask => {
435                let seconds = self.delay.as_secs();
436                let minutes = seconds.div_ceil(60);
437                Duration::from_secs(if minutes == 0 { 60 } else { minutes * 60 })
438            }
439            _ => self.delay,
440        }
441    }
442}
443
444impl Default for RestartPolicy {
445    fn default() -> Self {
446        Self {
447            delay: Self::DEFAULT_DELAY,
448            reset_after: Self::DEFAULT_RESET_AFTER,
449        }
450    }
451}
452
453impl fmt::Display for RestartPolicy {
454    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
455        write!(
456            f,
457            "restart after {}s, failure count resets after {}s",
458            self.delay.as_secs(),
459            self.reset_after.as_secs()
460        )
461    }
462}
463
464// ---------------------------------------------------------------------------
465// The account
466// ---------------------------------------------------------------------------
467
468/// The account a registration runs under.
469///
470/// Not an operator choice. It is a function of the start mode and the platform,
471/// because the start mode decides which secret store the daemon must read and
472/// the store's own access control decides which accounts can read it. See
473/// `docs/service-account.md`, and [`review_least_privilege`] for the check.
474#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
475#[serde(rename_all = "snake_case")]
476pub enum ServiceAccount {
477    /// `NT AUTHORITY\SYSTEM`. The only stock Windows account named by the
478    /// machine-scoped store's DACL.
479    LocalSystem,
480    /// `root`. What a LaunchDaemon and a systemd system unit run as, and what
481    /// the macOS System Keychain's root-only master key requires.
482    Root,
483    /// The operator's own account, for a login-mode registration.
484    InvokingUser,
485}
486
487impl ServiceAccount {
488    /// The account a given kind of definition obliges.
489    ///
490    /// Keyed on the definition rather than on `cfg!(windows)`, and the
491    /// difference is not academic: a Windows developer rendering the launchd
492    /// property list would otherwise write `UserName = NT AUTHORITY\SYSTEM`
493    /// into it, which is not an account macOS has. A definition's account is a
494    /// property of the definition, and the whole point of rendering being pure
495    /// is that any host can render any platform's and get the right answer.
496    #[must_use]
497    pub const fn for_definition(kind: DefinitionKind, mode: StartMode) -> Self {
498        match (kind, mode) {
499            (DefinitionKind::WindowsService, _) => Self::LocalSystem,
500            (DefinitionKind::WindowsScheduledTask, _) | (_, StartMode::Login) => Self::InvokingUser,
501            (_, StartMode::Boot) => Self::Root,
502        }
503    }
504
505    /// The account the given start mode obliges on the platform this binary was
506    /// built for.
507    #[must_use]
508    pub const fn for_start_mode(mode: StartMode) -> Self {
509        Self::for_definition(host_definition_kind(mode), mode)
510    }
511
512    /// How the platform spells it.
513    #[must_use]
514    pub const fn as_str(&self) -> &'static str {
515        match self {
516            Self::LocalSystem => "NT AUTHORITY\\SYSTEM",
517            Self::Root => "root",
518            Self::InvokingUser => "the invoking user",
519        }
520    }
521
522    /// Why this is the *minimum* account that can do the job, not merely the
523    /// convenient one.
524    ///
525    /// Printed by `service status` and by [`review_least_privilege`], because a
526    /// privileged account with no stated reason is indistinguishable from a
527    /// privileged account nobody thought about.
528    #[must_use]
529    pub const fn justification(&self) -> &'static str {
530        match self {
531            Self::LocalSystem => {
532                "the machine-scoped store's DACL names SY, BA and OW only; LocalService and \
533                 NetworkService cannot read it, and widening the DACL to reach them would grant \
534                 every service on this host read access to the one credential this product holds"
535            }
536            Self::Root => {
537                "a boot-time registration runs outside every login session: on macOS the System \
538                 Keychain is unlocked by /var/db/SystemKey, which is root-only, and on Linux the \
539                 machine-scoped store is a 0600 file under /var/lib that only root can open \
540                 before a session exists"
541            }
542            Self::InvokingUser => {
543                "a login-mode registration reads the user-scoped store, which is deliberately \
544                 readable by exactly one account and needs no elevation at all"
545            }
546        }
547    }
548
549    /// Whether registering under this account needs administrative rights.
550    #[must_use]
551    pub const fn needs_elevation(&self) -> bool {
552        matches!(self, Self::LocalSystem | Self::Root)
553    }
554}
555
556impl fmt::Display for ServiceAccount {
557    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
558        f.write_str(self.as_str())
559    }
560}
561
562// ---------------------------------------------------------------------------
563// Errors
564// ---------------------------------------------------------------------------
565
566/// Something went wrong installing, inspecting, or removing the registration.
567///
568/// Every variant is formatted straight into an operator-facing message, so each
569/// one says what to do next rather than only what failed. No variant carries a
570/// secret: this module never reads the token, only the *location* `d2` publishes
571/// for exactly this purpose.
572#[derive(Debug, thiserror::Error)]
573pub enum ServiceError {
574    /// The single-instance lock is held, so a second agent must not be
575    /// registered.
576    ///
577    /// `05-infrastructure.md` item 1. The message is `d1`'s, verbatim, because
578    /// `d1` already worked out what to tell an operator who lost that race.
579    #[error("cannot install the service while an agent is already running on this host: {source}")]
580    LockHeld {
581        /// What `d1` said, including who holds it.
582        #[source]
583        source: Box<LockError>,
584    },
585
586    /// The lock could not be inspected at all, which is not the same as it being
587    /// free.
588    #[error(
589        "cannot tell whether an agent is already running on this host, so the service was not \
590         installed: {source}. Fix the reported problem with the state directory and try again."
591    )]
592    LockUnreadable {
593        /// The underlying failure.
594        #[source]
595        source: Box<LockError>,
596    },
597
598    /// The runner root this registration would run jobs under could not be
599    /// created, inspected, or given the access the registration's account needs.
600    ///
601    /// Reported *before* anything is registered. A registration whose workspaces
602    /// would land in a directory ordinary local users can write is a
603    /// registration that should not exist, and `04-security-recovery.md` asks
604    /// for exactly that refusal rather than a warning.
605    #[error(
606        "this registration would run jobs under the default runner root, and that root could \
607         not be prepared, so nothing was registered: {source}"
608    )]
609    RunnerRoot {
610        /// What [`crate::runner_root_access`] reported, including the remedy.
611        #[source]
612        source: Box<RootAccessError>,
613    },
614
615    /// The path of the running binary could not be resolved.
616    #[error(
617        "cannot resolve the absolute path of this executable, so there is nothing to register: \
618         {detail}. Run the installer from the installed binary rather than through a shell \
619         function or a wrapper that replaces argv[0]."
620    )]
621    BinaryPath {
622        /// What the platform reported.
623        detail: String,
624    },
625
626    /// The binary named for the registration is not there.
627    #[error(
628        "{} is not a file, so registering it would create a service that cannot start. Install \
629         the product first, then run `service install` from the installed binary.",
630        path.display()
631    )]
632    BinaryMissing {
633        /// The path that was to be registered.
634        path: PathBuf,
635    },
636
637    /// The requested restart delay is outside the bound.
638    #[error(
639        "a restart delay of {requested_secs}s is outside the supported range \
640         {min_secs}s-{max_secs}s. Below the floor the platform's own throttle overrides the \
641         value, so `service status` would report a delay that is not the one in force."
642    )]
643    RestartDelay {
644        /// What was asked for.
645        requested_secs: u64,
646        /// The floor.
647        min_secs: u64,
648        /// The ceiling.
649        max_secs: u64,
650    },
651
652    /// The failure-count reset window is not longer than the restart delay.
653    #[error(
654        "a failure-count reset window of {reset_secs}s is not longer than the {delay_secs}s \
655         restart delay, so the count would reset between every pair of restarts and no \
656         start limit could ever apply."
657    )]
658    RestartResetWindow {
659        /// What was asked for.
660        reset_secs: u64,
661        /// The delay it has to exceed.
662        delay_secs: u64,
663    },
664
665    /// A registration is already there, in the **other** start mode.
666    ///
667    /// Only the other one. A registration in the mode being asked for is
668    /// replaced rather than refused — see [`Operations::install`], and the
669    /// outage that taught us why.
670    #[error(
671        "{name} is already registered to start at {existing}, and this asks for {requested}. \
672         Changing the start mode moves the registration between two different service \
673         managers, so it is not something an install does by itself: run `service uninstall` \
674         first, or change the start mode in the terminal UI (`runner-manager tui`), which \
675         switches it in place and keeps the registration running throughout."
676    )]
677    AlreadyInstalled {
678        /// Which registration.
679        name: String,
680        /// The mode it currently carries.
681        existing: StartMode,
682        /// The mode this install asked for.
683        requested: StartMode,
684    },
685
686    /// No registration is there.
687    #[error("{name} is not registered on this host, so there is nothing to {operation}.")]
688    NotInstalled {
689        /// Which registration.
690        name: String,
691        /// What was being attempted.
692        operation: &'static str,
693    },
694
695    /// The record `install` wrote could not be read or written.
696    #[error("cannot {operation} the service record {}: {detail}", path.display())]
697    Record {
698        /// Read, write, or remove.
699        operation: &'static str,
700        /// The record file.
701        path: PathBuf,
702        /// The underlying failure.
703        detail: String,
704    },
705
706    /// The record is there but is not one this version wrote.
707    #[error(
708        "the service record {} was not written by this product, or was written by a version \
709         this one cannot read: {detail}. Run `service uninstall` and `service install` again; \
710         neither touches configuration, secrets, or the cache.",
711        path.display()
712    )]
713    RecordUnreadable {
714        /// The record file.
715        path: PathBuf,
716        /// What was wrong with it.
717        detail: String,
718    },
719
720    /// The record is there and **this account** may not read it.
721    ///
722    /// Its own variant rather than a [`ServiceError::Record`] with a
723    /// `Permission denied` in the detail, because it is the one record failure
724    /// that is not a fault in the record: `service status` reports it and
725    /// carries on, where every other read failure ends the command. See
726    /// [`Operations::status`].
727    ///
728    /// A version before this one wrote the record at `0600` through a temporary
729    /// file, so a boot-mode `sudo service install` left it owned by `root` and
730    /// unreadable by the operator whose profile it is in. `service status` then
731    /// failed on their own host, with a remedy naming the command that had just
732    /// failed.
733    #[error(
734        "the service record {} is there and this account may not read it: {detail}. It was \
735         written by whichever account installed the service -- on a boot-mode host, `sudo \
736         service install`. Running `service install` again rewrites it readable; until then \
737         only what the service manager itself reports is available.",
738        path.display()
739    )]
740    RecordNotPermitted {
741        /// The record file.
742        path: PathBuf,
743        /// What the operating system said.
744        detail: String,
745    },
746
747    /// The application-data directories could not be resolved or created.
748    #[error("cannot prepare this host's application-data directories: {source}")]
749    Paths {
750        /// The underlying failure.
751        #[source]
752        source: Box<crate::paths::PathsError>,
753    },
754
755    /// The platform's service manager refused, or could not be reached.
756    #[error("cannot {operation} {name} through {manager}: {detail}")]
757    Control {
758        /// What was being attempted.
759        operation: &'static str,
760        /// Which registration.
761        name: String,
762        /// Which service manager.
763        manager: &'static str,
764        /// What it said.
765        detail: String,
766    },
767
768    /// An operation failed and its compensating action failed too.
769    #[error(
770        "cannot {operation} {name}: {cause}. The attempted rollback also failed: {rollback}. \
771         Inspect `service status` before retrying."
772    )]
773    Rollback {
774        /// The transaction that could not be completed.
775        operation: &'static str,
776        /// Which registration was involved.
777        name: String,
778        /// The original failure.
779        cause: String,
780        /// The failure while restoring the previous state.
781        rollback: String,
782    },
783
784    /// The machine's registration is another installation's (see
785    /// [`ForeignService`]), and changing it from this account would act on it
786    /// with this account's directories.
787    #[error(
788        "this machine's {name} service was installed from {installed_by}, not from this \
789         account's data root, so this account does not {operation} it: doing so would \
790         re-register or remove that installation's service with this account's directories. \
791         Do it {act_as}. If that account no longer exists, remove the registration with the \
792         service manager itself first."
793    )]
794    ForeignInstallation {
795        /// Which registration.
796        name: String,
797        /// What was refused.
798        operation: &'static str,
799        /// [`ForeignService::installed_by`].
800        installed_by: String,
801        /// [`ForeignService::act_as`].
802        act_as: String,
803    },
804
805    /// `host forget-local` found this account's configuration in use, and
806    /// moved nothing.
807    #[error("this account's local configuration is in use, so nothing was archived: {detail}.")]
808    LocalConfigurationInUse {
809        /// What uses it.
810        detail: String,
811    },
812
813    /// The operation needs administrative rights it does not have.
814    #[error("{operation} {name} needs administrative rights: {detail}. {remedy}")]
815    NeedsElevation {
816        /// What was being attempted.
817        operation: &'static str,
818        /// Which registration.
819        name: String,
820        /// What the platform said.
821        detail: String,
822        /// How to get them, in this platform's own terms.
823        remedy: &'static str,
824    },
825}
826
827// ---------------------------------------------------------------------------
828// Directories
829// ---------------------------------------------------------------------------
830
831/// The four directories the registration was installed against.
832///
833/// Recorded rather than re-derived, because `05-infrastructure.md` item 2 speaks
834/// of the *configured* cache and runtime directories: the account the service
835/// runs under and the account that ran `service install` do not always resolve
836/// the same platform-standard locations, and a claim about what the service can
837/// write is only checkable against a specific set of paths.
838#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
839pub struct ServiceDirectories {
840    /// Non-secret TOML and the SQLite database.
841    pub config: PathBuf,
842    /// The agent lock, the attempt journal, and the runner package cache.
843    pub state: PathBuf,
844    /// Per-attempt disposable runner workspaces.
845    pub runtime: PathBuf,
846    /// Rotating redacted diagnostics.
847    pub logs: PathBuf,
848}
849
850impl ServiceDirectories {
851    /// Snapshots the four directories a resolved [`AppPaths`] names.
852    #[must_use]
853    pub fn of(paths: &AppPaths) -> Self {
854        Self {
855            config: paths.config_dir().to_path_buf(),
856            state: paths.state_dir().to_path_buf(),
857            runtime: paths.runtime_dir().to_path_buf(),
858            logs: paths.logs_dir().to_path_buf(),
859        }
860    }
861
862    /// The four, in the order `05-infrastructure.md` lists them.
863    #[must_use]
864    pub fn all(&self) -> [&Path; 4] {
865        [
866            self.config.as_path(),
867            self.state.as_path(),
868            self.runtime.as_path(),
869            self.logs.as_path(),
870        ]
871    }
872
873    /// The rotating diagnostic log's stem, which is what `service status`
874    /// reports and what `05-infrastructure.md` item 4 requires be preserved.
875    #[must_use]
876    pub fn log_file(&self) -> PathBuf {
877        self.logs.join(LOG_FILE_STEM)
878    }
879
880    /// The four as the hidden `daemon run` options a registration passes them
881    /// in, [`SERVICE_DIRECTORY_OPTIONS`] order.
882    #[must_use]
883    pub fn arguments(&self) -> [OsString; 8] {
884        let [config, state, runtime, logs] = SERVICE_DIRECTORY_OPTIONS.map(OsString::from);
885        [
886            config,
887            self.config.clone().into_os_string(),
888            state,
889            self.state.clone().into_os_string(),
890            runtime,
891            self.runtime.clone().into_os_string(),
892            logs,
893            self.logs.clone().into_os_string(),
894        ]
895    }
896
897    /// Reads the four back out of a registration's command line.
898    ///
899    /// `None` unless all four options are there: a registration made before
900    /// they existed starts the daemon against whatever its account's
901    /// platform-standard directories are, and which those are cannot be told
902    /// from here.
903    #[must_use]
904    pub fn from_command_line(command_line: &str) -> Option<Self> {
905        let arguments = split_command_line(command_line);
906        let value = |option: &str| {
907            let at = arguments.iter().position(|argument| argument == option)?;
908            arguments.get(at + 1).map(PathBuf::from)
909        };
910        let [config, state, runtime, logs] = SERVICE_DIRECTORY_OPTIONS;
911        Some(Self {
912            config: value(config)?,
913            state: value(state)?,
914            runtime: value(runtime)?,
915            logs: value(logs)?,
916        })
917    }
918
919    /// The deepest directory all four sit under: what an operator would call
920    /// the installation's data root. The configuration directory when they
921    /// share nothing, and [`crate::paths::SYSTEM_DATA_ROOT`] for the system
922    /// tree, whose logs are under `/var/log` rather than beside the rest.
923    #[must_use]
924    pub fn root(&self) -> PathBuf {
925        if *self == Self::of(&AppPaths::system()) {
926            return PathBuf::from(crate::paths::SYSTEM_DATA_ROOT);
927        }
928        let mut shared: Vec<std::path::Component<'_>> = self.config.components().collect();
929        for other in [&self.state, &self.runtime, &self.logs] {
930            let common = shared
931                .iter()
932                .zip(other.components())
933                .take_while(|(left, right)| **left == *right)
934                .count();
935            shared.truncate(common);
936        }
937        if shared.is_empty() {
938            self.config.clone()
939        } else {
940            shared.iter().collect()
941        }
942    }
943
944    /// The four as an [`AppPaths`], to read the records a daemon running
945    /// against them writes.
946    #[must_use]
947    pub fn paths(&self) -> AppPaths {
948        AppPaths::from_directories(&self.config, &self.state, &self.runtime, &self.logs)
949    }
950}
951
952/// The hidden `daemon run` options that carry the installing account's four
953/// application-data directories across to the service's account, in
954/// [`ServiceDirectories`] order. Written by `service install`, and read back
955/// by [`ServiceDirectories::from_command_line`] to tell whose data root a
956/// registration runs against.
957pub const SERVICE_DIRECTORY_OPTIONS: [&str; 4] = [
958    "--service-config-dir",
959    "--service-state-dir",
960    "--service-runtime-dir",
961    "--service-logs-dir",
962];
963
964// ---------------------------------------------------------------------------
965// The install plan
966// ---------------------------------------------------------------------------
967
968/// What `service install` was asked for, before anything has been resolved.
969///
970/// Separate from [`InstallPlan`] because a request is the operator's words and
971/// a plan is what the host actually supports: the plan carries a *resolved
972/// absolute* binary path, the account the start mode obliges, and the four
973/// directories the registration is installed against, none of which the caller
974/// supplies.
975#[derive(Debug, Clone, PartialEq, Eq)]
976pub struct InstallRequest {
977    start_mode: StartMode,
978    binary: Option<PathBuf>,
979    source_binary: Option<PathBuf>,
980    arguments: Vec<OsString>,
981    restart: RestartPolicy,
982    on_demand: bool,
983}
984
985impl InstallRequest {
986    /// Register the binary that is running this call.
987    #[must_use]
988    pub fn new(start_mode: StartMode) -> Self {
989        Self {
990            start_mode,
991            binary: None,
992            source_binary: None,
993            arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
994            restart: RestartPolicy::default(),
995            on_demand: false,
996        }
997    }
998
999    /// Registers the service but does not ask the manager to start it by
1000    /// itself.
1001    ///
1002    /// Production never uses this: a boot-mode registration that does not start
1003    /// at boot is the failure `service status` reports. **The privileged
1004    /// installer tests use it for every fixture they create**, so that a
1005    /// registration which somehow escaped its cleanup guard cannot start with
1006    /// the owner's machine on the next reboot. A leaked service is bad; a
1007    /// leaked service that runs is worse, and the difference costs one flag.
1008    #[must_use]
1009    pub const fn started_on_demand(mut self) -> Self {
1010        self.on_demand = true;
1011        self
1012    }
1013
1014    /// Register a named binary instead of the running one.
1015    ///
1016    /// The privileged installer tests use this to register a fixture service
1017    /// host. Production does not: `05-infrastructure.md` item 6 is specifically
1018    /// about the path of *the running binary*, and letting an operator name a
1019    /// different one would defeat the stale-path detection it asks for.
1020    #[must_use]
1021    pub fn for_binary(mut self, binary: impl Into<PathBuf>) -> Self {
1022        self.binary = Some(binary.into());
1023        self
1024    }
1025
1026    /// Where the registered binary was copied from.
1027    ///
1028    /// # Why a registration has a source at all
1029    ///
1030    /// A service registered directly against a package manager's own file can
1031    /// never be upgraded on Windows, because the running service holds that
1032    /// file open and `npm i -g` cannot replace it. What it does instead is
1033    /// worse than failing: it rewrites the package metadata, reports success,
1034    /// and leaves the old executable in place — so the operator is told the new
1035    /// version is installed while the old one keeps running. That was observed,
1036    /// twice in a row, before this existed.
1037    ///
1038    /// So the service runs a copy the product owns, and this records where that
1039    /// copy came from. The source is what a package manager updates, what
1040    /// [`inspect_binary`] watches for item 6's stale-path case, and what the
1041    /// daemon compares its own version against to know an upgrade is waiting.
1042    #[must_use]
1043    pub fn copied_from(mut self, source: impl Into<PathBuf>) -> Self {
1044        self.source_binary = Some(source.into());
1045        self
1046    }
1047
1048    /// Replace the registered arguments. Defaults to [`DAEMON_ARGUMENTS`].
1049    #[must_use]
1050    pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1051    where
1052        I: IntoIterator<Item = S>,
1053        S: Into<OsString>,
1054    {
1055        self.arguments = arguments.into_iter().map(Into::into).collect();
1056        self
1057    }
1058
1059    /// Replace the restart-on-failure policy. Defaults to
1060    /// [`RestartPolicy::default`].
1061    #[must_use]
1062    pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1063        self.restart = restart;
1064        self
1065    }
1066
1067    /// The start mode asked for.
1068    #[must_use]
1069    pub const fn start_mode(&self) -> StartMode {
1070        self.start_mode
1071    }
1072}
1073
1074/// Everything a backend needs, resolved and validated.
1075///
1076/// Constructing one is where `05-infrastructure.md` item 6 is actually
1077/// satisfied: the binary path is made absolute and is confirmed to be a file
1078/// *before* anything is registered, so a registration that would fail at boot
1079/// is refused at install time instead.
1080#[derive(Debug, Clone, PartialEq, Eq)]
1081pub struct InstallPlan {
1082    identity: ServiceIdentity,
1083    start_mode: StartMode,
1084    binary: PathBuf,
1085    source_binary: Option<PathBuf>,
1086    arguments: Vec<OsString>,
1087    account: ServiceAccount,
1088    restart: RestartPolicy,
1089    directories: ServiceDirectories,
1090    secret_guard: Option<PathBuf>,
1091    on_demand: bool,
1092}
1093
1094impl InstallPlan {
1095    /// Resolves a request against this host.
1096    ///
1097    /// # Errors
1098    ///
1099    /// [`ServiceError::BinaryPath`] when the running executable cannot be
1100    /// located, and [`ServiceError::BinaryMissing`] when the path that would be
1101    /// registered is not a file.
1102    pub fn resolve(
1103        identity: ServiceIdentity,
1104        request: &InstallRequest,
1105        directories: ServiceDirectories,
1106    ) -> Result<Self, ServiceError> {
1107        let binary = match &request.binary {
1108            Some(named) => absolute(named)?,
1109            None => running_executable()?,
1110        };
1111        if !binary.is_file() {
1112            return Err(ServiceError::BinaryMissing { path: binary });
1113        }
1114        // `d2` publishes the guard file the store lives in so the systemd
1115        // sandbox can admit only its parent for atomic token rotation. A store
1116        // that cannot be resolved is not fatal here; it merely cannot receive
1117        // that narrow writable-path exception.
1118        let secret_guard = crate::secrets::PlatformSecretStore::for_start_mode(request.start_mode)
1119            .ok()
1120            .map(|store| store.guard());
1121        Ok(Self {
1122            identity,
1123            start_mode: request.start_mode,
1124            binary,
1125            source_binary: request.source_binary.clone(),
1126            arguments: request.arguments.clone(),
1127            account: ServiceAccount::for_start_mode(request.start_mode),
1128            restart: request.restart,
1129            directories,
1130            secret_guard,
1131            on_demand: request.on_demand,
1132        })
1133    }
1134
1135    /// Builds a plan from values a caller already holds, without touching the
1136    /// filesystem.
1137    ///
1138    /// This is what makes the renderers testable on a host that has none of the
1139    /// paths involved: a Windows developer can render the systemd unit for
1140    /// `/opt/runner-manager/bin/runner-manager` and assert every line of it.
1141    #[must_use]
1142    pub fn unchecked(
1143        identity: ServiceIdentity,
1144        start_mode: StartMode,
1145        binary: impl Into<PathBuf>,
1146        directories: ServiceDirectories,
1147    ) -> Self {
1148        Self {
1149            identity,
1150            start_mode,
1151            binary: binary.into(),
1152            source_binary: None,
1153            arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
1154            account: ServiceAccount::for_start_mode(start_mode),
1155            restart: RestartPolicy::default(),
1156            directories,
1157            secret_guard: None,
1158            on_demand: false,
1159        }
1160    }
1161
1162    /// Registers without arming the manager's automatic start. See
1163    /// [`InstallRequest::started_on_demand`].
1164    #[must_use]
1165    pub const fn started_on_demand(mut self) -> Self {
1166        self.on_demand = true;
1167        self
1168    }
1169
1170    /// Whether the manager was asked not to start this by itself.
1171    #[must_use]
1172    pub const fn is_on_demand(&self) -> bool {
1173        self.on_demand
1174    }
1175
1176    /// Names the file the secret store lives in, so systemd can permit atomic
1177    /// replacement of it under `ProtectSystem=strict`.
1178    ///
1179    /// [`InstallPlan::resolve`] fills this from `d2`; a test names it directly
1180    /// so that a Windows or macOS CI leg can assert the Linux unit's writable
1181    /// path for a store that platform does not have.
1182    #[must_use]
1183    pub fn with_secret_guard(mut self, guard: impl Into<PathBuf>) -> Self {
1184        self.secret_guard = Some(guard.into());
1185        self
1186    }
1187
1188    /// The file the machine-scoped secret store lives in, when one resolved.
1189    #[must_use]
1190    pub fn secret_guard(&self) -> Option<&Path> {
1191        self.secret_guard.as_deref()
1192    }
1193
1194    /// Overrides the restart policy on an already-built plan.
1195    #[must_use]
1196    pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1197        self.restart = restart;
1198        self
1199    }
1200
1201    /// Overrides the registered arguments on an already-built plan.
1202    #[must_use]
1203    pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1204    where
1205        I: IntoIterator<Item = S>,
1206        S: Into<OsString>,
1207    {
1208        self.arguments = arguments.into_iter().map(Into::into).collect();
1209        self
1210    }
1211
1212    /// What the operating system calls this registration.
1213    #[must_use]
1214    pub const fn identity(&self) -> &ServiceIdentity {
1215        &self.identity
1216    }
1217
1218    /// Boot or login.
1219    #[must_use]
1220    pub const fn start_mode(&self) -> StartMode {
1221        self.start_mode
1222    }
1223
1224    /// The resolved absolute path of the binary to register.
1225    #[must_use]
1226    pub fn binary(&self) -> &Path {
1227        &self.binary
1228    }
1229
1230    /// Where [`Self::binary`] was copied from, when it is a copy.
1231    ///
1232    /// `None` is the legacy layout: the registration names the file a package
1233    /// manager owns, and cannot be upgraded while it runs. See
1234    /// [`InstallRequest::copied_from`].
1235    #[must_use]
1236    pub fn source_binary(&self) -> Option<&Path> {
1237        self.source_binary.as_deref()
1238    }
1239
1240    /// The arguments it is registered with.
1241    #[must_use]
1242    pub fn arguments(&self) -> &[OsString] {
1243        &self.arguments
1244    }
1245
1246    /// The account it runs under.
1247    #[must_use]
1248    pub const fn account(&self) -> &ServiceAccount {
1249        &self.account
1250    }
1251
1252    /// The restart-on-failure policy.
1253    #[must_use]
1254    pub const fn restart(&self) -> RestartPolicy {
1255        self.restart
1256    }
1257
1258    /// The four directories it was installed against.
1259    #[must_use]
1260    pub const fn directories(&self) -> &ServiceDirectories {
1261        &self.directories
1262    }
1263
1264    /// The command line as one string, quoted the way each platform expects.
1265    #[must_use]
1266    pub fn command_line(&self) -> String {
1267        let mut out = quote_argument(&self.binary.to_string_lossy());
1268        for argument in &self.arguments {
1269            out.push(' ');
1270            out.push_str(&quote_argument(&argument.to_string_lossy()));
1271        }
1272        out
1273    }
1274}
1275
1276/// Undoes a runner-root change, and says what could not be undone.
1277///
1278/// `None` when the rollback was complete — either because there was nothing to
1279/// undo, or because the directory this operation created was removed again and
1280/// the descriptor it replaced was written back. `Some` is the "report any
1281/// non-reversible existing directory state explicitly" half of the requirement,
1282/// and the caller folds it into the failure it was already reporting.
1283fn retained_runner_root(change: &RootAccessChange) -> Option<String> {
1284    let reversal = change.revert();
1285    matches!(reversal, Reversal::Retained { .. }).then(|| reversal.to_string())
1286}
1287
1288/// The error a failed step returns once both of its rollbacks have been
1289/// attempted.
1290///
1291/// `retained` is [`retained_runner_root`]'s answer and `rollback` is what
1292/// undoing the *registration* reported — whatever it reports on success, which
1293/// a failure has no use for. Both arrive already evaluated, because each may be
1294/// attempted exactly once and the order is the caller's to choose.
1295///
1296/// A complete rollback leaves `cause` exactly as it was: the operator's problem
1297/// is what failed, not the tidying up afterwards. An incomplete one is promoted
1298/// to [`ServiceError::Rollback`], which is the variant that exists to say "this
1299/// failed *and* something is left behind" — and it names everything that did.
1300fn rolled_back<T>(
1301    retained: Option<String>,
1302    rollback: Result<T, ServiceError>,
1303    operation: &'static str,
1304    identity: &ServiceIdentity,
1305    cause: ServiceError,
1306) -> ServiceError {
1307    let left_behind = match (rollback.err(), retained) {
1308        (Some(rollback), Some(retained)) => Some(format!("{rollback}; {retained}")),
1309        (Some(rollback), None) => Some(rollback.to_string()),
1310        (None, retained) => retained,
1311    };
1312    match left_behind {
1313        Some(rollback) => ServiceError::Rollback {
1314            operation,
1315            name: identity.name().to_string(),
1316            cause: cause.to_string(),
1317            rollback,
1318        },
1319        None => cause,
1320    }
1321}
1322
1323/// [`rolled_back`] for a failure with nothing registered yet, where the runner
1324/// root is the only thing there is to undo.
1325fn undo_runner_root(
1326    change: &RootAccessChange,
1327    operation: &'static str,
1328    identity: &ServiceIdentity,
1329    cause: ServiceError,
1330) -> ServiceError {
1331    rolled_back(
1332        retained_runner_root(change),
1333        Ok(()),
1334        operation,
1335        identity,
1336        cause,
1337    )
1338}
1339
1340/// Makes a path absolute without resolving symlinks.
1341///
1342/// Symbolic links are deliberately left alone. A Homebrew installation puts a
1343/// stable link at `/opt/homebrew/bin/runner-manager` and moves the file it
1344/// points at on every upgrade; recording the link's target would make an
1345/// ordinary `brew upgrade` look exactly like the npm failure this module exists
1346/// to detect, and recording the link records the thing the operator installed.
1347///
1348/// Linux is the one platform where this is not fully in the caller's hands:
1349/// `std::env::current_exe` reads `/proc/self/exe`, which the kernel has already
1350/// resolved. There the recorded path is the real file rather than the shim —
1351/// which is still correct for the npm case, because a real file under a Node
1352/// prefix disappears with the prefix.
1353fn absolute(path: &Path) -> Result<PathBuf, ServiceError> {
1354    std::path::absolute(path).map_err(|error| ServiceError::BinaryPath {
1355        detail: format!("{} could not be made absolute: {error}", path.display()),
1356    })
1357}
1358
1359/// The absolute path of the executable running this call.
1360fn running_executable() -> Result<PathBuf, ServiceError> {
1361    let raw = std::env::current_exe().map_err(|error| ServiceError::BinaryPath {
1362        detail: error.to_string(),
1363    })?;
1364    absolute(&raw)
1365}
1366
1367/// Quotes one command-line argument when it needs it.
1368///
1369/// The rule is Windows', because Windows is the platform where a command line is
1370/// a single string the callee re-splits, and because `windows-service` applies
1371/// exactly this rule when it builds `lpBinaryPathName`. The Unix backends embed
1372/// arguments in a plist array and in a systemd `ExecStart=`, both of which
1373/// accept the same quoting, so one rule serves all three rather than three
1374/// nearly-identical ones.
1375pub(crate) fn quote_argument(argument: &str) -> String {
1376    if !argument.is_empty() && !argument.contains([' ', '"', '\t', '\n']) {
1377        return argument.to_string();
1378    }
1379    let mut out = String::with_capacity(argument.len() + 2);
1380    out.push('"');
1381    let mut backslashes = 0usize;
1382    for c in argument.chars() {
1383        match c {
1384            '\\' => {
1385                backslashes += 1;
1386                out.push('\\');
1387            }
1388            '"' => {
1389                // Every backslash immediately before a quote must be doubled,
1390                // and the quote itself escaped.
1391                for _ in 0..=backslashes {
1392                    out.push('\\');
1393                }
1394                out.push('"');
1395                backslashes = 0;
1396            }
1397            other => {
1398                backslashes = 0;
1399                out.push(other);
1400            }
1401        }
1402    }
1403    // Trailing backslashes would otherwise escape the closing quote.
1404    for _ in 0..backslashes {
1405        out.push('\\');
1406    }
1407    out.push('"');
1408    out
1409}
1410
1411/// Reads the executable back out of a command line quoted by
1412/// [`quote_argument`].
1413///
1414/// `service status` needs this because the Windows Service Control Manager
1415/// stores the binary and its arguments as one string and hands the whole thing
1416/// back from `QueryServiceConfigW`. Comparing a record against a registration
1417/// means splitting that string the same way Windows itself does.
1418///
1419/// Returns `None` for an empty or whitespace-only command line, which is the
1420/// only input with no first argument to find.
1421#[must_use]
1422pub fn executable_from_command_line(command_line: &str) -> Option<PathBuf> {
1423    split_command_line(command_line)
1424        .into_iter()
1425        .next()
1426        .filter(|executable| !executable.is_empty())
1427        .map(PathBuf::from)
1428}
1429
1430/// Splits a command line quoted by [`quote_argument`] back into its
1431/// arguments, by the rule Windows itself applies: whitespace separates
1432/// arguments outside quotes, `2n` backslashes before a quote are `n`
1433/// backslashes and a quote that opens or closes, `2n+1` are `n` backslashes
1434/// and a literal quote, and every other backslash is literal.
1435#[must_use]
1436pub fn split_command_line(command_line: &str) -> Vec<String> {
1437    let mut arguments = Vec::new();
1438    let mut current = String::new();
1439    let mut started = false;
1440    let mut quoted = false;
1441    let mut backslashes = 0usize;
1442    for c in command_line.chars() {
1443        match c {
1444            '\\' => {
1445                backslashes += 1;
1446                started = true;
1447            }
1448            '"' => {
1449                current.extend(std::iter::repeat_n('\\', backslashes / 2));
1450                if backslashes.is_multiple_of(2) {
1451                    quoted = !quoted;
1452                } else {
1453                    current.push('"');
1454                }
1455                backslashes = 0;
1456                started = true;
1457            }
1458            ' ' | '\t' if !quoted => {
1459                current.extend(std::iter::repeat_n('\\', backslashes));
1460                backslashes = 0;
1461                if started {
1462                    arguments.push(std::mem::take(&mut current));
1463                    started = false;
1464                }
1465            }
1466            other => {
1467                current.extend(std::iter::repeat_n('\\', backslashes));
1468                backslashes = 0;
1469                current.push(other);
1470                started = true;
1471            }
1472        }
1473    }
1474    current.extend(std::iter::repeat_n('\\', backslashes));
1475    if started {
1476        arguments.push(current);
1477    }
1478    arguments
1479}
1480
1481// ---------------------------------------------------------------------------
1482// Rendered definitions
1483// ---------------------------------------------------------------------------
1484
1485/// Which of the four things a platform reads.
1486#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1487pub enum DefinitionKind {
1488    /// The parameters `CreateServiceW` is called with, rendered as a canonical
1489    /// descriptor so that they can be reviewed and asserted like the other
1490    /// three. The Service Control Manager has no file.
1491    WindowsService,
1492    /// A Task Scheduler XML document, for `--start-at login` on Windows.
1493    WindowsScheduledTask,
1494    /// A launchd property list — a LaunchDaemon at boot, a LaunchAgent at login.
1495    LaunchdPlist,
1496    /// A systemd unit — a system unit at boot, a user unit at login.
1497    SystemdUnit,
1498}
1499
1500impl DefinitionKind {
1501    /// What to call the thing that reads it, in an operator-facing message.
1502    #[must_use]
1503    pub const fn manager(self) -> &'static str {
1504        match self {
1505            Self::WindowsService => "the Windows Service Control Manager",
1506            Self::WindowsScheduledTask => "Windows Task Scheduler",
1507            Self::LaunchdPlist => "launchd",
1508            Self::SystemdUnit => "systemd",
1509        }
1510    }
1511}
1512
1513impl fmt::Display for DefinitionKind {
1514    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1515        f.write_str(self.manager())
1516    }
1517}
1518
1519/// One platform's definition of the registration, as text.
1520///
1521/// Producing this is pure: no `cfg`, no privileges, no filesystem. That is what
1522/// lets every leg of the CI matrix assert every platform's definition, instead
1523/// of each leg asserting only its own and the other two being reviewed by
1524/// reading.
1525#[derive(Debug, Clone, PartialEq, Eq)]
1526pub struct ServiceDefinition {
1527    kind: DefinitionKind,
1528    text: String,
1529    install_path: Option<PathBuf>,
1530}
1531
1532impl ServiceDefinition {
1533    /// Which platform this is for.
1534    #[must_use]
1535    pub const fn kind(&self) -> DefinitionKind {
1536        self.kind
1537    }
1538
1539    /// The definition itself.
1540    #[must_use]
1541    pub fn text(&self) -> &str {
1542        &self.text
1543    }
1544
1545    /// Where the platform expects the file, when the platform reads a file.
1546    ///
1547    /// `None` for [`DefinitionKind::WindowsService`], whose "definition" is a
1548    /// set of arguments to `CreateServiceW`, and for a Task Scheduler document,
1549    /// which is handed to `schtasks` from a temporary file and is not stored
1550    /// where it was written.
1551    #[must_use]
1552    pub fn install_path(&self) -> Option<&Path> {
1553        self.install_path.as_deref()
1554    }
1555
1556    /// The definition this host's own service manager would be given.
1557    ///
1558    /// The value-level twin of [`host_definition_kind`]. Rendering needs no
1559    /// privileges and no service manager, so anything that wants to *see* what
1560    /// an install would register — `f3`'s `service install --dry-run`, a
1561    /// support bundle, [`RecordingControls`] — can have it without registering
1562    /// anything.
1563    ///
1564    /// # Errors
1565    ///
1566    /// [`ServiceError::Control`] when a Windows logon-triggered registration is
1567    /// asked for and the session reports no account to register it for.
1568    pub fn for_host(plan: &InstallPlan) -> Result<Self, ServiceError> {
1569        Ok(match host_definition_kind(plan.start_mode()) {
1570            DefinitionKind::WindowsService => Self::windows_service(plan),
1571            DefinitionKind::WindowsScheduledTask => {
1572                Self::windows_scheduled_task(plan, &TaskPrincipal::current()?)
1573            }
1574            DefinitionKind::LaunchdPlist => Self::launchd(plan, host_home().as_deref()),
1575            DefinitionKind::SystemdUnit => Self::systemd(plan, host_home().as_deref()),
1576        })
1577    }
1578
1579    /// The Windows service parameters, as a canonical descriptor.
1580    #[must_use]
1581    pub fn windows_service(plan: &InstallPlan) -> Self {
1582        Self {
1583            kind: DefinitionKind::WindowsService,
1584            text: windows_service_descriptor(plan),
1585            install_path: None,
1586        }
1587    }
1588
1589    /// A Task Scheduler document for `--start-at login`.
1590    #[must_use]
1591    pub fn windows_scheduled_task(plan: &InstallPlan, principal: &TaskPrincipal) -> Self {
1592        Self {
1593            kind: DefinitionKind::WindowsScheduledTask,
1594            text: windows_scheduled_task_xml(plan, principal),
1595            install_path: None,
1596        }
1597    }
1598
1599    /// A launchd property list.
1600    ///
1601    /// `home` is only consulted for `--start-at login`, where the file belongs
1602    /// in the operator's own `~/Library/LaunchAgents`.
1603    #[must_use]
1604    pub fn launchd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1605        let file = format!("{}.plist", plan.identity().launchd_label());
1606        let install_path = match plan.start_mode() {
1607            StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
1608            StartMode::Login => home.map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file)),
1609        };
1610        Self {
1611            kind: DefinitionKind::LaunchdPlist,
1612            text: launchd_plist(plan),
1613            install_path,
1614        }
1615    }
1616
1617    /// Wraps text this module did not render.
1618    ///
1619    /// Two callers need it and both matter. A test builds a deliberately
1620    /// widened definition and watches [`review_least_privilege`] reject it —
1621    /// without which the review would be a function nobody had ever seen fail.
1622    /// And `service status` can review the definition **actually on disk**,
1623    /// which on Linux and macOS an operator is free to edit after installation.
1624    #[must_use]
1625    pub fn from_text(kind: DefinitionKind, text: impl Into<String>) -> Self {
1626        Self {
1627            kind,
1628            text: text.into(),
1629            install_path: None,
1630        }
1631    }
1632
1633    /// A systemd unit.
1634    ///
1635    /// `home` is only consulted for `--start-at login`, where the unit belongs
1636    /// in the operator's own `~/.config/systemd/user`.
1637    #[must_use]
1638    pub fn systemd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1639        let file = plan.identity().systemd_unit();
1640        let install_path = match plan.start_mode() {
1641            StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
1642            StartMode::Login => home.map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file)),
1643        };
1644        Self {
1645            kind: DefinitionKind::SystemdUnit,
1646            text: systemd_unit(plan),
1647            install_path,
1648        }
1649    }
1650}
1651
1652/// Where a LaunchDaemon lives.
1653pub const LAUNCH_DAEMONS_DIR: &str = "/Library/LaunchDaemons";
1654/// Where a LaunchAgent lives, under the operator's home directory.
1655pub const LAUNCH_AGENTS_SUBDIR: &str = "Library/LaunchAgents";
1656/// Where a systemd system unit lives.
1657pub const SYSTEMD_SYSTEM_DIR: &str = "/etc/systemd/system";
1658/// Where a systemd user unit lives, under the operator's home directory.
1659pub const SYSTEMD_USER_SUBDIR: &str = ".config/systemd/user";
1660
1661/// The product's own page, cited by every definition so an operator who finds
1662/// one on a host can find out what put it there.
1663const DOCUMENTATION: &str = "https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI";
1664
1665/// How many consecutive failures the platform tolerates before it stops
1666/// retrying.
1667///
1668/// The companion to [`RestartPolicy::reset_after`]: the delay bounds how *fast*
1669/// a restart may come, and this bounds how *many* come before the host gives
1670/// up and leaves the failure visible instead of hiding it behind an endless
1671/// retry.
1672pub const START_LIMIT_BURST: u32 = 5;
1673
1674// -- systemd -----------------------------------------------------------------
1675
1676/// Renders the systemd unit for this plan.
1677///
1678/// # Why the sandbox is this tight, and what it costs
1679///
1680/// `05-infrastructure.md` item 2 ends *"and write its configured cache and
1681/// runtime directories — and no more"*. `ProtectSystem=strict` plus an explicit
1682/// `ReadWritePaths` is what "and no more" means on Linux: everything outside the
1683/// recorded application-data directories and the credential store directory
1684/// is read-only to this unit. The latter must be writable because refresh-token
1685/// rotation atomically replaces the credential file.
1686///
1687/// **The runner inherits it.** The agent spawns the GitHub Actions runner as a
1688/// child, so a workflow running on this host also runs inside this sandbox: it
1689/// cannot write outside those directories and its private `/tmp`, and
1690/// `NoNewPrivileges=yes` means it cannot `sudo`. `07-security.md` assumes a
1691/// hostile workflow may run here, so that is the intended direction — but it is
1692/// a real behavioural limit and `docs/service-account.md` states it where an
1693/// operator will find it.
1694#[must_use]
1695pub fn systemd_unit(plan: &InstallPlan) -> String {
1696    let identity = plan.identity();
1697    let restart = plan.restart();
1698    let directories = plan.directories();
1699
1700    let mut out = String::new();
1701    out.push_str("[Unit]\n");
1702    out.push_str(&format!("Description={}\n", identity.display_name()));
1703    out.push_str(&format!("Documentation={DOCUMENTATION}\n"));
1704    out.push_str("After=network-online.target\n");
1705    out.push_str("Wants=network-online.target\n");
1706    // In `[Unit]` rather than `[Service]`: systemd moved these in v229 and only
1707    // accepts them here without a deprecation warning.
1708    out.push_str(&format!(
1709        "StartLimitIntervalSec={}\n",
1710        restart.reset_after().as_secs()
1711    ));
1712    out.push_str(&format!("StartLimitBurst={START_LIMIT_BURST}\n"));
1713
1714    out.push_str("\n[Service]\n");
1715    out.push_str("Type=simple\n");
1716    // A runner is a child of the daemon. In particular, systemd must not
1717    // signal it merely because a cooperative daemon handover replaces the
1718    // manager process; startup recovery supervises any survivor instead.
1719    out.push_str("KillMode=process\n");
1720    out.push_str(&format!("ExecStart={}\n", plan.command_line()));
1721    out.push_str(&format!(
1722        "WorkingDirectory={}\n",
1723        directories.state.display()
1724    ));
1725    out.push_str(&format!("SyslogIdentifier={identity}\n"));
1726    out.push_str("Restart=on-failure\n");
1727    out.push_str(&format!("RestartSec={}\n", restart.delay().as_secs()));
1728
1729    out.push_str("\n# Least privilege. See docs/service-account.md.\n");
1730    for directive in systemd_hardening(plan.start_mode()) {
1731        out.push_str(directive);
1732        out.push('\n');
1733    }
1734    let quoted = |paths: &[String]| {
1735        paths
1736            .iter()
1737            .map(|path| quote_argument(path))
1738            .collect::<Vec<_>>()
1739            .join(" ")
1740    };
1741    out.push_str(&format!(
1742        "ReadWritePaths={}\n",
1743        quoted(&permitted_paths(DefinitionKind::SystemdUnit, plan))
1744    ));
1745
1746    out.push_str("\n[Install]\n");
1747    out.push_str(match plan.start_mode() {
1748        StartMode::Boot => "WantedBy=multi-user.target\n",
1749        StartMode::Login => "WantedBy=default.target\n",
1750    });
1751    out
1752}
1753
1754/// The hardening directives every unit carries, in the order they are rendered.
1755///
1756/// One list, used by [`systemd_hardening`], so that [`review_least_privilege`]
1757/// is written against the same directives the renderer emits: a directive
1758/// dropped from the unit is one the review then reports as missing, rather
1759/// than one that silently stops being checked.
1760///
1761/// The capability bounding set is empty in both start modes. A boot unit runs
1762/// as `root` against `root`'s own system tree (see [`crate::paths`]), so it
1763/// needs no capability to reach its directories, and the runner and every
1764/// workflow job inherit none.
1765const SYSTEMD_HARDENING: [&str; 13] = [
1766    "NoNewPrivileges=yes",
1767    "CapabilityBoundingSet=",
1768    "AmbientCapabilities=",
1769    "PrivateTmp=yes",
1770    "PrivateDevices=yes",
1771    "ProtectSystem=strict",
1772    "ProtectKernelTunables=yes",
1773    "ProtectKernelModules=yes",
1774    "ProtectControlGroups=yes",
1775    "RestrictNamespaces=yes",
1776    "RestrictRealtime=yes",
1777    "RestrictSUIDSGID=yes",
1778    "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1779];
1780
1781/// The hardening directives a unit started in `mode` carries.
1782///
1783/// A boot unit also hides every home directory behind an empty `tmpfs`: its
1784/// data lives outside them, and a workflow running as `root` has no business
1785/// reading the files there that DAC leaves world-readable. `tmpfs` rather than
1786/// `yes`, because tools such as `git` treat an *unreadable* `$HOME/.gitconfig`
1787/// as fatal and an absent one as nothing.
1788fn systemd_hardening(mode: StartMode) -> Vec<&'static str> {
1789    let mut directives = SYSTEMD_HARDENING.to_vec();
1790    if mode == StartMode::Boot {
1791        directives.push("ProtectHome=tmpfs");
1792    }
1793    directives
1794}
1795
1796/// The trees `ProtectHome=` hides, which a boot unit's directories must
1797/// therefore lie outside.
1798#[cfg(any(test, all(unix, not(target_os = "macos"))))]
1799const SYSTEMD_HOME_ROOTS: [&str; 3] = ["/home", "/root", "/run/user"];
1800
1801/// Renames a non-empty directory at `path` to `<name>.before-move-<stamp>`
1802/// beside it, leaving nothing at `path`.
1803#[cfg(any(all(test, unix), target_os = "linux"))]
1804fn set_aside(path: &Path, stamp: &str) -> std::io::Result<()> {
1805    let occupied = match std::fs::read_dir(path) {
1806        Ok(mut entries) => entries.next().is_some(),
1807        Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
1808        Err(error) => return Err(error),
1809    };
1810    if occupied {
1811        let name = path.file_name().map_or_else(
1812            || std::borrow::Cow::Borrowed("directory"),
1813            |name| name.to_string_lossy(),
1814        );
1815        std::fs::rename(
1816            path,
1817            path.with_file_name(format!("{name}.before-move-{stamp}")),
1818        )?;
1819    }
1820    Ok(())
1821}
1822
1823/// Copies the tree at `from` into `to`, except a top-level entry named `skip`.
1824/// Directories are created `0700`, files keep their mode, and symbolic links
1825/// are copied as links rather than followed.
1826#[cfg(any(all(test, unix), target_os = "linux"))]
1827fn copy_tree(from: &Path, to: &Path, skip: Option<&str>) -> std::io::Result<()> {
1828    use std::os::unix::fs::DirBuilderExt as _;
1829
1830    std::fs::DirBuilder::new()
1831        .recursive(true)
1832        .mode(0o700)
1833        .create(to)?;
1834    for entry in std::fs::read_dir(from)? {
1835        let entry = entry?;
1836        if skip.is_some_and(|skip| entry.file_name() == skip) {
1837            continue;
1838        }
1839        let source = entry.path();
1840        let target = to.join(entry.file_name());
1841        let kind = entry.file_type()?;
1842        if kind.is_symlink() {
1843            std::os::unix::fs::symlink(std::fs::read_link(&source)?, &target)?;
1844        } else if kind.is_dir() {
1845            copy_tree(&source, &target, None)?;
1846        } else {
1847            std::fs::copy(&source, &target)?;
1848        }
1849    }
1850    Ok(())
1851}
1852
1853/// Why a boot unit could not use `path`, owned by `owner`, or `None` when it
1854/// can.
1855///
1856/// A boot unit runs as `root` with no capability at all, so a directory
1857/// another account owns is one it cannot open, and one under a home tree is
1858/// one its `ProtectHome=` hides.
1859#[cfg(any(test, all(unix, not(target_os = "macos"))))]
1860fn boot_directory_refusal(path: &Path, owner: Option<u32>) -> Option<String> {
1861    if let Some(root) = SYSTEMD_HOME_ROOTS
1862        .iter()
1863        .find(|root| path.starts_with(root))
1864    {
1865        return Some(format!(
1866            "{} is under {root}, which a boot service cannot see",
1867            path.display()
1868        ));
1869    }
1870    match owner {
1871        Some(0) => None,
1872        Some(uid) => Some(format!(
1873            "{} belongs to uid {uid}, and a boot service runs as root with no capability \
1874             to open another account's directory",
1875            path.display()
1876        )),
1877        None => Some(format!("{} cannot be inspected", path.display())),
1878    }
1879}
1880
1881// -- launchd -----------------------------------------------------------------
1882
1883/// The `ProcessType` every rendered plist carries.
1884///
1885/// # Why not `Background`
1886///
1887/// launchd applies a job's `ProcessType` to the whole process tree it starts,
1888/// not only to the daemon. Under `Background` every runner the daemon launched
1889/// ran at base priority 4, on efficiency cores with throttled I/O, so each job
1890/// took 4 to 20 times as long as on a classic runner.
1891///
1892/// A child cannot leave it. This is the process type launchd chose at spawn
1893/// (`spawn type = background (5)` in `launchctl print`), not the
1894/// `PRIO_DARWIN_BG` flag `setpriority` controls: measured on macOS 27, a child
1895/// of a `Background` agent reads `getpriority(PRIO_DARWIN_PROCESS) == 0`, and
1896/// `setpriority(PRIO_DARWIN_PROCESS, 0, 0)` returns success and leaves it at
1897/// 4, before `exec` and after it alike. 0.4.31 tried exactly that.
1898///
1899/// The measured alternatives, as the base priority of a runner the agent
1900/// starts: `Background` 4, `Adaptive` 4, `Standard` (the default when the key
1901/// is absent) 20, `Interactive` 31 (the same as a classic runner, and the value
1902/// GitHub's own runner service template uses). The daemon itself mostly waits,
1903/// so it costs nothing to run it at that priority too.
1904pub const LAUNCHD_PROCESS_TYPE: &str = "Interactive";
1905
1906/// Renders the launchd property list for this plan.
1907///
1908/// `KeepAlive` is a dictionary rather than `<true/>` on purpose: the
1909/// requirement is *restart on failure*, and a bare `KeepAlive` also restarts a
1910/// job that exited cleanly, which would turn a deliberate `service stop` into a
1911/// fight with launchd.
1912#[must_use]
1913pub fn launchd_plist(plan: &InstallPlan) -> String {
1914    let identity = plan.identity();
1915    let directories = plan.directories();
1916    let mut out = String::new();
1917    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
1918    out.push_str(
1919        "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
1920         \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
1921    );
1922    out.push_str("<plist version=\"1.0\">\n<dict>\n");
1923    out.push_str(&plist_string("Label", &identity.launchd_label()));
1924
1925    out.push_str("  <key>ProgramArguments</key>\n  <array>\n");
1926    out.push_str(&format!(
1927        "    <string>{}</string>\n",
1928        xml_escape(&plan.binary().to_string_lossy())
1929    ));
1930    for argument in plan.arguments() {
1931        out.push_str(&format!(
1932            "    <string>{}</string>\n",
1933            xml_escape(&argument.to_string_lossy())
1934        ));
1935    }
1936    out.push_str("  </array>\n");
1937
1938    out.push_str("  <key>RunAtLoad</key>\n  <true/>\n");
1939    out.push_str("  <key>KeepAlive</key>\n  <dict>\n");
1940    out.push_str("    <key>SuccessfulExit</key>\n    <false/>\n");
1941    out.push_str("  </dict>\n");
1942    out.push_str(&format!(
1943        "  <key>ThrottleInterval</key>\n  <integer>{}</integer>\n",
1944        plan.restart().delay().as_secs()
1945    ));
1946    out.push_str(&plist_string("ProcessType", LAUNCHD_PROCESS_TYPE));
1947    out.push_str(&plist_string(
1948        "WorkingDirectory",
1949        &directories.state.to_string_lossy(),
1950    ));
1951    out.push_str(&plist_string(
1952        "StandardOutPath",
1953        &directories
1954            .logs
1955            .join("runner-manager.launchd.out.log")
1956            .to_string_lossy(),
1957    ));
1958    out.push_str(&plist_string(
1959        "StandardErrorPath",
1960        &directories
1961            .logs
1962            .join("runner-manager.launchd.err.log")
1963            .to_string_lossy(),
1964    ));
1965
1966    match plan.start_mode() {
1967        StartMode::Boot => {
1968            // A LaunchDaemon runs as root unless told otherwise, and here it
1969            // must: the System Keychain's master key is root-only.
1970            out.push_str(&plist_string(
1971                "UserName",
1972                ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
1973                    .as_str(),
1974            ));
1975            // A daemon has no user session and must not be given one.
1976            out.push_str("  <key>SessionCreate</key>\n  <false/>\n");
1977        }
1978        StartMode::Login => {
1979            // A LaunchAgent already runs as the operator. Naming a `UserName`
1980            // here would be asking launchd for an account switch a login-mode
1981            // registration has no reason to want.
1982        }
1983    }
1984
1985    out.push_str("</dict>\n</plist>\n");
1986    out
1987}
1988
1989/// One `<key>`/`<string>` pair, indented as the rest of the plist is.
1990fn plist_string(key: &str, value: &str) -> String {
1991    format!(
1992        "  <key>{}</key>\n  <string>{}</string>\n",
1993        xml_escape(key),
1994        xml_escape(value)
1995    )
1996}
1997
1998// -- Windows Task Scheduler --------------------------------------------------
1999
2000/// The account a Task Scheduler task runs as.
2001///
2002/// Task Scheduler, unlike the other three managers, needs the principal spelled
2003/// out in the document. Isolating the one impure step keeps
2004/// [`windows_scheduled_task_xml`] a pure function that every CI leg can assert.
2005#[derive(Debug, Clone, PartialEq, Eq)]
2006pub struct TaskPrincipal {
2007    user_id: String,
2008}
2009
2010impl TaskPrincipal {
2011    /// The account running this call, as `DOMAIN\user`.
2012    ///
2013    /// # Errors
2014    ///
2015    /// [`ServiceError::BinaryPath`] is not the right shape here, so this
2016    /// reports [`ServiceError::Control`] naming what the environment failed to
2017    /// say.
2018    pub fn current() -> Result<Self, ServiceError> {
2019        let user = std::env::var("USERNAME")
2020            .ok()
2021            .filter(|value| !value.trim().is_empty());
2022        let Some(user) = user else {
2023            return Err(ServiceError::Control {
2024                operation: "identify the account for",
2025                name: SERVICE_NAME.to_string(),
2026                manager: "Windows Task Scheduler",
2027                detail: "this session reports no %USERNAME%, so there is no principal to \
2028                         register a logon-triggered task for"
2029                    .to_string(),
2030            });
2031        };
2032        let domain = std::env::var("USERDOMAIN")
2033            .ok()
2034            .filter(|value| !value.trim().is_empty());
2035        Ok(Self {
2036            user_id: match domain {
2037                Some(domain) => format!("{domain}\\{user}"),
2038                None => user,
2039            },
2040        })
2041    }
2042
2043    /// A named principal, for tests and for a caller that already knows.
2044    #[must_use]
2045    pub fn named(user_id: impl Into<String>) -> Self {
2046        Self {
2047            user_id: user_id.into(),
2048        }
2049    }
2050
2051    /// `DOMAIN\user`.
2052    #[must_use]
2053    pub fn user_id(&self) -> &str {
2054        &self.user_id
2055    }
2056}
2057
2058/// Renders the Task Scheduler document for a `--start-at login` registration.
2059///
2060/// `RunLevel` is `LeastPrivilege`, which is the whole of Windows' answer to
2061/// item 2 in this domain: the task runs with the operator's ordinary filtered
2062/// token and never with an elevated one, whatever the operator's group
2063/// membership.
2064#[must_use]
2065pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
2066    let identity = plan.identity();
2067    let user = xml_escape(principal.user_id());
2068    let arguments = plan
2069        .arguments()
2070        .iter()
2071        .map(|argument| quote_argument(&argument.to_string_lossy()))
2072        .collect::<Vec<_>>()
2073        .join(" ");
2074    let mut out = String::new();
2075    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
2076    out.push_str(
2077        "<Task version=\"1.4\" \
2078         xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
2079    );
2080    out.push_str("  <RegistrationInfo>\n");
2081    out.push_str(&format!(
2082        "    <Description>{}</Description>\n",
2083        xml_escape(identity.description())
2084    ));
2085    out.push_str(&format!(
2086        "    <URI>\\{}</URI>\n",
2087        xml_escape(identity.name())
2088    ));
2089    out.push_str("  </RegistrationInfo>\n");
2090
2091    out.push_str("  <Triggers>\n    <LogonTrigger>\n");
2092    out.push_str("      <Enabled>true</Enabled>\n");
2093    out.push_str(&format!("      <UserId>{user}</UserId>\n"));
2094    out.push_str("    </LogonTrigger>\n  </Triggers>\n");
2095
2096    out.push_str("  <Principals>\n    <Principal id=\"Author\">\n");
2097    out.push_str(&format!("      <UserId>{user}</UserId>\n"));
2098    out.push_str("      <LogonType>InteractiveToken</LogonType>\n");
2099    out.push_str("      <RunLevel>LeastPrivilege</RunLevel>\n");
2100    out.push_str("    </Principal>\n  </Principals>\n");
2101
2102    out.push_str("  <Settings>\n");
2103    // One agent per host is `d1`'s lock; saying so here means Task Scheduler
2104    // refuses the second start rather than starting a process that then loses
2105    // the race and exits.
2106    out.push_str("    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
2107    out.push_str("    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
2108    out.push_str("    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
2109    out.push_str("    <AllowHardTerminate>true</AllowHardTerminate>\n");
2110    out.push_str("    <StartWhenAvailable>true</StartWhenAvailable>\n");
2111    out.push_str("    <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
2112    out.push_str("    <IdleSettings>\n");
2113    out.push_str("      <StopOnIdleEnd>false</StopOnIdleEnd>\n");
2114    out.push_str("      <RestartOnIdle>false</RestartOnIdle>\n");
2115    out.push_str("    </IdleSettings>\n");
2116    out.push_str("    <AllowStartOnDemand>true</AllowStartOnDemand>\n");
2117    out.push_str("    <Enabled>true</Enabled>\n");
2118    out.push_str("    <Hidden>false</Hidden>\n");
2119    out.push_str("    <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
2120    out.push_str("    <WakeToRun>false</WakeToRun>\n");
2121    // A daemon has no natural end, so any limit here would be a scheduled kill.
2122    out.push_str("    <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
2123    out.push_str("    <Priority>7</Priority>\n");
2124    out.push_str("    <RestartOnFailure>\n");
2125    out.push_str(&format!(
2126        "      <Interval>{}</Interval>\n",
2127        iso8601_minutes(
2128            plan.restart()
2129                .effective_delay(DefinitionKind::WindowsScheduledTask)
2130        )
2131    ));
2132    out.push_str(&format!("      <Count>{START_LIMIT_BURST}</Count>\n"));
2133    out.push_str("    </RestartOnFailure>\n");
2134    out.push_str("  </Settings>\n");
2135
2136    out.push_str("  <Actions Context=\"Author\">\n    <Exec>\n");
2137    out.push_str(&format!(
2138        "      <Command>{}</Command>\n",
2139        xml_escape(&plan.binary().to_string_lossy())
2140    ));
2141    if !arguments.is_empty() {
2142        out.push_str(&format!(
2143            "      <Arguments>{}</Arguments>\n",
2144            xml_escape(&arguments)
2145        ));
2146    }
2147    out.push_str(&format!(
2148        "      <WorkingDirectory>{}</WorkingDirectory>\n",
2149        xml_escape(&plan.directories().state.to_string_lossy())
2150    ));
2151    out.push_str("    </Exec>\n  </Actions>\n");
2152    out.push_str("</Task>\n");
2153    out
2154}
2155
2156/// `PT<n>M`, which is the only shape Task Scheduler accepts for a restart
2157/// interval. See [`RestartPolicy::effective_delay`].
2158fn iso8601_minutes(duration: Duration) -> String {
2159    format!("PT{}M", duration.as_secs() / 60)
2160}
2161
2162/// Escapes the five XML entities. Applied to every value that reaches a plist
2163/// or a task document, because a Windows account name may legitimately contain
2164/// `&` and a path may contain `<`.
2165///
2166/// `pub(crate)` rather than private because [`crate::wsl::task`] renders a
2167/// second kind of Task Scheduler document and must escape it the same way; two
2168/// escapers in one crate is exactly how one of them ends up subtly different.
2169pub(crate) fn xml_escape(value: &str) -> String {
2170    let mut out = String::with_capacity(value.len());
2171    for c in value.chars() {
2172        match c {
2173            '&' => out.push_str("&amp;"),
2174            '<' => out.push_str("&lt;"),
2175            '>' => out.push_str("&gt;"),
2176            '"' => out.push_str("&quot;"),
2177            '\'' => out.push_str("&apos;"),
2178            other => out.push(other),
2179        }
2180    }
2181    out
2182}
2183
2184/// The inverse of [`xml_escape`], for reading a definition back off disk.
2185///
2186/// `&amp;` is replaced last, which is the whole subtlety: replacing it first
2187/// would turn a literal `&amp;amp;` into `&` in two passes instead of `&amp;`
2188/// in one.
2189fn xml_unescape(value: &str) -> String {
2190    value
2191        .replace("&lt;", "<")
2192        .replace("&gt;", ">")
2193        .replace("&quot;", "\"")
2194        .replace("&apos;", "'")
2195        .replace("&amp;", "&")
2196}
2197
2198// -- Windows Service Control Manager -----------------------------------------
2199
2200/// How `CreateServiceW` should be called, in a form that can be reviewed,
2201/// asserted, and printed on any platform.
2202///
2203/// The Windows backend builds its `ServiceInfo` from **this** value rather than
2204/// from the plan, so the descriptor a Linux CI leg asserts and the parameters a
2205/// Windows host registers cannot drift apart.
2206#[derive(Debug, Clone, PartialEq, Eq)]
2207pub struct WindowsServiceSpec {
2208    /// The service name.
2209    pub name: String,
2210    /// What `services.msc` shows.
2211    pub display_name: String,
2212    /// The one-line description.
2213    pub description: String,
2214    /// `true` for `SERVICE_AUTO_START`, `false` for `SERVICE_DEMAND_START`.
2215    pub automatic_start: bool,
2216    /// The account, or `None` for `LocalSystem`, which is what
2217    /// `CreateServiceW` takes a null `lpServiceStartName` to mean.
2218    pub account: Option<String>,
2219    /// The full launch command, quoted.
2220    pub command_line: String,
2221    /// The restart-on-failure policy.
2222    pub restart: RestartPolicy,
2223}
2224
2225/// Derives the Windows service parameters from a plan.
2226#[must_use]
2227pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
2228    WindowsServiceSpec {
2229        name: plan.identity().name().to_string(),
2230        display_name: plan.identity().display_name().to_string(),
2231        description: plan.identity().description().to_string(),
2232        // Only a boot-mode registration is a service at all; a login-mode one
2233        // is a scheduled task. So an automatic start is the only kind here, and
2234        // the field exists because the privileged tests register an on-demand
2235        // fixture rather than one that starts with the test machine.
2236        automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
2237        account: match ServiceAccount::for_definition(
2238            DefinitionKind::WindowsService,
2239            plan.start_mode(),
2240        ) {
2241            // `None` means LocalSystem to `CreateServiceW`, and naming it
2242            // explicitly would be one more string to spell right.
2243            ServiceAccount::LocalSystem => None,
2244            other => Some(other.as_str().to_string()),
2245        },
2246        command_line: plan.command_line(),
2247        restart: plan.restart(),
2248    }
2249}
2250
2251/// Renders the descriptor [`review_least_privilege`] reads and `service status`
2252/// can print.
2253#[must_use]
2254fn windows_service_descriptor(plan: &InstallPlan) -> String {
2255    let spec = windows_service_spec(plan);
2256    let mut out = String::new();
2257    out.push_str("[windows-service]\n");
2258    out.push_str(&format!("Name={}\n", spec.name));
2259    out.push_str(&format!("DisplayName={}\n", spec.display_name));
2260    out.push_str(&format!("Description={}\n", spec.description));
2261    // OWN_PROCESS and never INTERACTIVE_PROCESS: an interactive service would
2262    // put a process this product controls on the operator's desktop, which is
2263    // both deprecated by Windows and more than the requirement asks for.
2264    out.push_str("ServiceType=OWN_PROCESS\n");
2265    out.push_str(&format!(
2266        "StartType={}\n",
2267        if spec.automatic_start {
2268            "AutoStart"
2269        } else {
2270            "OnDemand"
2271        }
2272    ));
2273    out.push_str("ErrorControl=Normal\n");
2274    out.push_str(&format!(
2275        "Account={}\n",
2276        spec.account
2277            .as_deref()
2278            .unwrap_or(ServiceAccount::LocalSystem.as_str())
2279    ));
2280    out.push_str(&format!("CommandLine={}\n", spec.command_line));
2281    out.push_str(&format!(
2282        "FailureActionRestartDelaySecs={}\n",
2283        spec.restart.delay().as_secs()
2284    ));
2285    out.push_str(&format!(
2286        "FailureActionsResetPeriodSecs={}\n",
2287        spec.restart.reset_after().as_secs()
2288    ));
2289    // Without this flag the Service Control Manager applies failure actions
2290    // only to a crash, and a daemon that exits non-zero after failing to reach
2291    // GitHub is not a crash.
2292    out.push_str("FailureActionsOnNonCrashFailures=true\n");
2293    out.push_str(&format!(
2294        "ReadWritePaths={}\n",
2295        plan.directories()
2296            .all()
2297            .iter()
2298            .map(|path| quote_argument(&path.to_string_lossy()))
2299            .collect::<Vec<_>>()
2300            .join(" ")
2301    ));
2302    out
2303}
2304
2305// ---------------------------------------------------------------------------
2306// The least-privilege review
2307// ---------------------------------------------------------------------------
2308
2309/// Whether a finding is about too much authority or too little.
2310#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2311pub enum FindingKind {
2312    /// The definition grants more than `05-infrastructure.md` item 2 asks for.
2313    /// This is what makes a review fail.
2314    Excess,
2315    /// The definition does not grant something the daemon needs, or does not
2316    /// state something the review cannot verify without.
2317    Shortfall,
2318}
2319
2320impl fmt::Display for FindingKind {
2321    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2322        f.write_str(match self {
2323            Self::Excess => "excess",
2324            Self::Shortfall => "shortfall",
2325        })
2326    }
2327}
2328
2329/// One thing the review has to say about a definition.
2330#[derive(Debug, Clone, PartialEq, Eq)]
2331pub struct PrivilegeFinding {
2332    /// Excess or shortfall.
2333    pub kind: FindingKind,
2334    /// The directive, key, or element it is about.
2335    pub subject: String,
2336    /// What is wrong with it, in terms an operator can act on.
2337    pub detail: String,
2338}
2339
2340impl fmt::Display for PrivilegeFinding {
2341    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2342        write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2343    }
2344}
2345
2346/// What a definition actually grants, measured against the requirement.
2347///
2348/// `05-infrastructure.md` item 2 and `07-security.md`'s release gate
2349/// (*"Service account permissions are documented and verified least
2350/// privilege"*) are the same requirement stated twice, and this is the
2351/// verification half. The documentation half is `docs/service-account.md`.
2352///
2353/// The review reads the **rendered text**, not the plan it came from. That is
2354/// deliberate and it is the only version of this check worth having: a review
2355/// that re-derived its expectations from the same values the renderer used
2356/// would agree with the renderer by construction and could never fail. Reading
2357/// the text means a hand-edited unit on a real host is reviewed as it is, and
2358/// means a test can widen a definition and watch this say so.
2359#[derive(Debug, Clone, PartialEq, Eq)]
2360pub struct PrivilegeReview {
2361    kind: DefinitionKind,
2362    account: ServiceAccount,
2363    controls: Vec<String>,
2364    findings: Vec<PrivilegeFinding>,
2365}
2366
2367impl PrivilegeReview {
2368    /// Whether the definition grants nothing beyond the requirement.
2369    #[must_use]
2370    pub fn is_least_privilege(&self) -> bool {
2371        !self
2372            .findings
2373            .iter()
2374            .any(|finding| finding.kind == FindingKind::Excess)
2375    }
2376
2377    /// Everything the review found, excesses and shortfalls together.
2378    #[must_use]
2379    pub fn findings(&self) -> &[PrivilegeFinding] {
2380        &self.findings
2381    }
2382
2383    /// Only the excesses — the findings that make [`Self::is_least_privilege`]
2384    /// false.
2385    #[must_use]
2386    pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2387        self.findings
2388            .iter()
2389            .filter(|finding| finding.kind == FindingKind::Excess)
2390            .collect()
2391    }
2392
2393    /// The controls the definition was confirmed to carry.
2394    ///
2395    /// Present so that a passing review says *what it checked* rather than only
2396    /// that it passed. A check that reports nothing when it succeeds is
2397    /// indistinguishable from a check that did not run.
2398    #[must_use]
2399    pub fn controls(&self) -> &[String] {
2400        &self.controls
2401    }
2402
2403    /// The account the registration runs under, and why it is the minimum.
2404    #[must_use]
2405    pub const fn account(&self) -> &ServiceAccount {
2406        &self.account
2407    }
2408
2409    /// Which platform's definition was reviewed.
2410    #[must_use]
2411    pub const fn kind(&self) -> DefinitionKind {
2412        self.kind
2413    }
2414}
2415
2416impl fmt::Display for PrivilegeReview {
2417    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2418        writeln!(
2419            f,
2420            "{} runs as {} ({})",
2421            self.kind,
2422            self.account,
2423            self.account.justification()
2424        )?;
2425        for control in &self.controls {
2426            writeln!(f, "  confirmed  {control}")?;
2427        }
2428        for finding in &self.findings {
2429            writeln!(f, "  {finding}")?;
2430        }
2431        if self.is_least_privilege() {
2432            write!(f, "  verdict    least privilege")
2433        } else {
2434            write!(
2435                f,
2436                "  verdict    NOT least privilege: {} excess(es)",
2437                self.excesses().len()
2438            )
2439        }
2440    }
2441}
2442
2443/// Reads a definition back and reports what it grants.
2444///
2445/// `plan` supplies only the two things the text cannot: the four directories
2446/// the registration is *allowed* to write, and the start mode, which decides
2447/// which account is the minimum.
2448#[must_use]
2449pub fn review_least_privilege(
2450    definition: &ServiceDefinition,
2451    plan: &InstallPlan,
2452) -> PrivilegeReview {
2453    let mut controls = Vec::new();
2454    let mut findings = Vec::new();
2455    match definition.kind() {
2456        DefinitionKind::SystemdUnit => {
2457            review_systemd(definition.text(), plan, &mut controls, &mut findings);
2458        }
2459        DefinitionKind::LaunchdPlist => {
2460            review_launchd(definition.text(), plan, &mut controls, &mut findings);
2461        }
2462        DefinitionKind::WindowsScheduledTask => {
2463            review_scheduled_task(definition.text(), &mut controls, &mut findings);
2464        }
2465        DefinitionKind::WindowsService => {
2466            review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2467        }
2468    }
2469    PrivilegeReview {
2470        kind: definition.kind(),
2471        // The definition's account, not the plan's: a Linux CI leg reviewing the
2472        // Windows descriptor must report `LocalSystem`, which is the account
2473        // that descriptor registers, and not `root`, which is the account this
2474        // leg's own host would use.
2475        account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2476        controls,
2477        findings,
2478    }
2479}
2480
2481/// Every writable path a definition may name, as the strings it names them by.
2482fn permitted_paths(kind: DefinitionKind, plan: &InstallPlan) -> Vec<String> {
2483    let mut permitted = plan
2484        .directories()
2485        .all()
2486        .iter()
2487        .map(|path| path.to_string_lossy().into_owned())
2488        .collect::<Vec<_>>();
2489    if kind == DefinitionKind::SystemdUnit
2490        && let Some(secret_directory) = plan.secret_guard().and_then(Path::parent)
2491    {
2492        permitted.push(secret_directory.to_string_lossy().into_owned());
2493    }
2494    permitted
2495}
2496
2497/// Whether two path spellings name the same place on **this host's**
2498/// filesystem. Case-insensitive on Windows, exact elsewhere.
2499///
2500/// For a rendered definition use [`same_path_for`] instead: which comparison is
2501/// right there follows the platform the definition is *for*, and every leg of
2502/// the CI matrix reviews all four.
2503fn same_path_text(left: &str, right: &str) -> bool {
2504    if cfg!(windows) {
2505        left.eq_ignore_ascii_case(right)
2506    } else {
2507        left == right
2508    }
2509}
2510
2511/// Whether two path spellings name the same place on the platform a given
2512/// definition targets.
2513fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
2514    match kind {
2515        DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
2516            left.eq_ignore_ascii_case(right)
2517        }
2518        DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
2519    }
2520}
2521
2522/// Checks a `ReadWritePaths`-style list against the permitted directories.
2523fn review_writable_paths(
2524    kind: DefinitionKind,
2525    subject: &str,
2526    listed: &[String],
2527    plan: &InstallPlan,
2528    controls: &mut Vec<String>,
2529    findings: &mut Vec<PrivilegeFinding>,
2530) {
2531    let permitted = permitted_paths(kind, plan);
2532    for entry in listed {
2533        if !permitted
2534            .iter()
2535            .any(|allowed| same_path_for(kind, allowed, entry))
2536        {
2537            findings.push(PrivilegeFinding {
2538                kind: FindingKind::Excess,
2539                subject: subject.to_string(),
2540                detail: format!(
2541                    "{entry} is writable but is not one of this registration's required paths"
2542                ),
2543            });
2544        }
2545    }
2546    for allowed in &permitted {
2547        if !listed
2548            .iter()
2549            .any(|entry| same_path_for(kind, allowed, entry))
2550        {
2551            findings.push(PrivilegeFinding {
2552                kind: FindingKind::Shortfall,
2553                subject: subject.to_string(),
2554                detail: format!(
2555                    "{allowed} is one of this registration's directories but is not writable, \
2556                     so the daemon cannot use it"
2557                ),
2558            });
2559        }
2560    }
2561    if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
2562        controls.push(format!(
2563            "{subject} names exactly the required application-data and credential paths"
2564        ));
2565    }
2566}
2567
2568/// Compares a unit's capability bounding set with the one it should keep, as
2569/// sets: a capability it lacks is a shortfall, one it adds is an excess.
2570fn review_capabilities(
2571    expected: &str,
2572    wanted: &str,
2573    actual: &str,
2574    controls: &mut Vec<String>,
2575    findings: &mut Vec<PrivilegeFinding>,
2576) {
2577    let subject = "CapabilityBoundingSet";
2578    // `~` inverts the list, keeping every capability *except* those named.
2579    if actual.trim_start().starts_with('~') {
2580        findings.push(PrivilegeFinding {
2581            kind: FindingKind::Excess,
2582            subject: subject.to_string(),
2583            detail: format!("is `{actual}`, which keeps every capability it does not name"),
2584        });
2585        return;
2586    }
2587    let wanted: BTreeSet<&str> = wanted.split_whitespace().collect();
2588    let actual: BTreeSet<&str> = actual.split_whitespace().collect();
2589    let extra = actual.difference(&wanted).copied().collect::<Vec<_>>();
2590    let missing = wanted.difference(&actual).copied().collect::<Vec<_>>();
2591    if !extra.is_empty() {
2592        findings.push(PrivilegeFinding {
2593            kind: FindingKind::Excess,
2594            subject: subject.to_string(),
2595            detail: format!(
2596                "keeps {}, which the requirement does not ask for",
2597                extra.join(" ")
2598            ),
2599        });
2600    }
2601    if !missing.is_empty() {
2602        findings.push(PrivilegeFinding {
2603            kind: FindingKind::Shortfall,
2604            subject: subject.to_string(),
2605            detail: format!("drops {}, which the daemon needs", missing.join(" ")),
2606        });
2607    }
2608    if extra.is_empty() && missing.is_empty() {
2609        controls.push(expected.to_string());
2610    }
2611}
2612
2613/// The one inbound-surface rule, applied to whichever platform names it.
2614///
2615/// `07-security.md` handling rule 2: *"The product exposes no inbound HTTP,
2616/// socket, or RPC surface anywhere, in any command."* A definition that asks the
2617/// service manager to open a socket or publish a Mach service on the daemon's
2618/// behalf would create exactly that surface without a line of product code
2619/// changing, which is why it is checked here and not only in review.
2620fn review_inbound_surface(
2621    text: &str,
2622    markers: &[(&str, &str)],
2623    controls: &mut Vec<String>,
2624    findings: &mut Vec<PrivilegeFinding>,
2625) {
2626    let mut clean = true;
2627    for (marker, detail) in markers {
2628        if text.contains(marker) {
2629            clean = false;
2630            findings.push(PrivilegeFinding {
2631                kind: FindingKind::Excess,
2632                subject: (*marker).to_string(),
2633                detail: (*detail).to_string(),
2634            });
2635        }
2636    }
2637    if clean {
2638        controls.push(
2639            "no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
2640        );
2641    }
2642}
2643
2644fn review_systemd(
2645    text: &str,
2646    plan: &InstallPlan,
2647    controls: &mut Vec<String>,
2648    findings: &mut Vec<PrivilegeFinding>,
2649) {
2650    let directives = ini_directives(text, "Service");
2651    for expected in systemd_hardening(plan.start_mode()) {
2652        let (key, value) = expected
2653            .split_once('=')
2654            .expect("every hardening directive is written as key=value");
2655        match directives.get(key) {
2656            Some(actual) if key == "CapabilityBoundingSet" => {
2657                review_capabilities(expected, value, actual, controls, findings);
2658            }
2659            Some(actual) if actual == value => controls.push((*expected).to_string()),
2660            Some(actual) => findings.push(PrivilegeFinding {
2661                kind: FindingKind::Excess,
2662                subject: key.to_string(),
2663                detail: format!(
2664                    "is `{actual}`, not `{value}`, so the unit keeps authority the \
2665                                 requirement does not ask for"
2666                ),
2667            }),
2668            None => findings.push(PrivilegeFinding {
2669                kind: FindingKind::Excess,
2670                subject: key.to_string(),
2671                detail: format!(
2672                    "is absent, so the unit inherits systemd's default rather than `{value}`"
2673                ),
2674            }),
2675        }
2676    }
2677
2678    match directives.get("ReadWritePaths") {
2679        Some(value) => {
2680            let listed = split_quoted(value);
2681            review_writable_paths(
2682                DefinitionKind::SystemdUnit,
2683                "ReadWritePaths",
2684                &listed,
2685                plan,
2686                controls,
2687                findings,
2688            );
2689        }
2690        None => findings.push(PrivilegeFinding {
2691            kind: FindingKind::Shortfall,
2692            subject: "ReadWritePaths".to_string(),
2693            detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
2694                .to_string(),
2695        }),
2696    }
2697
2698    // `User=` on a system unit is an escalation only in the other direction, so
2699    // what is checked is the pair systemd actually treats as privileged.
2700    if directives.contains_key("PrivateUsers")
2701        && directives.get("PrivateUsers") == Some(&"no".to_string())
2702    {
2703        findings.push(PrivilegeFinding {
2704            kind: FindingKind::Excess,
2705            subject: "PrivateUsers".to_string(),
2706            detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
2707                .to_string(),
2708        });
2709    }
2710
2711    review_inbound_surface(
2712        text,
2713        &[
2714            (
2715                "ListenStream=",
2716                "asks systemd to open a listening socket for this service, which \
2717                 07-security.md rule 2 forbids the product to have",
2718            ),
2719            (
2720                "ListenDatagram=",
2721                "asks systemd to open a listening socket for this service, which \
2722                 07-security.md rule 2 forbids the product to have",
2723            ),
2724        ],
2725        controls,
2726        findings,
2727    );
2728}
2729
2730fn review_launchd(
2731    text: &str,
2732    plan: &InstallPlan,
2733    controls: &mut Vec<String>,
2734    findings: &mut Vec<PrivilegeFinding>,
2735) {
2736    // A scheduling class is not an authority, so no value here is an excess.
2737    // Anything but `Interactive` is a shortfall: launchd applies it to every
2738    // runner the daemon starts. See `LAUNCHD_PROCESS_TYPE`.
2739    match plist_string_value(text, "ProcessType") {
2740        Some(value) if value == LAUNCHD_PROCESS_TYPE => {
2741            controls.push(format!("ProcessType={LAUNCHD_PROCESS_TYPE}"));
2742        }
2743        found => findings.push(PrivilegeFinding {
2744            kind: FindingKind::Shortfall,
2745            subject: "ProcessType".to_string(),
2746            detail: format!(
2747                "is {}, and launchd applies it to every runner the daemon starts, so their jobs \
2748                 run below normal priority. The daemon rewrites it to `{LAUNCHD_PROCESS_TYPE}` \
2749                 when it next starts with no runner; `service status` says how to restart it",
2750                found.map_or_else(
2751                    || "absent (launchd's `Standard`)".to_string(),
2752                    |value| format!("`{value}`")
2753                )
2754            ),
2755        }),
2756    }
2757
2758    match plan.start_mode() {
2759        StartMode::Boot => {
2760            if plist_bool_value(text, "SessionCreate") == Some(true) {
2761                findings.push(PrivilegeFinding {
2762                    kind: FindingKind::Excess,
2763                    subject: "SessionCreate".to_string(),
2764                    detail: "asks launchd to create a security session for a job that runs \
2765                             outside every login session and has no use for one"
2766                        .to_string(),
2767                });
2768            } else {
2769                controls.push("SessionCreate is not requested".to_string());
2770            }
2771            match plist_string_value(text, "UserName").as_deref() {
2772                Some("root") => {
2773                    controls.push("UserName=root, stated rather than inherited".to_string())
2774                }
2775                Some(other) => findings.push(PrivilegeFinding {
2776                    kind: FindingKind::Shortfall,
2777                    subject: "UserName".to_string(),
2778                    detail: format!(
2779                        "is `{other}`, which cannot unlock the System Keychain: \
2780                         /var/db/SystemKey is root-only, so the daemon would start and then \
2781                         find no credential"
2782                    ),
2783                }),
2784                None => findings.push(PrivilegeFinding {
2785                    kind: FindingKind::Shortfall,
2786                    subject: "UserName".to_string(),
2787                    detail: "is absent, so the account is launchd's implicit default and this \
2788                             review cannot confirm it"
2789                        .to_string(),
2790                }),
2791            }
2792        }
2793        StartMode::Login => {
2794            if let Some(named) = plist_string_value(text, "UserName") {
2795                findings.push(PrivilegeFinding {
2796                    kind: FindingKind::Excess,
2797                    subject: "UserName".to_string(),
2798                    detail: format!(
2799                        "names `{named}` in a LaunchAgent, which already runs as the operator; \
2800                         naming an account here asks launchd for a switch a login-mode \
2801                         registration has no reason to want"
2802                    ),
2803                });
2804            } else {
2805                controls
2806                    .push("no UserName: the agent runs as the operator and no other".to_string());
2807            }
2808        }
2809    }
2810
2811    review_inbound_surface(
2812        text,
2813        &[
2814            (
2815                "<key>Sockets</key>",
2816                "asks launchd to open a socket for this job, which 07-security.md rule 2 \
2817                 forbids the product to have",
2818            ),
2819            (
2820                "<key>MachServices</key>",
2821                "publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
2822                 forbids the product to have",
2823            ),
2824        ],
2825        controls,
2826        findings,
2827    );
2828}
2829
2830fn review_scheduled_task(
2831    text: &str,
2832    controls: &mut Vec<String>,
2833    findings: &mut Vec<PrivilegeFinding>,
2834) {
2835    match xml_value(text, "RunLevel").as_deref() {
2836        Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
2837        Some(other) => findings.push(PrivilegeFinding {
2838            kind: FindingKind::Excess,
2839            subject: "RunLevel".to_string(),
2840            detail: format!(
2841                "is `{other}`, so the task runs with an elevated token whenever the operator is \
2842                 an administrator"
2843            ),
2844        }),
2845        None => findings.push(PrivilegeFinding {
2846            kind: FindingKind::Excess,
2847            subject: "RunLevel".to_string(),
2848            detail: "is absent, so Task Scheduler decides the token rather than the definition"
2849                .to_string(),
2850        }),
2851    }
2852
2853    match xml_value(text, "LogonType").as_deref() {
2854        Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
2855        Some(other) => findings.push(PrivilegeFinding {
2856            kind: FindingKind::Excess,
2857            subject: "LogonType".to_string(),
2858            detail: format!(
2859                "is `{other}`, which asks Windows to store or synthesise a credential for this \
2860                 task; an interactive token needs neither"
2861            ),
2862        }),
2863        None => findings.push(PrivilegeFinding {
2864            kind: FindingKind::Shortfall,
2865            subject: "LogonType".to_string(),
2866            detail: "is absent, so this review cannot confirm that no credential is stored"
2867                .to_string(),
2868        }),
2869    }
2870}
2871
2872fn review_windows_service(
2873    text: &str,
2874    plan: &InstallPlan,
2875    controls: &mut Vec<String>,
2876    findings: &mut Vec<PrivilegeFinding>,
2877) {
2878    let directives = ini_directives(text, "windows-service");
2879
2880    match directives.get("ServiceType").map(String::as_str) {
2881        Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
2882        Some(other) => findings.push(PrivilegeFinding {
2883            kind: FindingKind::Excess,
2884            subject: "ServiceType".to_string(),
2885            detail: format!(
2886                "is `{other}`; an interactive or shared-process service reaches further than a \
2887                 daemon that only talks to GitHub over HTTPS"
2888            ),
2889        }),
2890        None => findings.push(PrivilegeFinding {
2891            kind: FindingKind::Shortfall,
2892            subject: "ServiceType".to_string(),
2893            detail: "is absent, so this review cannot confirm the service is not interactive"
2894                .to_string(),
2895        }),
2896    }
2897
2898    // The account is not a free choice on Windows: `d2`'s DACL decides it. What
2899    // the review can check is that the definition names the one account that
2900    // DACL admits, and no broader one.
2901    match directives.get("Account").map(String::as_str) {
2902        Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
2903            controls.push(format!(
2904                "Account={account}: the only stock account the machine-scoped store's DACL \
2905                 (SY, BA, OW) admits"
2906            ));
2907        }
2908        Some(other) => findings.push(PrivilegeFinding {
2909            kind: FindingKind::Shortfall,
2910            subject: "Account".to_string(),
2911            detail: format!(
2912                "is `{other}`, which the machine-scoped store's DACL does not name, so the \
2913                 daemon would start and then find no credential. Widening that DACL is not this \
2914                 registration's to do: an ACE reaching `{other}` would also reach every other \
2915                 service running under it"
2916            ),
2917        }),
2918        None => findings.push(PrivilegeFinding {
2919            kind: FindingKind::Shortfall,
2920            subject: "Account".to_string(),
2921            detail: "is absent, so this review cannot confirm which account was registered"
2922                .to_string(),
2923        }),
2924    }
2925
2926    match directives.get("ReadWritePaths") {
2927        Some(value) => {
2928            let listed = split_quoted(value);
2929            review_writable_paths(
2930                DefinitionKind::WindowsService,
2931                "ReadWritePaths",
2932                &listed,
2933                plan,
2934                controls,
2935                findings,
2936            );
2937        }
2938        None => findings.push(PrivilegeFinding {
2939            kind: FindingKind::Shortfall,
2940            subject: "ReadWritePaths".to_string(),
2941            detail: "is absent, so the directories the service was installed against are not \
2942                     recorded"
2943                .to_string(),
2944        }),
2945    }
2946}
2947
2948// -- parsing helpers ---------------------------------------------------------
2949
2950/// Reads `key=value` lines out of one `[section]` of an INI-shaped document.
2951///
2952/// systemd's unit format and this module's Windows descriptor are both this
2953/// shape. A repeated key takes its last value, which is systemd's own rule for
2954/// every directive here.
2955fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
2956    let mut out = BTreeMap::new();
2957    let mut inside = false;
2958    for line in text.lines() {
2959        let line = line.trim();
2960        if line.starts_with('[') && line.ends_with(']') {
2961            inside = &line[1..line.len() - 1] == section;
2962            continue;
2963        }
2964        if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2965            continue;
2966        }
2967        if let Some((key, value)) = line.split_once('=') {
2968            out.insert(key.trim().to_string(), value.trim().to_string());
2969        }
2970    }
2971    out
2972}
2973
2974/// The command line in a systemd `ExecStart=`, without the prefixes systemd
2975/// reads off its front (`-`, `@`, `:`, `+`, `!`, `!!`).
2976///
2977/// A drop-in may carry any of them, and left in place they become part of the
2978/// executable's path. With `@` the second word is the `argv[0]` handed to the
2979/// process rather than an argument, so it goes as well.
2980#[cfg(any(test, all(unix, not(target_os = "macos"))))]
2981fn systemd_exec_command_line(value: &str) -> String {
2982    let value = value.trim();
2983    let command = value.trim_start_matches(['-', '@', ':', '+', '!']);
2984    if !value[..value.len() - command.len()].contains('@') {
2985        return command.to_string();
2986    }
2987    let mut words = split_command_line(command);
2988    if words.len() > 1 {
2989        words.remove(1);
2990    }
2991    words
2992        .iter()
2993        .map(|word| quote_argument(word))
2994        .collect::<Vec<_>>()
2995        .join(" ")
2996}
2997
2998/// Parses a systemd time span such as `15`, `15s`, `500ms`, `1min 30s` or
2999/// `2h`. A bare number is seconds. `None` for `infinity`, an empty value, or
3000/// anything systemd would not accept either.
3001#[cfg(any(test, all(unix, not(target_os = "macos"))))]
3002fn systemd_timespan(value: &str) -> Option<Duration> {
3003    let mut rest = value.trim();
3004    if rest.is_empty() || rest == "infinity" {
3005        return None;
3006    }
3007    let mut seconds = 0f64;
3008    while !rest.is_empty() {
3009        let digits = rest
3010            .find(|c: char| !(c.is_ascii_digit() || c == '.'))
3011            .unwrap_or(rest.len());
3012        if digits == 0 {
3013            return None;
3014        }
3015        let number: f64 = rest[..digits].parse().ok()?;
3016        rest = rest[digits..].trim_start();
3017        let letters = rest
3018            .find(|c: char| !c.is_alphabetic())
3019            .unwrap_or(rest.len());
3020        let scale = match &rest[..letters] {
3021            "" | "s" | "sec" | "second" | "seconds" => 1.0,
3022            "us" | "usec" | "\u{3bc}s" | "\u{b5}s" => 1e-6,
3023            "ms" | "msec" => 1e-3,
3024            "m" | "min" | "minute" | "minutes" => 60.0,
3025            "h" | "hr" | "hour" | "hours" => 3_600.0,
3026            "d" | "day" | "days" => 86_400.0,
3027            "w" | "week" | "weeks" => 604_800.0,
3028            "M" | "month" | "months" => 2_629_800.0,
3029            "y" | "year" | "years" => 31_557_600.0,
3030            _ => return None,
3031        };
3032        seconds += number * scale;
3033        rest = rest[letters..].trim_start();
3034    }
3035    Duration::try_from_secs_f64(seconds).ok()
3036}
3037
3038/// Splits a whitespace-separated list that may contain arguments quoted by
3039/// [`quote_argument`].
3040fn split_quoted(value: &str) -> Vec<String> {
3041    let mut out = Vec::new();
3042    let mut rest = value.trim();
3043    while !rest.is_empty() {
3044        if rest.starts_with('"') {
3045            // `executable_from_command_line` already implements Windows' quoting
3046            // rules; reuse it rather than write a second, subtly different one.
3047            if let Some(parsed) = executable_from_command_line(rest) {
3048                out.push(parsed.to_string_lossy().into_owned());
3049            }
3050            // Advance past the closing quote.
3051            let mut depth = 0usize;
3052            let mut end = rest.len();
3053            for (index, c) in rest.char_indices() {
3054                match c {
3055                    '\\' => depth += 1,
3056                    '"' => {
3057                        if depth.is_multiple_of(2) && index > 0 {
3058                            end = index + 1;
3059                            break;
3060                        }
3061                        depth = 0;
3062                    }
3063                    _ => depth = 0,
3064                }
3065            }
3066            rest = rest[end..].trim_start();
3067        } else {
3068            let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
3069            out.push(rest[..end].to_string());
3070            rest = rest[end..].trim_start();
3071        }
3072    }
3073    out
3074}
3075
3076/// The text between the **first** `<tag>` and its `</tag>`, unescaped, for the
3077/// small, known documents this module renders. Not a general XML parser and
3078/// does not pretend to be one.
3079///
3080/// "First" matters for a document that repeats a tag: a Task Scheduler task has
3081/// an `<Enabled>` in its trigger *and* one in its settings, so a caller after
3082/// the second one passes the slice that starts at `<Settings>`.
3083///
3084/// `pub(crate)` rather than private because [`crate::wsl::task`] reads back a
3085/// second kind of Task Scheduler document; one reader for both is the same
3086/// argument [`xml_escape`] makes for one escaper.
3087pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
3088    let open = format!("<{tag}>");
3089    let close = format!("</{tag}>");
3090    let start = text.find(&open)? + open.len();
3091    let end = text[start..].find(&close)? + start;
3092    Some(xml_unescape(text[start..end].trim()))
3093}
3094
3095/// Whether Task Scheduler will launch a login task automatically.
3096///
3097/// Its `/Query /XML` output normalises away values that equal the schema
3098/// default. In particular, an enabled task commonly has no `<Enabled>` node at
3099/// all, so only an explicit `false` means disabled.
3100#[cfg(any(windows, test))]
3101fn windows_login_task_starts_automatically(document: &str) -> bool {
3102    document.contains("<LogonTrigger>")
3103        && xml_value(document, "Enabled").as_deref() != Some("false")
3104}
3105
3106/// The `<string>` that follows `<key>key</key>` in a property list.
3107fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
3108    let marker = format!("<key>{key}</key>");
3109    let start = text.find(&marker)? + marker.len();
3110    Some(text[start..].trim_start())
3111}
3112
3113pub fn plist_string_value(text: &str, key: &str) -> Option<String> {
3114    let rest = plist_value_after_key(text, key)?;
3115    if !rest.starts_with("<string>") {
3116        return None;
3117    }
3118    xml_value(rest, "string")
3119}
3120
3121fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
3122    let rest = plist_value_after_key(text, key)?;
3123    if rest.starts_with("<true/>") {
3124        Some(true)
3125    } else if rest.starts_with("<false/>") {
3126        Some(false)
3127    } else {
3128        None
3129    }
3130}
3131
3132// ---------------------------------------------------------------------------
3133// The install record
3134// ---------------------------------------------------------------------------
3135
3136/// The version of [`InstallRecord`] on disk.
3137///
3138/// Bumped when a record written by an older version can no longer be read.
3139/// `service status` reports an unreadable record as a problem with a remedy
3140/// rather than failing, because the remedy — uninstall and install again —
3141/// touches no configuration, no secret and no cache.
3142pub const RECORD_SCHEMA_VERSION: u32 = 1;
3143
3144/// What `service install` wrote down, and what `service status` reads back.
3145///
3146/// This is the *record* half of `05-infrastructure.md` item 6. The service
3147/// manager also knows the binary path, and the two are compared: a registration
3148/// whose command line no longer matches the record is a registration something
3149/// else has edited, and saying so is more useful than picking one of them to
3150/// believe.
3151///
3152/// It holds no credential. The nearest thing is the *location* of the secret
3153/// store, which `d2` publishes for `host show` to print, and even that is
3154/// derived rather than stored.
3155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3156pub struct InstallRecord {
3157    /// [`RECORD_SCHEMA_VERSION`] at the time of writing.
3158    pub schema_version: u32,
3159    /// What the operating system calls the registration.
3160    pub service_name: String,
3161    /// Which service manager holds it.
3162    pub manager: String,
3163    /// Boot or login.
3164    pub start_mode: StartMode,
3165    /// The account it runs under.
3166    pub account: ServiceAccount,
3167    /// **The resolved absolute path of the binary at install time.** Item 6.
3168    pub binary: PathBuf,
3169    /// Where [`Self::binary`] was copied from, when it is a copy.
3170    ///
3171    /// `#[serde(default)]` rather than a schema bump: a record written before
3172    /// this existed is still readable, and reads as `None`. That is not a
3173    /// silent downgrade — `None` means the registration names a package
3174    /// manager's own file, which is the layout that cannot be upgraded while
3175    /// the service runs, and `service status` says so by name.
3176    #[serde(default)]
3177    pub source_binary: Option<PathBuf>,
3178    /// The arguments it was registered with.
3179    pub arguments: Vec<String>,
3180    /// The restart-on-failure delay.
3181    pub restart_delay_secs: u64,
3182    /// How long it must run before the failure count resets.
3183    pub restart_reset_secs: u64,
3184    /// The rotating diagnostic log's stem. Item 4.
3185    pub log_file: PathBuf,
3186    /// Whether the manager was asked not to start this by itself. Always
3187    /// `false` for a registration an operator made; see
3188    /// [`InstallRequest::started_on_demand`].
3189    ///
3190    /// `#[serde(default)]` so that a record missing the field is rejected by
3191    /// the schema check below, with its remedy, rather than by a parse error
3192    /// that names a field an operator has never heard of.
3193    #[serde(default)]
3194    pub starts_on_demand: bool,
3195    /// Where the platform's definition file was written, when there is one.
3196    pub definition_path: Option<PathBuf>,
3197    /// When the registration was made.
3198    pub installed_at: DateTime<Utc>,
3199    /// Which build of the product made it.
3200    pub installed_by_version: String,
3201    // A TOML table has to follow every scalar at its level, so this field is
3202    // last by necessity rather than by taste.
3203    /// The four directories the registration was installed against. Item 2's
3204    /// *"configured cache and runtime directories"*.
3205    pub directories: ServiceDirectories,
3206}
3207
3208impl InstallRecord {
3209    /// Where the record lives, given this host's directories.
3210    #[must_use]
3211    pub fn path(paths: &AppPaths) -> PathBuf {
3212        paths.config_dir().join(RECORD_FILE)
3213    }
3214
3215    /// Builds the record for a plan that has just been applied.
3216    #[must_use]
3217    pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
3218        Self {
3219            schema_version: RECORD_SCHEMA_VERSION,
3220            service_name: plan.identity().name().to_string(),
3221            manager: definition.kind().manager().to_string(),
3222            start_mode: plan.start_mode(),
3223            account: plan.account().clone(),
3224            binary: plan.binary().to_path_buf(),
3225            arguments: plan
3226                .arguments()
3227                .iter()
3228                .map(|argument| argument.to_string_lossy().into_owned())
3229                .collect(),
3230            restart_delay_secs: plan.restart().delay().as_secs(),
3231            restart_reset_secs: plan.restart().reset_after().as_secs(),
3232            starts_on_demand: plan.is_on_demand(),
3233            source_binary: plan.source_binary().map(Path::to_path_buf),
3234            log_file: plan.directories().log_file(),
3235            definition_path: definition.install_path().map(Path::to_path_buf),
3236            installed_at: at,
3237            installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
3238            directories: plan.directories().clone(),
3239        }
3240    }
3241
3242    /// The plan this record describes, as this build would register it.
3243    ///
3244    /// The directories, binary, arguments and restart policy are the recorded
3245    /// ones; everything else -- hardening, the secret store's guard -- is what
3246    /// this build renders. That difference is the point: it is how a
3247    /// registration written by an older build is compared against, and
3248    /// repaired to, the current definition.
3249    #[must_use]
3250    pub fn plan(&self, identity: ServiceIdentity) -> InstallPlan {
3251        let plan = InstallPlan::unchecked(
3252            identity,
3253            self.start_mode,
3254            self.binary.clone(),
3255            self.directories.clone(),
3256        )
3257        .with_arguments(self.arguments.clone())
3258        .with_restart(self.restart());
3259        let plan = if self.starts_on_demand {
3260            plan.started_on_demand()
3261        } else {
3262            plan
3263        };
3264        match crate::secrets::PlatformSecretStore::for_start_mode(self.start_mode) {
3265            Ok(store) => plan.with_secret_guard(store.guard()),
3266            Err(_) => plan,
3267        }
3268    }
3269
3270    /// The restart policy this record describes, or [`RestartPolicy::default`]
3271    /// when the recorded numbers are outside the supported range — which can
3272    /// only happen to a record something else has edited.
3273    #[must_use]
3274    pub fn restart(&self) -> RestartPolicy {
3275        RestartPolicy::new(
3276            Duration::from_secs(self.restart_delay_secs),
3277            Duration::from_secs(self.restart_reset_secs),
3278        )
3279        .unwrap_or_default()
3280    }
3281
3282    /// Reads the record, or reports that there is none.
3283    ///
3284    /// `Ok(None)` means no registration was made by this product on this host.
3285    ///
3286    /// # Errors
3287    ///
3288    /// [`ServiceError::Record`] when the file exists and cannot be read,
3289    /// [`ServiceError::RecordNotPermitted`] when it exists and this account may
3290    /// not read it, and [`ServiceError::RecordUnreadable`] when it is not a
3291    /// record this version understands.
3292    pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
3293        let path = Self::path(paths);
3294        let text = match std::fs::read_to_string(&path) {
3295            Ok(text) => text,
3296            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3297            // Told apart from every other read failure because `status` carries
3298            // on through this one alone. See `ServiceError::RecordNotPermitted`.
3299            Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
3300                return Err(ServiceError::RecordNotPermitted {
3301                    path,
3302                    detail: error.to_string(),
3303                });
3304            }
3305            Err(error) => {
3306                return Err(ServiceError::Record {
3307                    operation: "read",
3308                    path,
3309                    detail: error.to_string(),
3310                });
3311            }
3312        };
3313        let record: Self =
3314            toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
3315                path: path.clone(),
3316                detail: error.to_string(),
3317            })?;
3318        if record.schema_version != RECORD_SCHEMA_VERSION {
3319            return Err(ServiceError::RecordUnreadable {
3320                path,
3321                detail: format!(
3322                    "it declares schema version {} and this build reads version {}",
3323                    record.schema_version, RECORD_SCHEMA_VERSION
3324                ),
3325            });
3326        }
3327        Ok(Some(record))
3328    }
3329
3330    /// Writes the record, replacing whatever was there.
3331    ///
3332    /// # Errors
3333    ///
3334    /// [`ServiceError::Record`].
3335    pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
3336        use std::io::Write as _;
3337
3338        let path = Self::path(paths);
3339        let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
3340            operation: "encode",
3341            path: path.clone(),
3342            detail: error.to_string(),
3343        })?;
3344        if let Some(parent) = path.parent() {
3345            std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
3346                operation: "write",
3347                path: path.clone(),
3348                detail: error.to_string(),
3349            })?;
3350        }
3351        let parent = path.parent().ok_or_else(|| ServiceError::Record {
3352            operation: "write",
3353            path: path.clone(),
3354            detail: "the record path has no parent directory".to_string(),
3355        })?;
3356        let mut temporary =
3357            tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
3358                operation: "write",
3359                path: path.clone(),
3360                detail: error.to_string(),
3361            })?;
3362        temporary
3363            .write_all(text.as_bytes())
3364            .and_then(|()| temporary.as_file().sync_all())
3365            .map_err(|error| ServiceError::Record {
3366                operation: "write",
3367                path: path.clone(),
3368                detail: error.to_string(),
3369            })?;
3370        // ------------------------------------------------------------------
3371        // THE RECORD IS READABLE BY THE ACCOUNT WHOSE DIRECTORY IT IS IN.
3372        // ------------------------------------------------------------------
3373        // `NamedTempFile` creates at `0600`, which is right for a temporary
3374        // file and wrong for this one. A boot-mode `service install` runs under
3375        // `sudo`, so `0600` made the record `root`-owned and unreadable by the
3376        // operator — and `service status`, which asks no privilege of anybody,
3377        // failed on their own host with `Permission denied` and a remedy that
3378        // named the command that had just failed.
3379        //
3380        // `0644` does not widen anything: `RECORD_FILE`'s own documentation is
3381        // that this is *"non-secret TOML"* holding no credential, and
3382        // `AppPaths::create_all` puts it in a `0700` directory, so no other
3383        // local account can reach it whatever its own mode says. It is the same
3384        // arrangement the rotating diagnostics already have.
3385        #[cfg(unix)]
3386        {
3387            use std::os::unix::fs::PermissionsExt as _;
3388
3389            temporary
3390                .as_file()
3391                .set_permissions(std::fs::Permissions::from_mode(0o644))
3392                .map_err(|error| ServiceError::Record {
3393                    operation: "write",
3394                    path: path.clone(),
3395                    detail: error.to_string(),
3396                })?;
3397        }
3398        temporary
3399            .persist(&path)
3400            .map(|_| ())
3401            .map_err(|error| ServiceError::Record {
3402                operation: "write",
3403                path,
3404                detail: error.error.to_string(),
3405            })
3406    }
3407
3408    /// Removes the record. Returns whether there was one.
3409    ///
3410    /// # Errors
3411    ///
3412    /// [`ServiceError::Record`].
3413    pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
3414        let path = Self::path(paths);
3415        match std::fs::remove_file(&path) {
3416            Ok(()) => Ok(true),
3417            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
3418            Err(error) => Err(ServiceError::Record {
3419                operation: "remove",
3420                path,
3421                detail: error.to_string(),
3422            }),
3423        }
3424    }
3425}
3426
3427// ---------------------------------------------------------------------------
3428// The last successful GitHub contact
3429// ---------------------------------------------------------------------------
3430
3431/// The heartbeat file's own schema version.
3432const CONTACT_SCHEMA_VERSION: u32 = 1;
3433
3434#[derive(Debug, Clone, Serialize, Deserialize)]
3435struct ContactRecord {
3436    schema_version: u32,
3437    last_success: DateTime<Utc>,
3438}
3439
3440/// Records that GitHub was reached successfully, for `service status` to
3441/// report.
3442///
3443/// # The contract, for `f3` and for the agent
3444///
3445/// Journey 5 step 4 requires `service status` to report *"the last successful
3446/// GitHub contact"*, and `service status` runs in the operator's terminal while
3447/// the daemon runs in a service. They share no memory, so the fact has to be on
3448/// disk, and this is the file. **The daemon calls this after each successful
3449/// GitHub call; nothing else writes it.**
3450///
3451/// It is a single timestamp and deliberately not a log: an operator asking this
3452/// question wants to know whether the agent is alive and reaching GitHub *now*,
3453/// and a value that is minutes old answers it. Writing it is a whole-file
3454/// replace through a temporary in the same directory, so a status command can
3455/// never read a half-written timestamp.
3456///
3457/// # Errors
3458///
3459/// [`ServiceError::Record`] when `state/` cannot be written.
3460pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3461    write_state_record(
3462        &contact_path(paths),
3463        &ContactRecord {
3464            schema_version: CONTACT_SCHEMA_VERSION,
3465            last_success: at,
3466        },
3467    )
3468}
3469
3470/// Reads the last successful GitHub contact, or reports that none was recorded.
3471///
3472/// `Ok(None)` is the honest answer on a host whose agent has never run, and it
3473/// is what `service status` prints rather than a zero timestamp.
3474///
3475/// # Errors
3476///
3477/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3478/// A malformed heartbeat is reported rather than treated as absence: absence
3479/// means *"the agent has never reached GitHub"*, and reporting a parse failure
3480/// as that would be a wrong answer to the question Journey 5 asks.
3481pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3482    Ok(read_state_record::<ContactRecord>(&contact_path(paths))?.map(|record| record.last_success))
3483}
3484
3485/// Where the heartbeat lives.
3486#[must_use]
3487pub fn contact_path(paths: &AppPaths) -> PathBuf {
3488    paths.state_dir().join(CONTACT_FILE)
3489}
3490
3491/// Writes a small TOML record under `state/`, through a temporary in the same
3492/// directory and a rename, so a status command never reads half of one.
3493pub(crate) fn write_state_record(path: &Path, record: &impl Serialize) -> Result<(), ServiceError> {
3494    let failed = |detail: String| ServiceError::Record {
3495        operation: "write",
3496        path: path.to_path_buf(),
3497        detail,
3498    };
3499    let text = toml::to_string_pretty(record).map_err(|error| failed(error.to_string()))?;
3500    let directory = path.parent().unwrap_or_else(|| Path::new("."));
3501    std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3502    let temporary = path.with_extension("toml.new");
3503    std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3504    std::fs::rename(&temporary, path).map_err(|error| failed(error.to_string()))
3505}
3506
3507/// Reads a record [`write_state_record`] wrote. `Ok(None)` when there is none;
3508/// one that exists and does not parse is an error, never absence.
3509pub(crate) fn read_state_record<T: serde::de::DeserializeOwned>(
3510    path: &Path,
3511) -> Result<Option<T>, ServiceError> {
3512    let failed = |detail: String| ServiceError::Record {
3513        operation: "read",
3514        path: path.to_path_buf(),
3515        detail,
3516    };
3517    let text = match std::fs::read_to_string(path) {
3518        Ok(text) => text,
3519        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3520        Err(error) => return Err(failed(error.to_string())),
3521    };
3522    toml::from_str(&text)
3523        .map(Some)
3524        .map_err(|error| failed(error.to_string()))
3525}
3526
3527/// Removes a record; one that is not there is already removed.
3528pub(crate) fn remove_state_record(path: &Path) -> Result<(), ServiceError> {
3529    match std::fs::remove_file(path) {
3530        Ok(()) => Ok(()),
3531        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3532        Err(error) => Err(ServiceError::Record {
3533            operation: "remove",
3534            path: path.to_path_buf(),
3535            detail: error.to_string(),
3536        }),
3537    }
3538}
3539
3540// ---------------------------------------------------------------------------
3541// The credential GitHub rejected
3542// ---------------------------------------------------------------------------
3543
3544#[derive(Debug, Serialize, Deserialize)]
3545#[serde(deny_unknown_fields)]
3546struct CredentialRejectionRecord {
3547    schema_version: u32,
3548    rejected_since: DateTime<Utc>,
3549}
3550
3551/// Records that GitHub rejected the daemon's credential, keeping the first
3552/// moment of an ongoing rejection.
3553///
3554/// # Why a file of its own
3555///
3556/// A rejected credential used to be visible only in the daemon's log: the
3557/// contact heartbeat simply stopped moving, `service status` still printed
3558/// `healthy`, and the TUI -- which authenticates with its own, perhaps freshly
3559/// renewed, copy -- showed nothing at all while the service started no runner
3560/// for hours. Separate from [`CONTACT_FILE`] so that an older build reading
3561/// that file is not handed a field it refuses.
3562///
3563/// # Errors
3564///
3565/// [`ServiceError::Record`] when `state/` cannot be written.
3566pub fn record_github_credential_rejected(
3567    paths: &AppPaths,
3568    at: DateTime<Utc>,
3569) -> Result<(), ServiceError> {
3570    if github_credential_rejected_since(paths)
3571        .ok()
3572        .flatten()
3573        .is_some()
3574    {
3575        return Ok(());
3576    }
3577    write_state_record(
3578        &credential_rejection_path(paths),
3579        &CredentialRejectionRecord {
3580            schema_version: CONTACT_SCHEMA_VERSION,
3581            rejected_since: at,
3582        },
3583    )
3584}
3585
3586/// Clears a recorded rejection once GitHub accepts the credential again.
3587///
3588/// # Errors
3589///
3590/// [`ServiceError::Record`] when the record exists and cannot be removed.
3591pub fn clear_github_credential_rejection(paths: &AppPaths) -> Result<(), ServiceError> {
3592    remove_state_record(&credential_rejection_path(paths))
3593}
3594
3595/// Since when GitHub has been rejecting the daemon's credential, if it is.
3596///
3597/// # Errors
3598///
3599/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3600pub fn github_credential_rejected_since(
3601    paths: &AppPaths,
3602) -> Result<Option<DateTime<Utc>>, ServiceError> {
3603    Ok(
3604        read_state_record::<CredentialRejectionRecord>(&credential_rejection_path(paths))?
3605            .map(|record| record.rejected_since),
3606    )
3607}
3608
3609/// Where the rejection record lives.
3610#[must_use]
3611pub fn credential_rejection_path(paths: &AppPaths) -> PathBuf {
3612    paths.state_dir().join(CREDENTIAL_REJECTION_FILE)
3613}
3614
3615#[derive(Debug, Serialize, Deserialize)]
3616#[serde(deny_unknown_fields)]
3617struct CredentialUnreadableRecord {
3618    schema_version: u32,
3619    unreadable_since: DateTime<Utc>,
3620}
3621
3622/// Records that the daemon could not read its stored credential, keeping the
3623/// first moment of an ongoing failure.
3624///
3625/// # Why a file of its own
3626///
3627/// A daemon that cannot read its credential exits, and the service manager
3628/// starts it again a few seconds later, for ever. `service status` reads the
3629/// registration, which is fine, and the heartbeat, which merely stops moving --
3630/// so it said `healthy` while a macOS host that had just been upgraded was
3631/// locked out of its own keychain item and started no runner. The reason was
3632/// only in the daemon's log. The daemon is the one process whose view of the
3633/// store matters, and it shares no memory with `service status`, so it says
3634/// so on disk; [`clear_credential_unreadable`] removes it as soon as a read
3635/// succeeds.
3636///
3637/// # Errors
3638///
3639/// [`ServiceError::Record`] when `state/` cannot be written.
3640pub fn record_credential_unreadable(
3641    paths: &AppPaths,
3642    at: DateTime<Utc>,
3643) -> Result<(), ServiceError> {
3644    if credential_unreadable_since(paths).ok().flatten().is_some() {
3645        return Ok(());
3646    }
3647    write_state_record(
3648        &credential_unreadable_path(paths),
3649        &CredentialUnreadableRecord {
3650            schema_version: CONTACT_SCHEMA_VERSION,
3651            unreadable_since: at,
3652        },
3653    )
3654}
3655
3656/// Clears a recorded failure once the daemon reads its credential again.
3657///
3658/// # Errors
3659///
3660/// [`ServiceError::Record`] when the record exists and cannot be removed.
3661pub fn clear_credential_unreadable(paths: &AppPaths) -> Result<(), ServiceError> {
3662    remove_state_record(&credential_unreadable_path(paths))
3663}
3664
3665/// Since when the daemon has been unable to read its stored credential, if it
3666/// has.
3667///
3668/// # Errors
3669///
3670/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3671pub fn credential_unreadable_since(
3672    paths: &AppPaths,
3673) -> Result<Option<DateTime<Utc>>, ServiceError> {
3674    Ok(
3675        read_state_record::<CredentialUnreadableRecord>(&credential_unreadable_path(paths))?
3676            .map(|record| record.unreadable_since),
3677    )
3678}
3679
3680/// Where the unreadable-credential record lives.
3681#[must_use]
3682pub fn credential_unreadable_path(paths: &AppPaths) -> PathBuf {
3683    paths.state_dir().join(CREDENTIAL_UNREADABLE_FILE)
3684}
3685
3686/// How to sign this host in again for `mode`: the exact command, in
3687/// backticks, and where to run it when that matters.
3688///
3689/// A machine-scoped store needs privilege: `sudo` on Unix, an elevated
3690/// terminal on Windows. A login-mode store on macOS is the login keychain,
3691/// which is unlocked for the desktop session only, so an SSH session cannot
3692/// write it.
3693#[must_use]
3694pub fn sign_in_instruction(mode: StartMode) -> String {
3695    let (command, place) = match mode {
3696        StartMode::Boot if cfg!(unix) => (sudo_command("auth login --start-at boot"), ""),
3697        StartMode::Boot => (
3698            "runner-manager auth login --start-at boot".to_owned(),
3699            " from an elevated terminal",
3700        ),
3701        StartMode::Login => (
3702            "runner-manager auth login --start-at login".to_owned(),
3703            MACOS_LOGIN_PLACE,
3704        ),
3705    };
3706    format!("`{command}`{place}")
3707}
3708
3709/// `sudo`, then this program, then `arguments`: a command an operator can
3710/// paste.
3711///
3712/// `sudo` searches only its own `secure_path`, which an npm or nvm
3713/// installation is not on, and the npm command is a Node shim that root's
3714/// `PATH` could not run either. So the command names this executable -- the
3715/// native binary, even behind that shim -- unless it already lives on
3716/// `secure_path`, where the bare name reads better and works.
3717#[must_use]
3718pub fn sudo_command(arguments: &str) -> String {
3719    // A test binary is not the product; its own path would only make every
3720    // remedy a test compares depend on where the build directory is.
3721    let executable = if cfg!(test) {
3722        None
3723    } else {
3724        std::env::current_exe().ok()
3725    };
3726    format!("sudo {} {arguments}", sudo_program(executable.as_deref()))
3727}
3728
3729/// How a `sudo` command names `executable`. See [`sudo_command`].
3730fn sudo_program(executable: Option<&Path>) -> String {
3731    /// Ubuntu's and Debian's `secure_path`, which other distributions'
3732    /// defaults are a subset of.
3733    const SECURE_PATH: [&str; 7] = [
3734        "/usr/local/sbin",
3735        "/usr/local/bin",
3736        "/usr/sbin",
3737        "/usr/bin",
3738        "/sbin",
3739        "/bin",
3740        "/snap/bin",
3741    ];
3742    let Some(executable) = executable else {
3743        return "runner-manager".to_owned();
3744    };
3745    if executable
3746        .parent()
3747        .is_some_and(|directory| SECURE_PATH.iter().any(|path| directory == Path::new(path)))
3748    {
3749        return "runner-manager".to_owned();
3750    }
3751    let text = executable.to_string_lossy();
3752    if text
3753        .chars()
3754        .all(|c| c.is_ascii_alphanumeric() || "/._-+@".contains(c))
3755    {
3756        text.into_owned()
3757    } else {
3758        format!("'{}'", text.replace('\'', "'\\''"))
3759    }
3760}
3761
3762/// Where a login-mode sign-in has to happen on macOS, as a phrase to follow
3763/// the command; nothing elsewhere.
3764pub const MACOS_LOGIN_PLACE: &str = if cfg!(target_os = "macos") {
3765    " in a Terminal in this Mac's desktop session (an SSH session cannot unlock the login \
3766     keychain)"
3767} else {
3768    ""
3769};
3770
3771// ---------------------------------------------------------------------------
3772// A definition written by an older build
3773// ---------------------------------------------------------------------------
3774
3775/// An installed service definition that differs from what this build renders
3776/// for the same recorded installation.
3777///
3778/// # Why this exists
3779///
3780/// An upgrade replaces the binary a registration runs and nothing else. A
3781/// systemd unit written by 0.4.7 still carried `LoadCredential=`, which hands
3782/// the daemon a copy of the credential frozen at service start, and lacked
3783/// write access to the credential store. Every release after the fix kept
3784/// running under that unit: the daemon could neither renew the token nor see
3785/// one renewed by anybody else, and stopped starting runners the moment the
3786/// frozen access token expired.
3787#[derive(Debug, Clone, PartialEq, Eq)]
3788pub struct DefinitionDrift {
3789    /// The definition file on disk.
3790    pub path: PathBuf,
3791    /// The build that wrote it, as its install record says.
3792    pub installed_by_version: String,
3793    /// What this build renders in its place.
3794    pub rendered: String,
3795    /// The start mode it was installed for.
3796    pub start_mode: StartMode,
3797    /// Which manager reads the definition.
3798    pub manager: DefinitionKind,
3799}
3800
3801/// Compares the installed definition against this build's rendering.
3802///
3803/// A systemd unit is compared byte for byte. A launchd plist is compared on
3804/// `ProcessType` alone (see [`launchd_definition_drift`]). `None` for every
3805/// other manager, for a definition that cannot be read, and for one that
3806/// already matches. The file is read through `controls`, so a double never
3807/// compares against a real host's definition.
3808fn definition_drift(
3809    controls: &dyn ControlFactory,
3810    identity: &ServiceIdentity,
3811    record: &InstallRecord,
3812) -> Option<DefinitionDrift> {
3813    let compare = match host_definition_kind(record.start_mode) {
3814        DefinitionKind::SystemdUnit => systemd_definition_drift,
3815        DefinitionKind::LaunchdPlist => launchd_definition_drift,
3816        DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => return None,
3817    };
3818    let path = record.definition_path.clone()?;
3819    let installed = controls.installed_definition(&path)?;
3820    compare(identity, record, path, &installed)
3821}
3822
3823/// A plist whose `ProcessType` is not [`LAUNCHD_PROCESS_TYPE`], with the plist
3824/// this build renders in its place.
3825///
3826/// Only that one key, unlike the systemd comparison. The daemon repairs this
3827/// drift by itself (`daemon.rs`, `converge_launchd_definition`), so a
3828/// whole-file comparison would make it rewrite and reload over every edit an
3829/// operator made to the plist. A plist that does carry the wrong value is
3830/// replaced whole, as `service install` would replace it.
3831fn launchd_definition_drift(
3832    identity: &ServiceIdentity,
3833    record: &InstallRecord,
3834    path: PathBuf,
3835    installed: &str,
3836) -> Option<DefinitionDrift> {
3837    (plist_string_value(installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE)).then(
3838        || DefinitionDrift {
3839            path,
3840            installed_by_version: record.installed_by_version.clone(),
3841            rendered: launchd_plist(&record.plan(identity.clone())),
3842            start_mode: record.start_mode,
3843            manager: DefinitionKind::LaunchdPlist,
3844        },
3845    )
3846}
3847
3848fn systemd_definition_drift(
3849    identity: &ServiceIdentity,
3850    record: &InstallRecord,
3851    path: PathBuf,
3852    installed: &str,
3853) -> Option<DefinitionDrift> {
3854    let rendered = systemd_unit(&record.plan(identity.clone()));
3855    (installed != rendered).then(|| DefinitionDrift {
3856        path,
3857        installed_by_version: record.installed_by_version.clone(),
3858        rendered,
3859        start_mode: record.start_mode,
3860        manager: DefinitionKind::SystemdUnit,
3861    })
3862}
3863
3864// ---------------------------------------------------------------------------
3865// The last runner-root refusal
3866// ---------------------------------------------------------------------------
3867
3868const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
3869
3870#[derive(Debug, Clone, Serialize, Deserialize)]
3871struct RootRefusalFile {
3872    schema_version: u32,
3873    /// Keyed by policy, because the fact is per-policy and not per-host.
3874    #[serde(default)]
3875    refusals: BTreeMap<String, RootRefusalEntry>,
3876}
3877
3878#[derive(Debug, Clone, Serialize, Deserialize)]
3879struct RootRefusalEntry {
3880    at: DateTime<Utc>,
3881    kind: String,
3882    root: String,
3883    detail: String,
3884}
3885
3886/// One policy's last runner-root refusal, as `service status` reports it.
3887#[derive(Debug, Clone, PartialEq, Eq)]
3888pub struct RunnerRootRefusal {
3889    /// The policy that placed no runner.
3890    pub policy: String,
3891    /// When it was last refused.
3892    pub at: DateTime<Utc>,
3893    /// The closed-vocabulary cause, as `RunnerRootError::kind` names it.
3894    pub kind: String,
3895    /// The root it was refused.
3896    pub root: String,
3897    /// The refusal in full, including the remediation. Not redacted: this file
3898    /// is read by a command in the operator's terminal, not by the log sink.
3899    pub detail: String,
3900}
3901
3902/// Records that one policy could not use its runner root.
3903///
3904/// # The contract, and why a file
3905///
3906/// A launch the runner root refuses fails before any attempt row exists, so the
3907/// journal cannot carry it and no screen can show it. The daemon's own log
3908/// cannot either: it redacts every field it does not allow-list and scrubs
3909/// anything path-shaped out of the ones it does, which is most of a
3910/// `RunnerRootError`. So the sentence an operator needs — the directory and the
3911/// remediation, verbatim — has nowhere to go, and the failure reads
3912/// `runner_start_failed reason=other` once per poll for as long as it lasts.
3913/// That is not hypothetical: it cost three hours.
3914///
3915/// **Keyed by policy, and that is load-bearing.** A host runs several policies
3916/// and they do not share a fate: `allocate_persistent_slot` tolerates an
3917/// unresolvable host default, so a repository with its own persistent root
3918/// places runners happily while an ephemeral policy on a withheld volume places
3919/// none. A single host-wide slot would have the working policy clear the broken
3920/// one's record on the same reconcile pass, and `service status` would report a
3921/// healthy host that had started zero runners for an entire target.
3922///
3923/// **The daemon writes this when a root refuses a launch and clears the policy's
3924/// entry when one succeeds; nothing else writes it.** It keeps the last refusal
3925/// per policy rather than a history, for the reason [`record_github_contact`] is
3926/// a single timestamp: the operator is asking whether runners can be placed
3927/// *now*.
3928///
3929/// Written whole through a temporary in the same directory, so a status command
3930/// cannot read half a record.
3931///
3932/// # Errors
3933///
3934/// [`ServiceError::Record`] when `state/` cannot be read or written.
3935pub fn record_runner_root_refusal(
3936    paths: &AppPaths,
3937    policy: &str,
3938    at: DateTime<Utc>,
3939    kind: &str,
3940    root: &str,
3941    detail: &str,
3942) -> Result<(), ServiceError> {
3943    let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
3944        schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
3945        refusals: BTreeMap::new(),
3946    });
3947    file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
3948    file.refusals.insert(
3949        policy.to_owned(),
3950        RootRefusalEntry {
3951            at,
3952            kind: kind.to_owned(),
3953            root: root.to_owned(),
3954            detail: detail.to_owned(),
3955        },
3956    );
3957    write_refusal_file(paths, &file)
3958}
3959
3960/// Clears one policy's entry, because that policy placed a runner.
3961///
3962/// Removes the file once the last entry goes, so a host that is working leaves
3963/// nothing behind for a later `service status` to report. A policy with no entry
3964/// is not an error: this is called on every successful placement, and almost
3965/// every one of those follows another success.
3966///
3967/// # Errors
3968///
3969/// [`ServiceError::Record`] when `state/` cannot be read or written.
3970pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
3971    let Some(mut file) = read_refusal_file(paths)? else {
3972        return Ok(());
3973    };
3974    if file.refusals.remove(policy).is_none() {
3975        return Ok(());
3976    }
3977    if file.refusals.is_empty() {
3978        let path = root_refusal_path(paths);
3979        return match std::fs::remove_file(&path) {
3980            Ok(()) => Ok(()),
3981            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3982            Err(error) => Err(ServiceError::Record {
3983                operation: "remove",
3984                path,
3985                detail: error.to_string(),
3986            }),
3987        };
3988    }
3989    write_refusal_file(paths, &file)
3990}
3991
3992/// Every policy whose runner root refused it and has not since succeeded.
3993///
3994/// Ordered by policy, so two runs of `service status` on an unchanged host
3995/// print the same thing in the same order.
3996///
3997/// # Errors
3998///
3999/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
4000/// Reported rather than treated as absence, for the reason
4001/// [`last_github_contact`] gives: absence means "every policy is placing
4002/// runners", and answering a parse failure with that would be a wrong answer.
4003pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
4004    Ok(read_refusal_file(paths)?
4005        .map(|file| {
4006            file.refusals
4007                .into_iter()
4008                .map(|(policy, entry)| RunnerRootRefusal {
4009                    policy,
4010                    at: entry.at,
4011                    kind: entry.kind,
4012                    root: entry.root,
4013                    detail: entry.detail,
4014                })
4015                .collect()
4016        })
4017        .unwrap_or_default())
4018}
4019
4020fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
4021    read_state_record(&root_refusal_path(paths))
4022}
4023
4024fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
4025    write_state_record(&root_refusal_path(paths), file)
4026}
4027
4028/// Where the refusals live.
4029#[must_use]
4030pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
4031    paths.state_dir().join(ROOT_REFUSAL_FILE)
4032}
4033
4034// ---------------------------------------------------------------------------
4035// The recorded binary path
4036// ---------------------------------------------------------------------------
4037
4038/// What became of the absolute path `install` recorded.
4039///
4040/// `05-infrastructure.md` item 6 in one type. Three of the four variants are
4041/// errors, and `service status` reports them as errors rather than as health —
4042/// which is the whole point, because the npm case produces a service that looks
4043/// installed, is registered, and cannot start.
4044#[derive(Debug, Clone, PartialEq, Eq)]
4045pub enum BinaryPath {
4046    /// The recorded path is a file, and the service manager still starts it.
4047    Current {
4048        /// The path.
4049        path: PathBuf,
4050    },
4051    /// **Nothing is at the recorded path.**
4052    ///
4053    /// This is the npm upgrade: `npm i -g @ivan-murzak/runner-manager` puts the binary under
4054    /// the active Node installation's global prefix, and switching Node versions
4055    /// with `nvm`, `fnm`, or `volta` moves that prefix. The service is still
4056    /// registered, still set to start at boot, and starts nothing.
4057    Missing {
4058        /// The path the record names.
4059        recorded: PathBuf,
4060    },
4061    /// Something is at the recorded path, but it is not a file the service
4062    /// manager could start.
4063    NotExecutable {
4064        /// The path the record names.
4065        recorded: PathBuf,
4066        /// What is there instead.
4067        detail: String,
4068    },
4069    /// The record and the service manager name different binaries.
4070    Diverged {
4071        /// What the record says.
4072        recorded: PathBuf,
4073        /// What the service manager is registered to start.
4074        registered: PathBuf,
4075    },
4076}
4077
4078impl BinaryPath {
4079    /// Whether this is a state `service status` must report as an error.
4080    #[must_use]
4081    pub const fn is_error(&self) -> bool {
4082        !matches!(self, Self::Current { .. })
4083    }
4084
4085    /// The path the record names, whatever state it is in.
4086    #[must_use]
4087    pub fn recorded(&self) -> &Path {
4088        match self {
4089            Self::Current { path } => path,
4090            Self::Missing { recorded }
4091            | Self::NotExecutable { recorded, .. }
4092            | Self::Diverged { recorded, .. } => recorded,
4093        }
4094    }
4095}
4096
4097impl fmt::Display for BinaryPath {
4098    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4099        match self {
4100            Self::Current { path } => write!(f, "{}", path.display()),
4101            Self::Missing { recorded } => write!(
4102                f,
4103                "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
4104                 package manager that moved the binary is the usual cause; an `npm i -g` \
4105                 installation moves with the active Node version. Run `service install` again \
4106                 from the binary that is now installed.",
4107                recorded.display()
4108            ),
4109            Self::NotExecutable { recorded, detail } => write!(
4110                f,
4111                "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
4112                 from the installed binary.",
4113                recorded.display()
4114            ),
4115            Self::Diverged {
4116                recorded,
4117                registered,
4118            } => write!(
4119                f,
4120                "{} -- STALE: the service manager is registered to start {} instead. Something \
4121                 has edited the registration since it was installed. Run `service uninstall` and \
4122                 `service install`; neither touches configuration, secrets, or the cache.",
4123                recorded.display(),
4124                registered.display()
4125            ),
4126        }
4127    }
4128}
4129
4130/// Decides what became of a recorded path.
4131///
4132/// `registered` is what the service manager says it will start, when the
4133/// platform can be asked. Order matters and is deliberate: **absence is checked
4134/// first**, because a missing binary is the failure item 6 exists for and an
4135/// operator hearing "the registration disagrees with the record" about a file
4136/// that is not there would be sent to the wrong problem.
4137#[must_use]
4138pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
4139    match std::fs::metadata(recorded) {
4140        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
4141            return BinaryPath::Missing {
4142                recorded: recorded.to_path_buf(),
4143            };
4144        }
4145        Err(error) => {
4146            return BinaryPath::NotExecutable {
4147                recorded: recorded.to_path_buf(),
4148                detail: format!("it cannot be inspected ({error})"),
4149            };
4150        }
4151        Ok(metadata) if !metadata.is_file() => {
4152            return BinaryPath::NotExecutable {
4153                recorded: recorded.to_path_buf(),
4154                detail: "what is there is not a file".to_string(),
4155            };
4156        }
4157        Ok(_) => {}
4158    }
4159    if let Some(registered) = registered
4160        && !same_path_text(&recorded.to_string_lossy(), &registered.to_string_lossy())
4161    {
4162        return BinaryPath::Diverged {
4163            recorded: recorded.to_path_buf(),
4164            registered: registered.to_path_buf(),
4165        };
4166    }
4167    BinaryPath::Current {
4168        path: recorded.to_path_buf(),
4169    }
4170}
4171
4172// ---------------------------------------------------------------------------
4173// The control seam
4174// ---------------------------------------------------------------------------
4175
4176/// What a service manager says about a registration it holds.
4177#[derive(Debug, Clone, PartialEq, Eq)]
4178pub struct Registration {
4179    /// Which manager holds it, which is also what decides the start mode:
4180    /// a Windows service is a boot registration and a Task Scheduler task is a
4181    /// login one; a LaunchDaemon and a system unit are boot, a LaunchAgent and
4182    /// a user unit are login.
4183    pub manager: DefinitionKind,
4184    /// The start mode the *domain* it was found in implies.
4185    pub start_mode: StartMode,
4186    /// The full command line, as the manager stores it.
4187    pub command_line: String,
4188    /// The account it runs under, spelled as [`ServiceAccount`] spells it, when
4189    /// the manager names a real account.
4190    ///
4191    /// `None` where the manager reports only a placeholder: a login
4192    /// registration that runs as whoever signs in, or a Task Scheduler `UserId`
4193    /// that is a raw SID. Status then reports the install record's account.
4194    pub account: Option<String>,
4195    /// Whether it is running now.
4196    pub running: bool,
4197    /// Whether it will start by itself.
4198    ///
4199    /// Distinct from [`Registration::start_mode`]: a Windows service can be
4200    /// registered in the boot domain and still be set to `demand` start, which
4201    /// is a service that exists, looks installed, and does not come back after
4202    /// a reboot.
4203    pub starts_automatically: bool,
4204    /// The restart-on-failure delay the manager reports, when it reports one.
4205    ///
4206    /// The *delay* rather than the whole [`RestartPolicy`], because that is the
4207    /// half every one of the four managers can be asked for. The failure-count
4208    /// reset window has no representation at all in Task Scheduler or launchd,
4209    /// so a `RestartPolicy` read back from them would carry one number the
4210    /// manager reported and one this module invented — and a comparison against
4211    /// an invented number is a comparison that cannot fail.
4212    pub restart_delay: Option<Duration>,
4213}
4214
4215impl Registration {
4216    /// The executable the manager will start, parsed out of the command line.
4217    #[must_use]
4218    pub fn binary(&self) -> Option<PathBuf> {
4219        executable_from_command_line(&self.command_line)
4220    }
4221}
4222
4223/// One platform's service manager, for one start-mode domain.
4224///
4225/// Every method takes the identity rather than storing it, so one control can
4226/// answer about the product's registration and about a test fixture's without
4227/// either being able to reach the other by accident.
4228pub trait ServiceControl: fmt::Debug {
4229    /// Which manager this is.
4230    fn manager(&self) -> DefinitionKind;
4231
4232    /// Registers the plan, returning the definition that was applied.
4233    ///
4234    /// # Errors
4235    ///
4236    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`].
4237    fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
4238
4239    /// Deregisters. Returns whether there was a registration to remove.
4240    ///
4241    /// **Removes the registration and nothing else.** No implementation of this
4242    /// method may delete a directory, a database, a secret, or a cache;
4243    /// `05-infrastructure.md` item 5 is a property of every backend, not a
4244    /// check somewhere above them.
4245    ///
4246    /// # Errors
4247    ///
4248    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`].
4249    fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
4250
4251    /// What the manager knows about this registration, if anything.
4252    ///
4253    /// # Errors
4254    ///
4255    /// [`ServiceError::Control`].
4256    fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
4257
4258    /// Starts it now, without waiting for the next boot or logon.
4259    ///
4260    /// # Errors
4261    ///
4262    /// [`ServiceError::Control`], [`ServiceError::NeedsElevation`], or
4263    /// [`ServiceError::NotInstalled`].
4264    fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
4265
4266    /// Stops it. Returns whether it was running.
4267    ///
4268    /// # Errors
4269    ///
4270    /// As [`ServiceControl::start`].
4271    fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
4272}
4273
4274/// Chooses the control for a start mode.
4275///
4276/// The indirection is what makes [`ServiceOperations`] testable: production
4277/// hands it [`HostControls`], every test in this file hands it
4278/// [`RecordingControls`], and neither knows the difference.
4279pub trait ControlFactory: fmt::Debug + Send + Sync {
4280    /// The control for one start-mode domain.
4281    ///
4282    /// # Errors
4283    ///
4284    /// [`ServiceError::Control`] when this host has no manager for that domain.
4285    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
4286
4287    /// The text of an installed definition file, for drift detection. `None`
4288    /// when there is none to read. The default reads nothing, so a double
4289    /// never reports the real host's unit as its own.
4290    fn installed_definition(&self, _path: &Path) -> Option<String> {
4291        None
4292    }
4293
4294    /// Whether this Mac signs somebody in after a restart, and whether
4295    /// FileVault holds it at the unlock screen. `None` where the platform has
4296    /// no such probe; the default probes nothing, for the same reason as
4297    /// [`Self::installed_definition`].
4298    fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
4299        None
4300    }
4301}
4302
4303/// The real service managers of the host this binary was built for.
4304#[derive(Debug, Clone, Copy, Default)]
4305pub struct HostControls;
4306
4307// ---------------------------------------------------------------------------
4308// The operations
4309// ---------------------------------------------------------------------------
4310
4311/// What `install` did.
4312#[derive(Debug, Clone)]
4313pub struct Installed {
4314    /// The plan that was applied, including the resolved absolute binary path.
4315    pub plan: InstallPlan,
4316    /// The definition the platform was given.
4317    pub definition: ServiceDefinition,
4318    /// The record that was written.
4319    pub record: InstallRecord,
4320    /// What the definition grants, measured against the requirement.
4321    pub review: PrivilegeReview,
4322    /// What was done to the runner root the registration will run jobs under.
4323    ///
4324    /// Named trustees rather than SIDs, so printing it adds no identity to the
4325    /// output. See [`crate::runner_root_access`].
4326    pub runner_root: RootAccessSummary,
4327    /// Whether this replaced a registration that was already there, rather than
4328    /// making a new one.
4329    ///
4330    /// Reported rather than glossed over: "Service installed" on a host that
4331    /// had one already reads as though nothing had been taken away and put
4332    /// back, and what was taken away and put back is the thing an operator
4333    /// watching a running agent wants to know about.
4334    pub replaced_existing: bool,
4335}
4336
4337/// What `uninstall` did — and, as importantly, what it did not.
4338#[derive(Debug, Clone, PartialEq, Eq)]
4339pub struct Uninstalled {
4340    /// Whether a registration was removed.
4341    pub removed_registration: bool,
4342    /// Whether the install record was removed.
4343    pub removed_record: bool,
4344    /// Whether a definition file was removed, and which.
4345    pub removed_definition: Option<PathBuf>,
4346    /// The directories that were **left exactly as they were**.
4347    ///
4348    /// `05-infrastructure.md` item 5 stated as a value rather than as a
4349    /// promise: `service uninstall` prints this list, so an operator can see
4350    /// that the configuration, the SQLite database, the stored token and the
4351    /// runner package cache are all still there.
4352    pub preserved: Vec<PathBuf>,
4353}
4354
4355impl fmt::Display for Uninstalled {
4356    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4357        if self.removed_registration {
4358            writeln!(f, "The service registration was removed.")?;
4359        } else {
4360            writeln!(f, "There was no service registration to remove.")?;
4361        }
4362        writeln!(f, "Nothing else was deleted. These are untouched:")?;
4363        for path in &self.preserved {
4364            writeln!(f, "  {}", path.display())?;
4365        }
4366        write!(
4367            f,
4368            "The stored GitHub token is untouched too; `auth logout` is what purges it."
4369        )
4370    }
4371}
4372
4373/// What `set_start_mode` did.
4374#[derive(Debug, Clone, PartialEq, Eq)]
4375pub struct StartModeChange {
4376    /// What it was.
4377    pub from: StartMode,
4378    /// What it is now.
4379    pub to: StartMode,
4380    /// `false` when the registration was already in the requested mode.
4381    pub changed: bool,
4382    /// The secret store the new mode obliges, so a caller can tell an operator
4383    /// whether the token has to move too.
4384    pub store_scope: crate::secrets::SecretScope,
4385    /// What the mode change did to the runner root's access control.
4386    ///
4387    /// A mode change moves the account the daemon runs as, and the runner root
4388    /// admits that account by name — so the two move together or the new
4389    /// registration cannot write its own workspaces.
4390    pub runner_root: RootAccessSummary,
4391}
4392
4393impl fmt::Display for StartModeChange {
4394    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4395        if !self.changed {
4396            return write!(f, "The service already starts at {}.", self.to);
4397        }
4398        write!(
4399            f,
4400            "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
4401             if the token was stored under the other scope, run `auth login` again. {}",
4402            self.to, self.from, self.store_scope, self.runner_root
4403        )
4404    }
4405}
4406
4407/// Install, uninstall, inspect, and switch start mode.
4408///
4409/// This is the whole of the library contract `f3` builds `service install`,
4410/// `service uninstall` and `service status` on. Nothing here prints; every
4411/// operation returns a value with a [`fmt::Display`] a command can write, so the
4412/// same facts are available to the TUI and to a JSON document without being
4413/// re-derived from text.
4414#[derive(Debug, Clone)]
4415pub struct ServiceOperations {
4416    paths: AppPaths,
4417    identity: ServiceIdentity,
4418    controls: std::sync::Arc<dyn ControlFactory>,
4419    runner_root: Option<LocalAbsolutePath>,
4420}
4421
4422impl ServiceOperations {
4423    /// Operates on this host's real service managers.
4424    #[must_use]
4425    pub fn on_this_host(paths: AppPaths) -> Self {
4426        Self::with_controls(
4427            paths,
4428            ServiceIdentity::product(),
4429            std::sync::Arc::new(HostControls),
4430        )
4431    }
4432
4433    /// Operates on the controls a caller supplies.
4434    ///
4435    /// Both other arguments are explicit for the same reason: the privileged
4436    /// installer tests register a real service under a
4437    /// [`ServiceIdentity::fixture`] name, against a disposable
4438    /// [`AppPaths::rooted_at`] tree, and must not be able to touch an operator's
4439    /// installation even by mistake.
4440    #[must_use]
4441    pub fn with_controls(
4442        paths: AppPaths,
4443        identity: ServiceIdentity,
4444        controls: std::sync::Arc<dyn ControlFactory>,
4445    ) -> Self {
4446        Self {
4447            paths,
4448            identity,
4449            controls,
4450            runner_root: None,
4451        }
4452    }
4453
4454    /// Points runner-root preparation at a directory the caller owns, for the
4455    /// **current account**.
4456    ///
4457    /// **Only a [`ServiceIdentity::fixture`] registration is allowed to move
4458    /// it, and the guard is here rather than at the call site.** A product
4459    /// registration ignores the override entirely and resolves
4460    /// [`crate::runner_root::default_runner_root`] itself, which is what keeps
4461    /// "custom roots are never re-ACLed" true no matter what a caller passes.
4462    ///
4463    /// It exists because the directory the product uses is
4464    /// `%SystemDrive%\rman`, and a smoke test that created and re-permissioned
4465    /// *that* would be editing the machine it runs on from outside its own
4466    /// fixture. So a fixture aims this at a temporary directory it created and
4467    /// will delete — and "and will delete" is why the override is always
4468    /// reconciled for the calling account rather than for the account the start
4469    /// mode obliges. A boot-mode root admits `SY` and `BA` only, which an
4470    /// ordinary filtered token is neither, so a test that supplied one could
4471    /// not then inspect or remove its own temporary directory.
4472    #[must_use]
4473    pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
4474        self.runner_root = Some(root);
4475        self
4476    }
4477
4478    /// The directories this operates against.
4479    #[must_use]
4480    pub const fn paths(&self) -> &AppPaths {
4481        &self.paths
4482    }
4483
4484    /// The registration this operates on.
4485    #[must_use]
4486    pub const fn identity(&self) -> &ServiceIdentity {
4487        &self.identity
4488    }
4489
4490    /// Registers `daemon run` with the operating system.
4491    ///
4492    /// The order is the contract, and each step is a requirement:
4493    ///
4494    /// 1. the four directories are created, because item 2 is about writing them;
4495    /// 2. **the single-instance lock is taken** — item 1. It is held for the
4496    ///    whole of the install rather than probed and released, so a daemon that
4497    ///    starts halfway through cannot end up racing a registration;
4498    /// 3. neither start-mode domain already holds a registration — item 6's
4499    ///    record would otherwise describe one of two;
4500    /// 4. the binary path is resolved and confirmed to be a file — item 6;
4501    /// 5. the platform registers it;
4502    /// 6. the record is written.
4503    ///
4504    /// # Errors
4505    ///
4506    /// [`ServiceError::LockHeld`] when an agent is already running,
4507    /// [`ServiceError::AlreadyInstalled`], [`ServiceError::BinaryMissing`], and
4508    /// whatever the platform reports.
4509    pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
4510        self.paths
4511            .create_all()
4512            .map_err(|source| ServiceError::Paths {
4513                source: Box::new(source),
4514            })?;
4515
4516        // Item 1. The guard lives until the end of this function; dropping it is
4517        // the release, so an early return releases it too.
4518        let _guard = self.refuse_while_an_agent_runs()?;
4519        let adopted = self.adopt_boot_installation(request.start_mode())?;
4520        // Replacing applies to this account's own registration only: over
4521        // another installation's it would re-register that service against
4522        // this account's directories.
4523        if !adopted {
4524            self.refuse_foreign("replace")?;
4525        }
4526
4527        // --------------------------------------------------------------------
4528        // AN INSTALL OVER THE SAME START MODE REPLACES; IT DOES NOT REFUSE.
4529        // --------------------------------------------------------------------
4530        // This used to refuse any existing registration, and that was wrong in
4531        // a way that took a real host down.
4532        //
4533        // `service install` is *also* how an operator moves to a new version by
4534        // hand — `install_owned_copy` says so, and the `.old` rename it does
4535        // exists precisely so that reinstalling over a **running** service
4536        // works. So the caller replaces the copy the service runs and *then*
4537        // asks to register it. Refusing at that point does not undo the swap:
4538        // the daemon is left running a binary nobody registered, the command
4539        // reports failure, and on macOS the new binary is a program the stored
4540        // credential's keychain grant does not name. Watched on 0.1.17: the
4541        // copy under `state/bin` was replaced at 00:16, `service install` said
4542        // "already registered", and the daemon crash-looped on `-25293` from
4543        // then on.
4544        //
4545        // Registering the same service twice is not what an operator is asking
4546        // for here and is not what they get: the platform is asked to drop the
4547        // registration and take it again, which is a deregister followed by a
4548        // register because that is the only thing launchd, systemd and the SCM
4549        // all support — `launchctl bootstrap` refuses a label already loaded.
4550        //
4551        // The *other* mode is still refused. Moving between boot and login
4552        // moves the registration between two service managers and changes the
4553        // account and the secret store with it; `set_start_mode` does that,
4554        // carefully, and an install is not the place for it.
4555        let (replacing, previous_was_running) = match self.find_registration()? {
4556            Some((existing, registration)) if existing == request.start_mode() => {
4557                (true, registration.running)
4558            }
4559            Some((existing, _)) => {
4560                return Err(ServiceError::AlreadyInstalled {
4561                    name: self.identity.name().to_string(),
4562                    existing,
4563                    requested: request.start_mode(),
4564                });
4565            }
4566            None => (false, false),
4567        };
4568
4569        let plan = InstallPlan::resolve(
4570            self.identity.clone(),
4571            request,
4572            ServiceDirectories::of(&self.paths),
4573        )?;
4574
4575        // Resolved before the runner root, and used after it: every step from
4576        // here on has to be able to undo the directory, and a `?` between the
4577        // preparation and the first fallible use would leave one behind with
4578        // nothing to say so.
4579        let control = self.controls.control(plan.start_mode())?;
4580
4581        // Item 8, and the one step that can refuse an otherwise valid install.
4582        // Before the platform is asked to register anything, because a
4583        // registration whose workspaces would land in a directory ordinary local
4584        // users can write is one that should never have existed — and because
4585        // undoing a directory is cheaper than undoing a service.
4586        let root = self.prepare_runner_root(plan.start_mode())?;
4587
4588        // Read before the registration is dropped, so that a replace which then
4589        // fails to take can put back what was there. A record this account
4590        // cannot read leaves nothing to reinstate, which is reported rather
4591        // than pretended: see `reinstate`.
4592        let previous = if replacing {
4593            InstallRecord::read(&self.paths).ok().flatten()
4594        } else {
4595            None
4596        };
4597        if replacing && let Err(cause) = control.uninstall(&self.identity) {
4598            return Err(undo_runner_root(&root, "install", &self.identity, cause));
4599        }
4600
4601        let definition = match control.install(&plan) {
4602            Ok(definition) => definition,
4603            Err(cause) => {
4604                // Nothing was deregistered on a first install, so there is
4605                // nothing to put back and `reinstate` says so with `Ok(())`.
4606                let restored =
4607                    self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running);
4608                return Err(rolled_back(
4609                    retained_runner_root(&root),
4610                    restored,
4611                    "install",
4612                    &self.identity,
4613                    cause,
4614                ));
4615            }
4616        };
4617        let review = review_least_privilege(&definition, &plan);
4618        let record = InstallRecord::of(&plan, &definition, Utc::now());
4619        if let Err(cause) = record.write(&self.paths) {
4620            let rollback = control
4621                .uninstall(&self.identity)
4622                .map(|_| ())
4623                .and_then(|()| {
4624                    self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4625                });
4626            return Err(rolled_back(
4627                retained_runner_root(&root),
4628                rollback,
4629                "install",
4630                &self.identity,
4631                cause,
4632            ));
4633        }
4634
4635        // Registration is not the postcondition of an install: a service the
4636        // current session still has to start by hand is only half-installed.
4637        // This matters most for a logon-triggered Windows task because its
4638        // trigger has already passed by the time an operator reinstalls it.
4639        // Start through the same control that registered the definition and
4640        // roll the whole replacement back if the manager refuses it.
4641        if let Err(cause) = control.start(&self.identity) {
4642            let remove_new = control.uninstall(&self.identity).map(|_| ());
4643            let restore_registration = remove_new.and_then(|()| {
4644                self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4645            });
4646            let restore_record = restore_registration.and_then(|()| match &previous {
4647                Some(previous) => previous.write(&self.paths),
4648                None => InstallRecord::remove(&self.paths).map(|_| ()),
4649            });
4650            return Err(rolled_back(
4651                retained_runner_root(&root),
4652                restore_record,
4653                "install and start",
4654                &self.identity,
4655                cause,
4656            ));
4657        }
4658        Ok(Installed {
4659            plan,
4660            definition,
4661            record,
4662            review,
4663            runner_root: root.summary().clone(),
4664            replaced_existing: replacing,
4665        })
4666    }
4667
4668    /// Puts back a registration this call deregistered in order to replace it.
4669    ///
4670    /// `Ok(())` when there was nothing to put back, which is the ordinary case:
4671    /// a first install deregisters nothing. When there *was* a record, the plan
4672    /// is rebuilt from it rather than from the request, because the request is
4673    /// the one that just failed.
4674    ///
4675    /// Best effort by construction — the caller passes the result to
4676    /// [`rolled_back`], which reports a failed rollback alongside the original
4677    /// cause rather than replacing it.
4678    fn reinstate(
4679        &self,
4680        control: &dyn ServiceControl,
4681        previous: Option<&InstallRecord>,
4682        was_running: bool,
4683    ) -> Result<(), ServiceError> {
4684        let Some(record) = previous else {
4685            return Ok(());
4686        };
4687        control.install(&record.plan(self.identity.clone()))?;
4688        if was_running {
4689            control.start(&self.identity)?;
4690        }
4691        Ok(())
4692    }
4693
4694    /// Deregisters, and deletes nothing else.
4695    ///
4696    /// # Errors
4697    ///
4698    /// Whatever the platform reports. A missing registration is **not** an
4699    /// error: `service uninstall` on a host that has none should say so and
4700    /// exit cleanly, because an operator running it twice has not made a
4701    /// mistake.
4702    pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
4703        let record = InstallRecord::read(&self.paths).ok().flatten();
4704        let removed_definition = record
4705            .as_ref()
4706            .and_then(|record| record.definition_path.clone());
4707
4708        let mut removed_registration = false;
4709        // Both domains, not only the recorded one. A record can be lost while a
4710        // registration survives, and leaving a registration behind because a
4711        // TOML file was deleted is exactly the state `uninstall` exists to end.
4712        //
4713        // Except another installation's: it is skipped, and when it is all
4714        // there is, the uninstall is refused and this account's record kept.
4715        let mut foreign = None;
4716        for mode in [StartMode::Boot, StartMode::Login] {
4717            let control = self.controls.control(mode)?;
4718            if let Some(directories) = control
4719                .query(&self.identity)
4720                .ok()
4721                .flatten()
4722                .and_then(|registration| self.foreign_directories(&registration))
4723            {
4724                foreign = Some(directories);
4725                continue;
4726            }
4727            if control.uninstall(&self.identity)? {
4728                removed_registration = true;
4729            }
4730        }
4731        if !removed_registration && let Some(directories) = foreign {
4732            return Err(self.foreign_refusal("uninstall", directories));
4733        }
4734        let removed_record = InstallRecord::remove(&self.paths)?;
4735        Ok(Uninstalled {
4736            removed_registration,
4737            removed_record,
4738            removed_definition: removed_definition.filter(|_| removed_registration),
4739            preserved: self
4740                .paths
4741                .all()
4742                .iter()
4743                .map(|(_, path)| (*path).to_path_buf())
4744                .collect(),
4745        })
4746    }
4747
4748    /// Switches between `boot` and `login` **without re-resolving anything**.
4749    ///
4750    /// `05-infrastructure.md` item 7. Everything the new registration carries —
4751    /// the absolute binary path, the arguments, the restart policy, the four
4752    /// directories — comes from the existing record, so the operator is not
4753    /// asked to reinstall the product and a binary that has since moved is not
4754    /// silently swapped for whichever one happens to be running this command.
4755    ///
4756    /// On Windows the registration necessarily moves between two different
4757    /// Windows facilities, because Windows has no service that starts at logon;
4758    /// see this module's documentation. That is still not a reinstall: no file
4759    /// is downloaded, replaced, or re-resolved.
4760    ///
4761    /// The caller must persist the new mode onto this host's `Host` record so
4762    /// that `host show` reports it — [`StartModeChange::to`] is the value, and
4763    /// `f1` already emits `host.service_start_mode` from that field.
4764    ///
4765    /// # Errors
4766    ///
4767    /// [`ServiceError::NotInstalled`] when there is no record to switch, and
4768    /// whatever the platform reports.
4769    pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
4770        let Some(record) = InstallRecord::read(&self.paths)? else {
4771            return Err(ServiceError::NotInstalled {
4772                name: self.identity.name().to_string(),
4773                operation: "switch the start mode of",
4774            });
4775        };
4776        let from = record.start_mode;
4777        if from == to {
4778            // Nothing moves, so nothing about the root's access control has to.
4779            // Reconciling it here would turn a no-op command into one that can
4780            // fail on a permission it does not need.
4781            return Ok(StartModeChange {
4782                from,
4783                to,
4784                changed: false,
4785                store_scope: crate::secrets::SecretScope::for_start_mode(to),
4786                runner_root: RootAccessSummary::NotApplicable,
4787            });
4788        }
4789
4790        // A real switch only: a no-op touches no service manager at all. Over
4791        // another installation's registration it would move that service.
4792        self.refuse_foreign("switch the start mode of")?;
4793
4794        #[cfg(windows)]
4795        let arguments = {
4796            let mut arguments = record.arguments.clone();
4797            arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
4798            if to == StartMode::Boot {
4799                arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
4800            }
4801            arguments
4802        };
4803        #[cfg(not(windows))]
4804        let arguments = record.arguments.clone();
4805        let plan = InstallPlan::unchecked(
4806            self.identity.clone(),
4807            to,
4808            record.binary.clone(),
4809            record.directories.clone(),
4810        )
4811        .with_arguments(arguments)
4812        .with_restart(record.restart());
4813        let plan = if record.starts_on_demand {
4814            plan.started_on_demand()
4815        } else {
4816            plan
4817        };
4818        let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
4819            Ok(store) => plan.with_secret_guard(store.guard()),
4820            Err(_) => plan,
4821        };
4822
4823        // Install the target domain before touching the live one. This makes a
4824        // failed target install a no-op from the operator's point of view and,
4825        // unlike uninstall-first ordering, never trades a working service for
4826        // an error message. Resolved before the runner root and used after it,
4827        // so that no `?` sits between preparing the directory and the first
4828        // step that knows how to undo it.
4829        let target = self.controls.control(to)?;
4830        // The domain being left, resolved here rather than where it is used for
4831        // the same reason: the only step that removes it runs after the runner
4832        // root has been prepared, and a `?` there would abandon the target
4833        // registration, the record and the directory without a word.
4834        let previous = self.controls.control(from)?;
4835
4836        // The account changes with the mode, and so must the account the runner
4837        // root admits: `04-security-recovery.md` requires the selected identity
4838        // to be *reconciled* when service mode changes, which means adding the
4839        // operator's on the way to login and dropping it again on the way back.
4840        // Before the target install, for the same reason `install` does it
4841        // first — a root that cannot be made safe must not produce a working
4842        // registration.
4843        let root = self.prepare_runner_root(to)?;
4844
4845        let definition = match target.install(&plan) {
4846            Ok(definition) => definition,
4847            Err(cause) => {
4848                return Err(undo_runner_root(
4849                    &root,
4850                    "switch start mode",
4851                    &self.identity,
4852                    cause,
4853                ));
4854            }
4855        };
4856        let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
4857        if let Err(cause) = next_record.write(&self.paths) {
4858            return Err(rolled_back(
4859                retained_runner_root(&root),
4860                target.uninstall(&self.identity),
4861                "switch start mode",
4862                &self.identity,
4863                cause,
4864            ));
4865        }
4866
4867        // Only after the target registration and its durable record exist is
4868        // it safe to remove the old domain. If that last step fails, remove the
4869        // target and put the old record back so status and reality agree.
4870        if let Err(cause) = previous.uninstall(&self.identity) {
4871            let target_rollback = target.uninstall(&self.identity);
4872            let record_rollback = record.write(&self.paths);
4873            return Err(rolled_back(
4874                retained_runner_root(&root),
4875                target_rollback.and(record_rollback),
4876                "switch start mode",
4877                &self.identity,
4878                cause,
4879            ));
4880        }
4881        Ok(StartModeChange {
4882            from,
4883            to,
4884            changed: true,
4885            store_scope: crate::secrets::SecretScope::for_start_mode(to),
4886            runner_root: root.summary().clone(),
4887        })
4888    }
4889
4890    /// Starts the registration now.
4891    ///
4892    /// # Errors
4893    ///
4894    /// [`ServiceError::NotInstalled`], or whatever the platform reports.
4895    pub fn start(&self) -> Result<(), ServiceError> {
4896        let mode = self.own_registration_mode("start")?;
4897        self.controls.control(mode)?.start(&self.identity)
4898    }
4899
4900    /// The domain holding this account's own registration, for `operation`.
4901    ///
4902    /// # Errors
4903    ///
4904    /// [`ServiceError::NotInstalled`] when there is none, and
4905    /// [`ServiceError::ForeignInstallation`] when the only one is another
4906    /// installation's.
4907    fn own_registration_mode(&self, operation: &'static str) -> Result<StartMode, ServiceError> {
4908        match self.registration_for_status()? {
4909            None => Err(ServiceError::NotInstalled {
4910                name: self.identity.name().to_string(),
4911                operation,
4912            }),
4913            Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
4914            Some((registration, None)) => Ok(registration.start_mode),
4915        }
4916    }
4917
4918    /// Stops the registration. Returns whether it was running.
4919    ///
4920    /// # Errors
4921    ///
4922    /// As [`ServiceOperations::start`].
4923    pub fn stop(&self) -> Result<bool, ServiceError> {
4924        let mode = self.own_registration_mode("stop")?;
4925        self.controls.control(mode)?.stop(&self.identity)
4926    }
4927
4928    /// Everything Journey 5 step 4 asks `service status` to report.
4929    ///
4930    /// # Errors
4931    ///
4932    /// [`ServiceError::Record`] when local state cannot be read, and whatever
4933    /// the platform reports. A *stale binary path* is deliberately **not** an
4934    /// error here: it is a reported state, because a status command that
4935    /// refused to print anything else would hide the very facts an operator
4936    /// needs in order to fix it.
4937    ///
4938    /// A record **this account may not read** is reported for the same reason
4939    /// and is the second exception. On a boot-mode host the record was written
4940    /// by `sudo service install`, and a version before this one wrote it `0600`
4941    /// — so the operator's own `service status` ended with `Permission denied`
4942    /// and printed nothing at all, on a host whose registration the service
4943    /// manager would have described perfectly well. What launchd, systemd or
4944    /// the SCM says is a separate fact from the record, and it is still worth
4945    /// having.
4946    ///
4947    /// # A registration made from another data root
4948    ///
4949    /// The service manager holds one registration per machine under one name,
4950    /// and every account on the machine sees it. When its command line names
4951    /// application-data directories that are not this account's, it is
4952    /// **another installation's service**, and comparing this account's
4953    /// install record against it would report a stale binary, a mismatched
4954    /// start mode and a secret store in the wrong scope, each with a remedy
4955    /// (`service uninstall`, `service install`, `auth login`) that, followed
4956    /// from this account, breaks a healthy service. So it is reported as what
4957    /// it is: see [`ForeignService`].
4958    pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
4959        match self.registration_for_status()? {
4960            Some((registration, Some(directories))) => {
4961                Ok(self.foreign_status(registration, directories))
4962            }
4963            found => {
4964                let (record, record_refused) = match InstallRecord::read(&self.paths) {
4965                    Ok(record) => (record, None),
4966                    Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
4967                    Err(error) => return Err(error),
4968                };
4969                self.status_at(
4970                    &self.paths,
4971                    record,
4972                    record_refused.as_ref(),
4973                    found.map(|(registration, _)| registration),
4974                )
4975            }
4976        }
4977    }
4978
4979    /// [`Self::status`] for the installation whose directories are `paths`,
4980    /// from the record already read there.
4981    fn status_at(
4982        &self,
4983        paths: &AppPaths,
4984        record: Option<InstallRecord>,
4985        record_refused: Option<&ServiceError>,
4986        found: Option<Registration>,
4987    ) -> Result<ServiceStatus, ServiceError> {
4988        let last_github_contact = last_github_contact(paths)?;
4989        let drift = record
4990            .as_ref()
4991            .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, record));
4992        let credential_rejected_since = github_credential_rejected_since(paths).ok().flatten();
4993        let credential_unreadable_since = credential_unreadable_since(paths).ok().flatten();
4994        let probed = self.controls.unattended_login();
4995        let unattended = record.as_ref().and_then(|record| {
4996            // The account is only read where something was probed to judge
4997            // it against.
4998            let account = probed.as_ref().map_or_else(String::new, |_| {
4999                found
5000                    .as_ref()
5001                    .and_then(|registration| registration.account.clone())
5002                    .unwrap_or_else(|| service_account_name(&record.account))
5003            });
5004            unattended_gap(record.start_mode, &account, probed.as_ref())
5005        });
5006        let now = Utc::now();
5007        let installed = record.is_some() || found.is_some();
5008        let status = ServiceStatus::compose(
5009            self.identity.clone(),
5010            record,
5011            record_refused,
5012            found,
5013            last_github_contact,
5014            paths,
5015        )
5016        .with_definition_drift(drift)
5017        .with_credential_rejection(credential_rejected_since)
5018        .with_credential_unreadable(credential_unreadable_since)
5019        .with_launches_blocked(crate::launch_health::launches_blocked(paths, now))
5020        .with_unattended_gap(unattended)
5021        .with_host_unfit(
5022            crate::host_fitness::host_unfit_in_force(paths, now).filter(|_| installed),
5023        );
5024        let restart = status.restart_command();
5025        Ok(status.with_stall(
5026            crate::daemon_heartbeat::liveness(paths, now).stall(last_github_contact, &restart),
5027        ))
5028    }
5029
5030    /// The installed definition, when it is not what this build renders for
5031    /// the recorded installation. See [`definition_drift`].
5032    ///
5033    /// # Errors
5034    ///
5035    /// Whatever reading the install record reports.
5036    pub fn definition_drift(&self) -> Result<Option<DefinitionDrift>, ServiceError> {
5037        Ok(InstallRecord::read(&self.paths)?
5038            .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, &record)))
5039    }
5040
5041    /// Writes the plist this build renders over an outdated one, then has a
5042    /// detached helper boot the job out and bootstrap it again from the new
5043    /// file.
5044    ///
5045    /// launchd reads a plist only when the job is bootstrapped. Rewriting the
5046    /// file changes nothing for a job that is already loaded, and a `KeepAlive`
5047    /// restart reuses the loaded copy. So the job has to be booted out, and
5048    /// that terminates the daemon that asked for it. The helper runs in a
5049    /// session of its own, which launchd does not kill with the job, and
5050    /// retries the bootstrap: launchd refuses it for a few seconds while the
5051    /// old job is still exiting (`5: Input/output error`, measured).
5052    ///
5053    /// Call this only from the daemon the job runs, and only while it holds no
5054    /// runner. It is an operation on the registration the daemon itself runs
5055    /// under, and it never changes the start mode.
5056    ///
5057    /// # Errors
5058    ///
5059    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`] when the
5060    /// plist cannot be written or the helper cannot be started. The loaded job
5061    /// is untouched in both cases.
5062    #[cfg(target_os = "macos")]
5063    pub fn reload_launchd_definition(&self, drift: &DefinitionDrift) -> Result<(), ServiceError> {
5064        // The record names the start mode; the account this process runs as has
5065        // to agree, or the record describes a registration other than the one
5066        // running, and root would end up rewriting an operator's LaunchAgent.
5067        // SAFETY: `getuid` reads the calling process's real user id and cannot
5068        // fail.
5069        let root = unsafe { libc::getuid() } == 0;
5070        if root != (drift.start_mode == StartMode::Boot) {
5071            return Err(ServiceError::Control {
5072                operation: "rewrite",
5073                name: self.identity.name().to_string(),
5074                manager: "launchd",
5075                detail: format!(
5076                    "the install record says --start-at {}, but this daemon runs as {}; the plist \
5077                     is left as it is",
5078                    drift.start_mode,
5079                    if root { "root" } else { "the operator" }
5080                ),
5081            });
5082        }
5083        replace_definition(self.identity.name(), drift)?;
5084        // The definition is now this build's, as after the WSL unit rewrite.
5085        // Best effort: the record says who wrote the plist and nothing reads it
5086        // to decide whether to repair.
5087        if let Ok(Some(mut record)) = InstallRecord::read(&self.paths) {
5088            record.installed_by_version = env!("CARGO_PKG_VERSION").to_string();
5089            let _ = record.write(&self.paths);
5090        }
5091        sys::reload_detached(&self.identity, drift.start_mode, &drift.path)
5092    }
5093
5094    /// Creates or reconciles the runner root this start mode's account needs.
5095    ///
5096    /// The account is not an argument: it is [`ServiceAccount::for_start_mode`],
5097    /// the same function the registration's own principal comes from, so the
5098    /// directory admits exactly the identity the definition registers and a mode
5099    /// change reconciles both together or neither.
5100    ///
5101    /// On macOS and Linux this is a no-op that returns
5102    /// [`RootAccessSummary::NotApplicable`]; see [`crate::runner_root_access`].
5103    fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
5104        #[cfg(not(windows))]
5105        {
5106            // `runner_root` is consumed here as well as in the Windows arm: a
5107            // field only one platform reads is a dead field on the other.
5108            let _ = (mode, &self.runner_root);
5109            Ok(RootAccessChange::not_applicable())
5110        }
5111        #[cfg(windows)]
5112        {
5113            let wrap = |source| ServiceError::RunnerRoot {
5114                source: Box::new(source),
5115            };
5116            // The test seam, and the two things allowed through it. `cfg!(test)`
5117            // is false in every shipped binary, so what a released build honours
5118            // is the fixture name alone — and a fixture name cannot be the
5119            // product's, which is what keeps a released `service install`
5120            // pointed at the platform default whatever a caller passes.
5121            //
5122            // An overridden root is always reconciled **for this account**,
5123            // which is the foreground admission — a real mode of the product
5124            // rather than a concession invented here. It has to be: a boot-mode
5125            // root admits `SY` and `BA` only, and a test process holding an
5126            // ordinary filtered token is neither, so it could not inspect the
5127            // temporary directory it just supplied nor delete it afterwards.
5128            // What that costs is that the *boot* descriptor is not proved
5129            // through this path; it is proved purely, by this module's
5130            // `the_runner_root_a_boot_registration_needs_admits_only_the_service`
5131            // and by `runner_root_access`'s own tests, and for real by the
5132            // privileged installer test, which runs elevated.
5133            if let Some(root) = self
5134                .runner_root
5135                .as_ref()
5136                .filter(|_| self.identity.is_fixture() || cfg!(test))
5137            {
5138                let admission = RootAdmission::of_this_account().map_err(wrap)?;
5139                return crate::runner_root_access::reconcile(&self.paths, root, &admission)
5140                    .map_err(wrap);
5141            }
5142
5143            let admission = match ServiceAccount::for_start_mode(mode) {
5144                // A boot registration runs as LocalSystem, which the constant
5145                // `SY` ace already names.
5146                ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
5147                // A login registration runs as this account, under a filtered
5148                // token in which Administrators is deny-only — so without an
5149                // ace of its own it would be admitted by nothing.
5150                ServiceAccount::InvokingUser | ServiceAccount::Root => {
5151                    RootAdmission::of_this_account().map_err(wrap)?
5152                }
5153            };
5154            crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
5155        }
5156    }
5157
5158    /// Takes the single-instance lock, or refuses with `d1`'s own message.
5159    fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
5160        HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
5161            |source| match source {
5162                held @ LockError::Held { .. } => ServiceError::LockHeld {
5163                    source: Box::new(held),
5164                },
5165                other => ServiceError::LockUnreadable {
5166                    source: Box::new(other),
5167                },
5168            },
5169        )
5170    }
5171
5172    /// The registration, in whichever domain holds it.
5173    fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
5174        for mode in [StartMode::Boot, StartMode::Login] {
5175            let control = self.controls.control(mode)?;
5176            if let Some(registration) = control.query(&self.identity)? {
5177                return Ok(Some((registration.start_mode, registration)));
5178            }
5179        }
5180        Ok(None)
5181    }
5182
5183    /// The registration [`Self::status`] describes, with the directories it
5184    /// runs against when they are not this account's: this account's own
5185    /// registration when either domain holds one, else whichever there is.
5186    ///
5187    /// [`Self::find_registration`] stops at the boot domain, so a boot service
5188    /// made from another data root would hide this account's own login
5189    /// registration. The other domain is asked only in that case, and a
5190    /// failure to ask it is not this status's failure.
5191    fn registration_for_status(
5192        &self,
5193    ) -> Result<Option<(Registration, Option<ServiceDirectories>)>, ServiceError> {
5194        let Some((mode, found)) = self.find_registration()? else {
5195            return Ok(None);
5196        };
5197        let Some(directories) = self.foreign_directories(&found) else {
5198            return Ok(Some((found, None)));
5199        };
5200        let other = match mode {
5201            StartMode::Boot => StartMode::Login,
5202            StartMode::Login => StartMode::Boot,
5203        };
5204        let own = self
5205            .controls
5206            .control(other)
5207            .ok()
5208            .and_then(|control| control.query(&self.identity).ok().flatten())
5209            .filter(|registration| self.foreign_directories(registration).is_none());
5210        Ok(Some(match own {
5211            Some(own) => (own, None),
5212            None => (found, Some(directories)),
5213        }))
5214    }
5215
5216    /// Moves a Linux boot service registered against another tree onto this
5217    /// one, which for `root` is the system tree. Returns whether it did.
5218    ///
5219    /// `service install` calls this before it opens anything in this tree, so
5220    /// that the copy lands in an empty one; the install itself calls it again,
5221    /// which then only confirms the registration is to be replaced.
5222    ///
5223    /// Before the system tree, a boot service ran against whichever tree
5224    /// `sudo` resolved: `/root`'s on some distributions, the operator's own on
5225    /// others. A boot unit with no capabilities cannot open the operator's, and
5226    /// `ProtectHome=` hides both, so the service is stopped, its configuration
5227    /// and state are copied here unless this tree already has its own, and the
5228    /// install then replaces the registration. The old tree is left as it was.
5229    ///
5230    /// # Errors
5231    ///
5232    /// Whatever stopping the old service or copying its tree reports.
5233    pub fn adopt_boot_installation(&self, mode: StartMode) -> Result<bool, ServiceError> {
5234        #[cfg(target_os = "linux")]
5235        return self.adopt_boot_installation_on_linux(mode);
5236        #[cfg(not(target_os = "linux"))]
5237        {
5238            let _ = mode;
5239            Ok(false)
5240        }
5241    }
5242
5243    #[cfg(target_os = "linux")]
5244    fn adopt_boot_installation_on_linux(&self, mode: StartMode) -> Result<bool, ServiceError> {
5245        if mode != StartMode::Boot {
5246            return Ok(false);
5247        }
5248        self.refuse_overridden_account()?;
5249        if !crate::host_fitness::is_elevated() {
5250            return Ok(false);
5251        }
5252        let Some((StartMode::Boot, found)) = self.find_registration()? else {
5253            return Ok(false);
5254        };
5255        let Some(old) = self.foreign_directories(&found) else {
5256            return Ok(false);
5257        };
5258        self.controls
5259            .control(StartMode::Boot)?
5260            .stop(&self.identity)?;
5261        // No service was ever installed against this tree, so whatever is in it
5262        // is what an interrupted attempt or a stray command left: set aside,
5263        // never deleted, in favour of the data the running service used. A
5264        // tree with its own install record keeps it.
5265        if matches!(InstallRecord::read(&self.paths), Ok(None)) {
5266            let stamp = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
5267            for (from, to, skip) in [
5268                (&old.config, self.paths.config_dir(), None),
5269                // `service install` copies the binary in again.
5270                (&old.state, self.paths.state_dir(), Some("bin")),
5271            ] {
5272                set_aside(to, &stamp)
5273                    .and_then(|()| copy_tree(from, to, skip))
5274                    .map_err(|error| ServiceError::Control {
5275                        operation: "move the data of",
5276                        name: self.identity.name().to_string(),
5277                        manager: "systemd",
5278                        detail: format!(
5279                            "cannot copy {} to {}: {error}",
5280                            from.display(),
5281                            to.display()
5282                        ),
5283                    })?;
5284            }
5285        }
5286        Ok(true)
5287    }
5288
5289    /// Refuses a Linux boot install over a unit an administrator's drop-in made
5290    /// run as another account.
5291    ///
5292    /// A drop-in outlives the unit file `service install` rewrites, so the new
5293    /// unit would run as that account too, and that account cannot open the
5294    /// system tree `root` owns. Said before anything is stopped or copied.
5295    #[cfg(target_os = "linux")]
5296    fn refuse_overridden_account(&self) -> Result<(), ServiceError> {
5297        let Some((StartMode::Boot, found)) = self.find_registration()? else {
5298            return Ok(());
5299        };
5300        match found.account.as_deref() {
5301            Some(account) if account != ServiceAccount::Root.as_str() => {
5302                Err(ServiceError::Control {
5303                    operation: "install",
5304                    name: self.identity.name().to_string(),
5305                    manager: "systemd",
5306                    detail: format!(
5307                        "a drop-in under {SYSTEMD_SYSTEM_DIR}/{}.d/ makes it run as {account}, and \
5308                         a boot service must run as root to use {}. Remove `User=` and `Group=` \
5309                         from that drop-in, run `sudo systemctl daemon-reload`, and install again",
5310                        self.identity.systemd_unit(),
5311                        crate::paths::SYSTEM_DATA_ROOT,
5312                    ),
5313                })
5314            }
5315            _ => Ok(()),
5316        }
5317    }
5318
5319    /// Refuses `operation` when the machine's registration is another
5320    /// installation's. See [`ServiceError::ForeignInstallation`].
5321    fn refuse_foreign(&self, operation: &'static str) -> Result<(), ServiceError> {
5322        match self.registration_for_status()? {
5323            Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
5324            _ => Ok(()),
5325        }
5326    }
5327
5328    /// [`ServiceError::ForeignInstallation`] for the installation at
5329    /// `directories`.
5330    fn foreign_refusal(
5331        &self,
5332        operation: &'static str,
5333        directories: ServiceDirectories,
5334    ) -> ServiceError {
5335        let foreign = self.foreign_service_at(directories);
5336        ServiceError::ForeignInstallation {
5337            name: self.identity.name().to_string(),
5338            operation,
5339            installed_by: foreign.installed_by(),
5340            act_as: foreign.act_as(),
5341        }
5342    }
5343
5344    /// The directories `registration` runs against, when they are not this
5345    /// account's. See [`ForeignService`].
5346    fn foreign_directories(&self, registration: &Registration) -> Option<ServiceDirectories> {
5347        ServiceDirectories::from_command_line(&registration.command_line)
5348            .filter(|directories| !same_directory(&directories.config, self.paths.config_dir()))
5349    }
5350
5351    /// The machine's service and the installation it belongs to, when that is
5352    /// not this account's, without reading anything of that installation's
5353    /// beyond its path. `None` when the service is this account's, when there
5354    /// is none, or when the service manager cannot be asked.
5355    #[must_use]
5356    pub fn foreign_service(&self) -> Option<ForeignService> {
5357        let (_, directories) = self.registration_for_status().ok()??;
5358        Some(self.foreign_service_at(directories?))
5359    }
5360
5361    /// The [`ForeignService`] for an installation at `directories`, before
5362    /// anything of its own is read.
5363    fn foreign_service_at(&self, directories: ServiceDirectories) -> ForeignService {
5364        let root = directories.root();
5365        let home = host_home();
5366        // This account's own installation under other directories, when the
5367        // four are exactly a layout a command of this account resolves.
5368        let this_account = home
5369            .as_deref()
5370            .filter(|home| contains_directory(home, &root))
5371            .and_then(|_| {
5372                if directories == ServiceDirectories::of(&AppPaths::rooted_at(&root)) {
5373                    Some(format!(
5374                        "with `--data-dir {}`",
5375                        quote_argument(&root.to_string_lossy())
5376                    ))
5377                } else {
5378                    AppPaths::discover()
5379                        .ok()
5380                        .filter(|standard| directories == ServiceDirectories::of(standard))
5381                        .map(|_| "without `--data-dir`".to_owned())
5382                }
5383            });
5384        ForeignService {
5385            owner: data_root_owner(&root, home.as_deref()),
5386            this_account,
5387            root,
5388            directories,
5389            local_root: ServiceDirectories::of(&self.paths).root(),
5390            local_record: InstallRecord::path(&self.paths),
5391            unused_local_record: InstallRecord::read(&self.paths).ok().flatten(),
5392            unreadable: None,
5393        }
5394    }
5395
5396    /// [`Self::status`] for a registration made from another data root.
5397    ///
5398    /// What that installation recorded is read when this account may read it,
5399    /// and the status is then exactly the one its owner would see. When it may
5400    /// not, the status holds what the service manager says and nothing it
5401    /// would have to guess.
5402    fn foreign_status(
5403        &self,
5404        registration: Registration,
5405        directories: ServiceDirectories,
5406    ) -> ServiceStatus {
5407        let service_paths = directories.paths();
5408        let mut foreign = self.foreign_service_at(directories);
5409        let unreadable = match InstallRecord::read(&service_paths) {
5410            Ok(Some(record)) => {
5411                match self.status_at(
5412                    &service_paths,
5413                    Some(record),
5414                    None,
5415                    Some(registration.clone()),
5416                ) {
5417                    Ok(status) => return status.with_foreign(foreign),
5418                    Err(error) => error.to_string(),
5419                }
5420            }
5421            Ok(None) => format!(
5422                "there is no install record at {}",
5423                InstallRecord::path(&service_paths).display()
5424            ),
5425            // Not the error's own words: `RecordNotPermitted` and
5426            // `RecordUnreadable` both end in a `service install` remedy, which
5427            // from this account would re-register the machine's service
5428            // against the wrong directories.
5429            Err(
5430                ServiceError::RecordNotPermitted { path, detail }
5431                | ServiceError::RecordUnreadable { path, detail }
5432                | ServiceError::Record { path, detail, .. },
5433            ) => format!("this account cannot read {} ({detail})", path.display()),
5434            Err(error) => error.to_string(),
5435        };
5436        foreign.unreadable = Some(unreadable);
5437        ServiceStatus::foreign_unread(self.identity.clone(), registration, foreign)
5438    }
5439
5440    /// Moves this account's own configuration aside, when no registration on
5441    /// this machine runs against it: `host forget-local`.
5442    ///
5443    /// Everything in the configuration directory -- the install record, the
5444    /// database with its host row and policies, `runner.env` and the rest -- is
5445    /// **renamed** into a `forgotten-<timestamp>` directory beside it, never
5446    /// deleted, so moving it back restores it exactly. The state, runtime and
5447    /// log directories are left alone even where the platform puts them inside
5448    /// the configuration directory (macOS does), and so is every secret store
5449    /// and every directory a registration names.
5450    ///
5451    /// Both domains are asked, strictly: a domain that cannot be asked might
5452    /// hold this account's own registration, so it refuses rather than reads
5453    /// as empty.
5454    ///
5455    /// # Errors
5456    ///
5457    /// [`ServiceError::LocalConfigurationInUse`] when a registration on this
5458    /// machine runs against these directories, when a registration from
5459    /// another data root names a directory overlapping them, or when an agent
5460    /// holds this account's single-instance lock. [`ServiceError::Record`] when
5461    /// the move fails; whatever had already moved is put back first, and
5462    /// anything that could not be put back is named.
5463    pub fn forget_local(&self, at: DateTime<Utc>) -> Result<ForgottenLocal, ServiceError> {
5464        let config = self.paths.config_dir();
5465        let in_use = |detail: String| ServiceError::LocalConfigurationInUse { detail };
5466        let mut service_root = None;
5467        for mode in [StartMode::Boot, StartMode::Login] {
5468            let registration = self
5469                .controls
5470                .control(mode)
5471                .and_then(|control| control.query(&self.identity))
5472                .map_err(|error| {
5473                    in_use(format!(
5474                        "whether a {mode} registration runs against it could not be read ({error})"
5475                    ))
5476                })?;
5477            let Some(registration) = registration else {
5478                continue;
5479            };
5480            let Some(directories) = self.foreign_directories(&registration) else {
5481                return Err(in_use(format!(
5482                    "{} holds a registration that runs against {}. `service status` describes it",
5483                    registration.manager,
5484                    config.display()
5485                )));
5486            };
5487            if let Some(overlap) = directories.all().into_iter().find(|directory| {
5488                contains_directory(directory, config) || contains_directory(config, directory)
5489            }) {
5490                return Err(in_use(format!(
5491                    "the machine's service runs against {}, which overlaps {}",
5492                    overlap.display(),
5493                    config.display()
5494                )));
5495            }
5496            service_root.get_or_insert_with(|| directories.root());
5497        }
5498        let _lock =
5499            HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(|source| {
5500                in_use(format!(
5501                    "an agent may be running against these directories: {source}"
5502                ))
5503            })?;
5504        let failed = |operation: &'static str, path: &Path| {
5505            let path = path.to_path_buf();
5506            move |error: std::io::Error| ServiceError::Record {
5507                operation,
5508                path,
5509                detail: error.to_string(),
5510            }
5511        };
5512        // Never the agent's own directories, wherever the platform nests them.
5513        let kept = [
5514            self.paths.state_dir(),
5515            self.paths.runtime_dir(),
5516            self.paths.logs_dir(),
5517        ];
5518        let mut entries = Vec::new();
5519        for entry in std::fs::read_dir(config).map_err(failed("list", config))? {
5520            let entry = entry.map_err(failed("list", config))?.path();
5521            let archived = entry
5522                .file_name()
5523                .is_some_and(|name| name.to_string_lossy().starts_with(FORGOTTEN_PREFIX));
5524            if !archived && !kept.iter().any(|kept| contains_directory(&entry, kept)) {
5525                entries.push(entry);
5526            }
5527        }
5528        if entries.is_empty() {
5529            return Ok(ForgottenLocal {
5530                archive: None,
5531                moved: entries,
5532                service_root,
5533            });
5534        }
5535        entries.sort();
5536        let archive = config.join(format!("{FORGOTTEN_PREFIX}{}", at.format("%Y%m%dT%H%M%SZ")));
5537        std::fs::create_dir(&archive).map_err(failed("create", &archive))?;
5538        let inside = |entry: &Path| archive.join(entry.file_name().unwrap_or_default());
5539        for (index, entry) in entries.iter().enumerate() {
5540            if let Err(error) = std::fs::rename(entry, inside(entry)) {
5541                let stranded: Vec<String> = entries[..index]
5542                    .iter()
5543                    .rev()
5544                    .filter(|done| std::fs::rename(inside(done), done).is_err())
5545                    .map(|done| inside(done).display().to_string())
5546                    .collect();
5547                let _ = std::fs::remove_dir(&archive);
5548                let mut detail = error.to_string();
5549                if !stranded.is_empty() {
5550                    detail.push_str(&format!(
5551                        "; these could not be put back and are still in the archive: {}",
5552                        stranded.join(", ")
5553                    ));
5554                }
5555                return Err(ServiceError::Record {
5556                    operation: "archive",
5557                    path: entry.clone(),
5558                    detail,
5559                });
5560            }
5561        }
5562        Ok(ForgottenLocal {
5563            archive: Some(archive),
5564            moved: entries,
5565            service_root,
5566        })
5567    }
5568}
5569
5570/// The name every `host forget-local` archive starts with. A later run leaves
5571/// earlier archives where they are.
5572pub const FORGOTTEN_PREFIX: &str = "forgotten-";
5573
5574/// What [`ServiceOperations::forget_local`] did.
5575#[derive(Debug, Clone, PartialEq, Eq)]
5576pub struct ForgottenLocal {
5577    /// Where the configuration went, or `None` when there was nothing to move.
5578    pub archive: Option<PathBuf>,
5579    /// What was moved, by its old path.
5580    pub moved: Vec<PathBuf>,
5581    /// The data root of the machine's service, when one runs from another
5582    /// installation. Named so the operator can see it was not touched.
5583    pub service_root: Option<PathBuf>,
5584}
5585
5586impl fmt::Display for ForgottenLocal {
5587    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5588        let Some(archive) = &self.archive else {
5589            return write!(
5590                f,
5591                "Nothing to archive: this account's configuration directory is empty."
5592            );
5593        };
5594        writeln!(f, "Archived this account's unused local configuration")?;
5595        writeln!(f, "  archive                   {}", archive.display())?;
5596        for path in &self.moved {
5597            writeln!(f, "  moved                     {}", path.display())?;
5598        }
5599        if let Some(root) = &self.service_root {
5600            writeln!(
5601                f,
5602                "  service data root         {} (untouched)",
5603                root.display()
5604            )?;
5605        }
5606        write!(
5607            f,
5608            "  restore                   move everything in the archive back into its parent \
5609             directory"
5610        )
5611    }
5612}
5613
5614/// Whether two directory spellings name the same place: the same text on
5615/// this platform's terms, or the same canonical directory.
5616fn same_directory(left: &Path, right: &Path) -> bool {
5617    let text = |path: &Path| {
5618        path.to_string_lossy()
5619            .trim_end_matches(['/', '\\'])
5620            .to_owned()
5621    };
5622    same_path_text(&text(left), &text(right))
5623        || matches!(
5624            (std::fs::canonicalize(left), std::fs::canonicalize(right)),
5625            (Ok(left), Ok(right)) if left == right
5626        )
5627}
5628
5629/// Where the remedy starts in a [`StatusProblem`]'s detail: the sentence
5630/// openings every builder above uses for it.
5631const REMEDY_OPENINGS: [&str; 8] = [
5632    ". Run ",
5633    ". If nothing is waiting",
5634    ". Re-register",
5635    ". Switch the start mode",
5636    " Sign in again",
5637    ". Fix: ",
5638    "; to do it now",
5639    "; if the service manager denies",
5640];
5641
5642/// A problem's diagnosis without its remedy. See
5643/// [`ServiceStatus::with_foreign`].
5644///
5645/// A detail that still names a command that changes the registration or the
5646/// credential after the cut -- a builder this list does not know yet -- is
5647/// reduced to the first clause rather than passed on, because the cost of
5648/// showing too little is a shorter sentence and the cost of showing too much
5649/// is a broken service.
5650fn diagnosis_only(detail: &str) -> String {
5651    let cut = REMEDY_OPENINGS
5652        .iter()
5653        .filter_map(|opening| detail.find(opening))
5654        .min()
5655        .unwrap_or(detail.len());
5656    let diagnosis = detail[..cut].trim_end_matches(['.', ' ']);
5657    let names_a_change = |text: &str| {
5658        [
5659            "service install",
5660            "service uninstall",
5661            "auth login",
5662            "launchctl",
5663            "systemctl",
5664        ]
5665        .iter()
5666        .any(|command| text.contains(command))
5667    };
5668    if !names_a_change(diagnosis) {
5669        return diagnosis.to_owned();
5670    }
5671    // Clause ends followed by a space, so a `.` inside a path does not cut.
5672    let end = [", ", "; ", ". "]
5673        .iter()
5674        .filter_map(|separator| diagnosis.find(separator))
5675        .min()
5676        .unwrap_or(diagnosis.len());
5677    let first = &diagnosis[..end];
5678    if names_a_change(first) {
5679        "the installation's own records report this".to_owned()
5680    } else {
5681        first.to_owned()
5682    }
5683}
5684
5685/// A path's components as this platform compares them: case-insensitively
5686/// on Windows.
5687fn path_key(path: &Path) -> Vec<String> {
5688    path.components()
5689        .map(|component| {
5690            let text = component.as_os_str().to_string_lossy();
5691            if cfg!(windows) {
5692                text.to_lowercase()
5693            } else {
5694                text.into_owned()
5695            }
5696        })
5697        .collect()
5698}
5699
5700/// Whether `inner` is `outer` or a directory under it, by the rule
5701/// [`same_directory`] uses: on this platform's terms, or canonically.
5702fn contains_directory(outer: &Path, inner: &Path) -> bool {
5703    path_key(inner).starts_with(&path_key(outer))
5704        || matches!(
5705            (std::fs::canonicalize(outer), std::fs::canonicalize(inner)),
5706            (Ok(outer), Ok(inner)) if inner.starts_with(&outer)
5707        )
5708}
5709
5710/// The account whose data root `root` is, when the path says: the folder
5711/// under the directory that holds this account's `home`, `root` for root's
5712/// home on Unix, and `LocalSystem` for its profile on Windows. `None` when
5713/// the path names nobody, which is reported as the path alone.
5714#[must_use]
5715pub fn data_root_owner(root: &Path, home: Option<&Path>) -> Option<String> {
5716    let target = path_key(root);
5717    if cfg!(windows) {
5718        if target
5719            .windows(3)
5720            .any(|run| run == ["system32", "config", "systemprofile"])
5721        {
5722            return Some("LocalSystem".to_owned());
5723        }
5724    } else if [
5725        "/root",
5726        "/var/root",
5727        "/private/var/root",
5728        crate::paths::SYSTEM_DATA_ROOT,
5729    ]
5730    .iter()
5731    .any(|home| root.starts_with(home))
5732    {
5733        return Some("root".to_owned());
5734    }
5735    let users = path_key(home?.parent()?);
5736    // A home directly under the root (`/root` under `sudo`) names no directory
5737    // of homes: every path would then "belong" to its first component.
5738    if users.len() < 2 {
5739        return None;
5740    }
5741    if target.len() > users.len() && target[..users.len()] == users[..] {
5742        root.components()
5743            .nth(users.len())
5744            .map(|name| name.as_os_str().to_string_lossy().into_owned())
5745    } else {
5746        None
5747    }
5748}
5749
5750/// A registration whose command line names application-data directories that
5751/// are not this account's: **another installation's service**.
5752///
5753/// Every account on a machine sees the one registration its service manager
5754/// holds under the product's name. A second account -- one that ran a
5755/// login-mode install once, say -- has an install record and a database of its
5756/// own, and comparing them against the other installation's registration used
5757/// to report a stale binary, a mismatched start mode and a secret store in the
5758/// wrong scope, with `service uninstall`, `service install` and `auth login`
5759/// as the remedies. Followed from that account, each of them breaks or
5760/// replaces a service that is healthy. So the registration is reported as the
5761/// other installation's, with what can be read of it, and this account's own
5762/// record as the unused configuration it is.
5763#[derive(Debug, Clone, PartialEq, Eq)]
5764pub struct ForeignService {
5765    /// The directories its command line names.
5766    pub directories: ServiceDirectories,
5767    /// The data root they sit under. See [`ServiceDirectories::root`].
5768    pub root: PathBuf,
5769    /// The account that data root belongs to, when its path says.
5770    pub owner: Option<String>,
5771    /// How a command run by this account reaches the installation, when it is
5772    /// this account's own under other directories -- an install made with
5773    /// `--data-dir`, seen from a command run without it, or the other way
5774    /// round. `None` for another account's.
5775    pub this_account: Option<String>,
5776    /// This account's own data root, which no registration runs against.
5777    pub local_root: PathBuf,
5778    /// Where this account's own install record is, or would be.
5779    pub local_record: PathBuf,
5780    /// This account's own install record, from an earlier install.
5781    pub unused_local_record: Option<InstallRecord>,
5782    /// Why the service's own records could not be read, or `None` when they
5783    /// were and the status describes them.
5784    pub unreadable: Option<String>,
5785}
5786
5787impl ForeignService {
5788    /// How to see the service's own details: as its owner, or elevated.
5789    #[must_use]
5790    pub fn where_to_look(&self) -> String {
5791        if let Some(reach) = &self.this_account {
5792            return reach.clone();
5793        }
5794        let elevated = if cfg!(windows) {
5795            "from an elevated terminal (Run as administrator)"
5796        } else {
5797            "with sudo"
5798        };
5799        match &self.owner {
5800            Some(owner) if owner != "LocalSystem" && owner != "root" => {
5801                format!("{elevated}, or signed in as {owner}")
5802            }
5803            _ => elevated.to_owned(),
5804        }
5805    }
5806
5807    /// Where a change to the service has to be made from: the installation's
5808    /// own account, because every remedy a command prints acts on the
5809    /// directories of whoever runs it. Elevation alone reads the service's
5810    /// records but is still this account.
5811    #[must_use]
5812    pub fn act_as(&self) -> String {
5813        if let Some(reach) = &self.this_account {
5814            return reach.clone();
5815        }
5816        match &self.owner {
5817            Some(owner) if owner != "LocalSystem" && owner != "root" => {
5818                format!("signed in as {owner}")
5819            }
5820            _ => format!(
5821                "from the account whose data root {} is",
5822                self.root.display()
5823            ),
5824        }
5825    }
5826
5827    /// Whether the service's own records could not be read, so only what the
5828    /// service manager says is known.
5829    #[must_use]
5830    pub const fn is_unread(&self) -> bool {
5831        self.unreadable.is_some()
5832    }
5833
5834    /// Who installed it, for a sentence.
5835    #[must_use]
5836    pub fn installed_by(&self) -> String {
5837        match &self.owner {
5838            Some(owner) => format!("{owner}'s data root {}", self.root.display()),
5839            None => format!("the data root {}", self.root.display()),
5840        }
5841    }
5842
5843    /// The data root and its owner, for a field: `<root> (<owner>)`.
5844    #[must_use]
5845    pub fn label(&self) -> String {
5846        match &self.owner {
5847            Some(owner) => format!("{} ({owner})", self.root.display()),
5848            None => self.root.display().to_string(),
5849        }
5850    }
5851
5852    /// The note describing this account's own record, when there is one.
5853    #[must_use]
5854    pub fn unused_local_note(&self) -> Option<String> {
5855        let record = self.unused_local_record.as_ref()?;
5856        Some(format!(
5857            "unused local configuration from an earlier install: {} records a {} registration \
5858             made by runner-manager {} on {}, and no service on this machine runs against it. \
5859             `runner-manager host forget-local` moves it aside; it never touches the service's \
5860             own directories or any secret.",
5861            self.local_record.display(),
5862            record.start_mode,
5863            record.installed_by_version,
5864            record.installed_at.format("%Y-%m-%d"),
5865        ))
5866    }
5867}
5868
5869// ---------------------------------------------------------------------------
5870// Status
5871// ---------------------------------------------------------------------------
5872
5873/// One thing `service status` has to report as wrong.
5874#[derive(Debug, Clone, PartialEq, Eq)]
5875pub struct StatusProblem {
5876    /// What it is about.
5877    pub subject: &'static str,
5878    /// What is wrong, and what to do.
5879    pub detail: String,
5880}
5881
5882impl fmt::Display for StatusProblem {
5883    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5884        write!(f, "{}: {}", self.subject, self.detail)
5885    }
5886}
5887
5888/// Everything `service status` reports.
5889///
5890/// Journey 5 step 4 names four of these — the start mode, the resolved binary
5891/// path, the diagnostic log path, and the last successful GitHub contact — and
5892/// the release gate adds the fifth: *"`service status` reports a stale binary
5893/// path as an error rather than appearing healthy"*. [`Self::is_healthy`] is
5894/// what an exit code should be derived from, and it is false whenever
5895/// [`Self::problems`] is non-empty.
5896#[derive(Debug, Clone)]
5897pub struct ServiceStatus {
5898    identity: ServiceIdentity,
5899    record: Option<InstallRecord>,
5900    registration: Option<Registration>,
5901    binary: Option<BinaryPath>,
5902    log_file: PathBuf,
5903    store: Option<crate::secrets::ActiveStore>,
5904    last_github_contact: Option<DateTime<Utc>>,
5905    runner_root: Option<(PathBuf, RootAccessReport)>,
5906    credential_rejected_since: Option<DateTime<Utc>>,
5907    launches_blocked: Option<crate::launch_health::LaunchesBlocked>,
5908    unattended_gap: Option<UnattendedGap>,
5909    foreign: Option<ForeignService>,
5910    problems: Vec<StatusProblem>,
5911    notes: Vec<String>,
5912}
5913
5914/// Why a registration does not come back by itself after an unattended
5915/// restart, and what an operator can do about it.
5916#[derive(Debug, Clone, PartialEq, Eq)]
5917pub struct UnattendedGap {
5918    /// One or more sentences, the way `service status` prints them.
5919    pub reason: String,
5920    /// What changes it. Never something this product does by itself.
5921    pub remedy: String,
5922}
5923
5924/// The name of the account a registration runs as: the invoking user's own
5925/// for a login-mode one.
5926#[must_use]
5927pub fn service_account_name(account: &ServiceAccount) -> String {
5928    match account {
5929        // Under `sudo` the invoking user is the one sudo names, not root.
5930        ServiceAccount::InvokingUser => std::env::var("SUDO_USER")
5931            .ok()
5932            .filter(|user| !user.is_empty() && crate::host_fitness::is_elevated())
5933            .or_else(crate::unattended_login::current_account)
5934            .unwrap_or_else(|| "the operator".to_owned()),
5935        other => other.as_str().to_owned(),
5936    }
5937}
5938
5939/// The account `registration` names, which an administrator can change from
5940/// the one the install record saved, and otherwise the record's.
5941fn registered_account<'a>(registration: Option<&'a Registration>, recorded: &'a str) -> &'a str {
5942    registration
5943        .and_then(|registration| registration.account.as_deref())
5944        .unwrap_or(recorded)
5945}
5946
5947/// A Windows service account as `QueryServiceConfigW` names it, spelled the
5948/// way [`ServiceAccount`] does, so a service nobody edited reports the account
5949/// the record holds.
5950#[cfg(any(windows, test))]
5951fn windows_service_account(name: &str) -> String {
5952    if name.eq_ignore_ascii_case("LocalSystem") || name.eq_ignore_ascii_case(".\\LocalSystem") {
5953        ServiceAccount::LocalSystem.as_str().to_string()
5954    } else {
5955        name.to_string()
5956    }
5957}
5958
5959/// A Task Scheduler `UserId`, unless it is a raw SID, which names no account
5960/// an operator would recognise.
5961#[cfg(any(windows, test))]
5962fn scheduled_task_account(user_id: Option<String>) -> Option<String> {
5963    user_id.filter(|id| !id.to_ascii_uppercase().starts_with("S-1-"))
5964}
5965
5966/// Works out the gap for a registration in `mode` running as `account`.
5967///
5968/// `found` is `None` where nothing could be probed (every platform but macOS):
5969/// a login registration then keeps the general statement that it waits for a
5970/// sign-in. On a Mac the statement depends on automatic login and FileVault,
5971/// and a login registration that automatic login brings back has no gap. The
5972/// earlier statement was printed for every login registration, so a Mac set to
5973/// sign its runner account in automatically was told it would not resume.
5974#[must_use]
5975pub fn unattended_gap(
5976    mode: StartMode,
5977    account: &str,
5978    found: Option<&crate::unattended_login::UnattendedLogin>,
5979) -> Option<UnattendedGap> {
5980    let Some(found) = found else {
5981        return (mode == StartMode::Login).then(|| UnattendedGap {
5982            reason: "This registration starts at login, so the agent does not run until the \
5983                     operator signs in; this host does not resume work after an unattended \
5984                     reboot."
5985                .to_owned(),
5986            remedy: "For machine-on operation, move the service and credential to `--start-at \
5987                     boot` from an elevated terminal."
5988                .to_owned(),
5989        });
5990    };
5991    let resume = crate::unattended_login::resume(found, mode, account);
5992    if matches!(resume, crate::unattended_login::Resume::Unknown(_)) {
5993        // `host doctor` says what could not be read; a note here would claim
5994        // a gap nobody has seen.
5995        return None;
5996    }
5997    Some(UnattendedGap {
5998        reason: resume.explain(account)?,
5999        remedy: resume.remedy().unwrap_or_else(|| {
6000            "Run `runner-manager host doctor` on the Mac's desktop, where automatic login and \
6001             FileVault can be read."
6002                .to_owned()
6003        }),
6004    })
6005}
6006
6007impl ServiceStatus {
6008    /// `record_refused` carries a [`ServiceError::RecordNotPermitted`] when
6009    /// there **is** a record and this account may not read it, which is not the
6010    /// same state as having none and must not be reported as one. See
6011    /// [`Operations::status`].
6012    fn compose(
6013        identity: ServiceIdentity,
6014        record: Option<InstallRecord>,
6015        record_refused: Option<&ServiceError>,
6016        registration: Option<Registration>,
6017        last_github_contact: Option<DateTime<Utc>>,
6018        paths: &AppPaths,
6019    ) -> Self {
6020        let mut problems = Vec::new();
6021        let mut notes = Vec::new();
6022
6023        if let Some(refusal) = record_refused {
6024            // A problem rather than a note: `is_healthy` stays false, because
6025            // an operator who cannot read their own install record does have
6026            // something to fix. It is just not a reason to withhold everything
6027            // the service manager knows.
6028            problems.push(StatusProblem {
6029                subject: "install record",
6030                detail: refusal.to_string(),
6031            });
6032        }
6033
6034        let log_file = record.as_ref().map_or_else(
6035            || ServiceDirectories::of(paths).log_file(),
6036            |record| record.log_file.clone(),
6037        );
6038
6039        let binary = record.as_ref().map(|record| {
6040            inspect_binary(
6041                &record.binary,
6042                registration
6043                    .as_ref()
6044                    .and_then(Registration::binary)
6045                    .as_deref(),
6046            )
6047        });
6048        if let Some(state) = &binary
6049            && state.is_error()
6050        {
6051            problems.push(StatusProblem {
6052                subject: "binary",
6053                detail: state.to_string(),
6054            });
6055        }
6056
6057        match (&record, &registration) {
6058            (Some(_), None) => problems.push(StatusProblem {
6059                subject: "registration",
6060                detail: "this host has a service record but no service manager knows the \
6061                         registration. Run `service install` again; it deletes nothing."
6062                    .to_string(),
6063            }),
6064            // Not reported when the record was refused rather than absent: the
6065            // problem above already names the real state, and "there is no
6066            // install record" would be a second, wrong diagnosis of the same
6067            // file, with a `service uninstall` in its remedy.
6068            (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
6069                subject: "record",
6070                detail: format!(
6071                    "{} holds a registration for this service but there is no install record, so \
6072                     the path it was installed from and the directories it was installed against \
6073                     are unknown. Run `service uninstall` and `service install`.",
6074                    found.manager
6075                ),
6076            }),
6077            (None, Some(_)) => {}
6078            (Some(record), Some(found)) => {
6079                if record.start_mode != found.start_mode {
6080                    problems.push(StatusProblem {
6081                        subject: "start mode",
6082                        detail: format!(
6083                            "the record says {} and {} holds a {} registration. Switch the start \
6084                             mode again to make them agree.",
6085                            record.start_mode, found.manager, found.start_mode
6086                        ),
6087                    });
6088                }
6089                if record.start_mode == StartMode::Boot && !found.starts_automatically {
6090                    problems.push(StatusProblem {
6091                        subject: "start mode",
6092                        detail: format!(
6093                            "{} holds the registration but will not start it by itself, so this \
6094                             host does not resume work after a reboot.",
6095                            found.manager
6096                        ),
6097                    });
6098                }
6099                if let Some(actual) = found.restart_delay {
6100                    let expected = record.restart().effective_delay(found.manager);
6101                    if actual != expected {
6102                        problems.push(StatusProblem {
6103                            subject: "restart policy",
6104                            detail: format!(
6105                                "the record says the service restarts after {}s and {} reports \
6106                                 {}s. Something has edited the registration since it was \
6107                                 installed.",
6108                                expected.as_secs(),
6109                                found.manager,
6110                                actual.as_secs()
6111                            ),
6112                        });
6113                    } else if expected != record.restart().delay() {
6114                        // Not a fault. Task Scheduler expresses this in whole
6115                        // minutes, so the delay in force is longer than the one
6116                        // asked for -- never shorter, which is the direction
6117                        // the requirement cares about.
6118                        notes.push(format!(
6119                            "{} expresses the restart delay in whole minutes, so the {}s asked \
6120                             for is enforced as {}s. The service therefore never restarts faster \
6121                             than the configured bound.",
6122                            found.manager,
6123                            record.restart().delay().as_secs(),
6124                            expected.as_secs()
6125                        ));
6126                    }
6127                }
6128                if found.starts_automatically && !found.running {
6129                    problems.push(StatusProblem {
6130                        subject: "runtime",
6131                        detail: format!(
6132                            "{} holds an automatic registration, but the daemon is stopped. Run \
6133                             `runner-manager service install --start-at {}`; if the service \
6134                             manager denies replacement, retry from an elevated terminal. If it \
6135                             stops again, inspect {}.",
6136                            found.manager,
6137                            found.start_mode,
6138                            log_file.display()
6139                        ),
6140                    });
6141                }
6142            }
6143            (None, None) => {}
6144        }
6145
6146        // The two facts here really are independent, which is what `f1`'s
6147        // `status.rs` says this check needs and could not have there: the scope
6148        // is derived from the mode the **record** carries, and it is compared
6149        // against the mode the **service manager** actually holds.
6150        let store = record.as_ref().and_then(|record| {
6151            let registered_mode = registration
6152                .as_ref()
6153                .map_or(record.start_mode, |found| found.start_mode);
6154            crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
6155                .ok()
6156                .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
6157        });
6158        if let Some(store) = &store
6159            && !store.agrees_with_start_mode()
6160        {
6161            problems.push(StatusProblem {
6162                subject: "secret store",
6163                detail: format!(
6164                    "{store}. Run `auth login` again so the token is stored where the registered \
6165                     start mode can read it."
6166                ),
6167            });
6168        }
6169
6170        if last_github_contact.is_none() {
6171            notes.push(
6172                "GitHub has not been reached successfully since this host's state directory was \
6173                 created."
6174                    .to_string(),
6175            );
6176        }
6177
6178        // The privileged inspection output. Read-only, tolerant of an account
6179        // that may not read the descriptor at all, and never a `problem`: a
6180        // root this account cannot inspect is a true statement about this
6181        // account's rights, not a fault in the registration. What refuses an
6182        // install is the preflight in `runner_root_access`, which runs as the
6183        // installing account and has the authority to know.
6184        let runner_root = crate::runner_root::default_runner_root(paths)
6185            .ok()
6186            .map(|root| {
6187                let path = root.as_path().to_path_buf();
6188                let report = crate::runner_root_access::report(&path);
6189                (path, report)
6190            });
6191        // Reported, and deliberately not a `problem`. The Definition of Done
6192        // asks for a broad root to be "reported and fail the security
6193        // preflight", and the security preflight is `install`'s -- which runs
6194        // as the installing account and refuses outright. `service status` runs
6195        // as whoever typed it, is expected to be readable on a host with
6196        // nothing installed at all, and drives an exit code; turning a
6197        // directory that predates this feature into a non-zero exit for a
6198        // machine that has never installed the service would report a fault
6199        // that is not this registration's.
6200        if let Some((
6201            path,
6202            RootAccessReport::Present {
6203                broad_write: true, ..
6204            },
6205        )) = &runner_root
6206        {
6207            notes.push(format!(
6208                "the platform default runner root {} can be written by ordinary local users, so \
6209                 it is not a safe place to run jobs. `service install` refuses it rather than \
6210                 tightening it, because the contents of a directory anybody could write cannot \
6211                 be trusted: remove or empty it, or choose another root with `runner-manager \
6212                 host set-runtime-root --path <PATH>`.",
6213                path.display()
6214            ));
6215        }
6216
6217        // The surface the agent has nowhere else to reach. A root that refuses
6218        // a launch does so before any attempt row exists, and the daemon's log
6219        // redacts the paths out of the sentence, so without this the operator
6220        // sees `runner_start_failed reason=other` once per poll and nothing
6221        // that names the directory or the fix.
6222        //
6223        // A **note** and not a problem, by the same rule the broad-write root
6224        // above obeys: `service status` runs as whoever typed it, is expected
6225        // to be readable on a host with nothing installed at all, and drives an
6226        // exit code. A problem here would fail that exit code on three hosts
6227        // that are not broken -- one whose root was fixed but which has had no
6228        // queued job since, one whose service was uninstalled without clearing
6229        // `state/`, and one that only ever ran the agent in the foreground --
6230        // and the printed remedy (`service uninstall && service install`) does
6231        // not touch `state/`, so following it would not clear the error either.
6232        // A note is read by the same operator at the same moment and traps
6233        // nobody in a loop.
6234        //
6235        // A read failure is reported and not swallowed, for the reason
6236        // `runner_root_refusals` documents: absence means "every policy is
6237        // placing runners", and answering a parse failure with that would be
6238        // the wrong answer to the question this row exists for.
6239        match runner_root_refusals(paths) {
6240            Ok(refusals) => {
6241                for refusal in refusals {
6242                    notes.push(format!(
6243                        "policy {} started no runner: its runner root {} refused the launch \
6244                         ({}), last at {}. {} This clears when that policy next places a \
6245                         runner.",
6246                        refusal.policy,
6247                        refusal.root,
6248                        refusal.kind,
6249                        refusal.at.to_rfc3339(),
6250                        refusal.detail,
6251                    ));
6252                }
6253            }
6254            Err(error) => notes.push(format!(
6255                "whether the agent could use its runner roots could not be read: {error}"
6256            )),
6257        }
6258
6259        Self {
6260            identity,
6261            record,
6262            registration,
6263            binary,
6264            log_file,
6265            store,
6266            last_github_contact,
6267            runner_root,
6268            credential_rejected_since: None,
6269            launches_blocked: None,
6270            unattended_gap: None,
6271            foreign: None,
6272            problems,
6273            notes,
6274        }
6275    }
6276
6277    /// Marks a status read from another installation's directories as that
6278    /// installation's. See [`ForeignService`].
6279    ///
6280    /// Its problems were written for its owner, and their remedies -- `service
6281    /// install`, `auth login`, re-registering a drifted definition -- act on
6282    /// the directories of whoever runs them. Printed to this account, which
6283    /// reaches here precisely by following "run it elevated", they would
6284    /// re-register the machine's service against this account's data root.
6285    /// So each keeps its diagnosis and loses its remedy, and says where the
6286    /// remedy has to be run instead.
6287    fn with_foreign(mut self, foreign: ForeignService) -> Self {
6288        let act_as = foreign.act_as();
6289        for problem in &mut self.problems {
6290            problem.detail = format!(
6291                "{}. Act on it {act_as}: a remedy run from this account acts on this account's \
6292                 own directories, not the service's.",
6293                diagnosis_only(&problem.detail)
6294            );
6295        }
6296        if let Some(note) = foreign.unused_local_note() {
6297            self.notes.push(note);
6298        }
6299        self.foreign = Some(foreign);
6300        self
6301    }
6302
6303    /// A registration made from another data root whose records this account
6304    /// cannot read: what the service manager reports, and nothing else.
6305    ///
6306    /// The only problems are ones the service manager alone settles -- an
6307    /// automatic registration that is stopped, a boot registration that will
6308    /// not start by itself -- and their remedy is to look as the owner, never
6309    /// to reinstall from here.
6310    fn foreign_unread(
6311        identity: ServiceIdentity,
6312        registration: Registration,
6313        foreign: ForeignService,
6314    ) -> Self {
6315        let mut problems = Vec::new();
6316        let look = foreign.where_to_look();
6317        if registration.starts_automatically && !registration.running {
6318            problems.push(StatusProblem {
6319                subject: "runtime",
6320                detail: format!(
6321                    "{} holds an automatic registration for the service installed from {}, but \
6322                     it is stopped. Run `runner-manager service status` {look} to see why; do not \
6323                     reinstall it from this account.",
6324                    registration.manager,
6325                    foreign.installed_by(),
6326                ),
6327            });
6328        }
6329        if registration.start_mode == StartMode::Boot && !registration.starts_automatically {
6330            problems.push(StatusProblem {
6331                subject: "start mode",
6332                detail: format!(
6333                    "{} holds the boot registration installed from {} but will not start it by \
6334                     itself. Run `runner-manager service status` {look}.",
6335                    registration.manager,
6336                    foreign.installed_by(),
6337                ),
6338            });
6339        }
6340        let mut notes = Vec::new();
6341        if let Some(reason) = &foreign.unreadable {
6342            notes.push(format!(
6343                "its health is not known here: {reason}. Run `runner-manager service status` \
6344                 {look} to see it."
6345            ));
6346        }
6347        notes.extend(foreign.unused_local_note());
6348        Self {
6349            identity,
6350            record: None,
6351            log_file: foreign.directories.log_file(),
6352            registration: Some(registration),
6353            binary: None,
6354            store: None,
6355            last_github_contact: None,
6356            runner_root: None,
6357            credential_rejected_since: None,
6358            launches_blocked: None,
6359            unattended_gap: None,
6360            foreign: Some(foreign),
6361            problems,
6362            notes,
6363        }
6364    }
6365
6366    /// The other installation this machine's service belongs to, when it is
6367    /// not this account's. See [`ForeignService`].
6368    #[must_use]
6369    pub const fn foreign(&self) -> Option<&ForeignService> {
6370        self.foreign.as_ref()
6371    }
6372
6373    /// Whether this is another installation's service whose own records this
6374    /// account could not read, so only the service manager's facts are here.
6375    #[must_use]
6376    pub fn is_unread(&self) -> bool {
6377        self.foreign.as_ref().is_some_and(ForeignService::is_unread)
6378    }
6379
6380    /// The one-word-or-so verdict `service status` ends with.
6381    #[must_use]
6382    pub fn verdict(&self) -> String {
6383        if !self.is_healthy() {
6384            return "NOT healthy".to_owned();
6385        }
6386        match &self.foreign {
6387            Some(foreign) if foreign.is_unread() => format!(
6388                "{}; installed from {}, so its health is read {}",
6389                if self.is_running() {
6390                    "running"
6391                } else {
6392                    "not running"
6393                },
6394                foreign.installed_by(),
6395                foreign.where_to_look()
6396            ),
6397            _ => "healthy".to_owned(),
6398        }
6399    }
6400
6401    /// Reports a definition this build would render differently.
6402    #[must_use]
6403    pub fn with_definition_drift(mut self, drift: Option<DefinitionDrift>) -> Self {
6404        if let Some(drift) = drift {
6405            if drift.manager == DefinitionKind::LaunchdPlist {
6406                // A restart, not `service install`: the running daemon holds the
6407                // single-instance lock, which refuses an install, and a daemon
6408                // that starts with no runner repairs the plist itself.
6409                let restart = match drift.start_mode {
6410                    StartMode::Boot => format!(
6411                        "sudo launchctl kickstart -k system/{}",
6412                        self.identity.launchd_label()
6413                    ),
6414                    StartMode::Login => format!(
6415                        "launchctl kickstart -k gui/$(id -u)/{}",
6416                        self.identity.launchd_label()
6417                    ),
6418                };
6419                self.problems.push(StatusProblem {
6420                    subject: "definition",
6421                    detail: format!(
6422                        "{} was written by runner-manager {} with a `ProcessType` other than \
6423                         `{LAUNCHD_PROCESS_TYPE}`, which launchd applies to every runner the \
6424                         daemon starts, so their jobs run below normal priority. The daemon \
6425                         rewrites it and reloads itself when it starts with no runner, which it \
6426                         does after every upgrade; to do it now, restart the service while no \
6427                         job is running: `{restart}`. Configuration and credentials are kept.",
6428                        drift.path.display(),
6429                        drift.installed_by_version,
6430                    ),
6431                });
6432                return self;
6433            }
6434            let repair = match drift.start_mode {
6435                StartMode::Boot => format!(
6436                    "`sudo systemctl stop runner-manager && {}` (for a managed WSL host, run \
6437                     `runner-manager wsl install --distribution <name>` from Windows)",
6438                    sudo_command("service install --start-at boot")
6439                ),
6440                StartMode::Login => "`systemctl --user stop runner-manager && runner-manager \
6441                                     service install --start-at login`"
6442                    .to_owned(),
6443            };
6444            self.problems.push(StatusProblem {
6445                subject: "definition",
6446                detail: format!(
6447                    "{} was written by runner-manager {} and differs from what {} renders, so \
6448                     the daemon runs under outdated settings (for example a credential frozen \
6449                     at service start). Re-register it with {repair}; configuration and \
6450                     credentials are kept.",
6451                    drift.path.display(),
6452                    drift.installed_by_version,
6453                    env!("CARGO_PKG_VERSION"),
6454                ),
6455            });
6456        }
6457        self
6458    }
6459
6460    /// Reports that GitHub is rejecting the daemon's credential.
6461    #[must_use]
6462    pub fn with_credential_rejection(mut self, since: Option<DateTime<Utc>>) -> Self {
6463        if let Some(since) = since {
6464            self.credential_rejected_since = Some(since);
6465            self.problems.push(StatusProblem {
6466                subject: "github credential",
6467                detail: format!(
6468                    "GitHub has rejected the service's credential since {}, so the service \
6469                     cannot see queued jobs or start runners. Run `runner-manager auth login` \
6470                     (for a managed WSL host, `runner-manager --host wsl:<name> auth login` \
6471                     from Windows).",
6472                    since.to_rfc3339()
6473                ),
6474            });
6475        }
6476        self
6477    }
6478
6479    /// Since when GitHub has rejected the service's credential, if it has.
6480    #[must_use]
6481    pub const fn credential_rejected_since(&self) -> Option<DateTime<Utc>> {
6482        self.credential_rejected_since
6483    }
6484
6485    /// Reports that the daemon starts no runner because its launches are
6486    /// blocked. See [`crate::launch_health`].
6487    #[must_use]
6488    pub fn with_launches_blocked(
6489        mut self,
6490        blocked: Option<crate::launch_health::LaunchesBlocked>,
6491    ) -> Self {
6492        if let Some(blocked) = &blocked {
6493            self.problems.push(StatusProblem {
6494                subject: LAUNCHES_BLOCKED_SUBJECT,
6495                detail: format!(
6496                    "the service has started no runner {blocked}. Fix: {}.",
6497                    blocked.remedy
6498                ),
6499            });
6500        }
6501        self.launches_blocked = blocked;
6502        self
6503    }
6504
6505    /// Why the service starts no runner, when its launches are blocked.
6506    #[must_use]
6507    pub const fn launches_blocked(&self) -> Option<&crate::launch_health::LaunchesBlocked> {
6508        self.launches_blocked.as_ref()
6509    }
6510
6511    /// Reports that the daemon cannot read its own stored credential, and the
6512    /// exact command that ends it. See [`record_credential_unreadable`].
6513    #[must_use]
6514    pub fn with_credential_unreadable(mut self, since: Option<DateTime<Utc>>) -> Self {
6515        if let Some(since) = since {
6516            let mode = self.start_mode().unwrap_or_default();
6517            self.problems.push(StatusProblem {
6518                subject: CREDENTIAL_UNREADABLE_SUBJECT,
6519                detail: format!(
6520                    "the service has been unable to read its stored GitHub credential since {}, \
6521                     so it starts no runner; the diagnostic log has the store's own answer.{} \
6522                     Sign in again: {}.",
6523                    since.to_rfc3339(),
6524                    if cfg!(target_os = "macos") {
6525                        " That is what a keychain item written by a different build looks like."
6526                    } else {
6527                        ""
6528                    },
6529                    sign_in_instruction(mode)
6530                ),
6531            });
6532        }
6533        self
6534    }
6535
6536    /// What this host's default runner root grants, and to whom.
6537    ///
6538    /// `None` when the platform default could not even be resolved. The
6539    /// descriptor inside has been through
6540    /// [`crate::runner_root_access::redact`], so it names the well-known
6541    /// trustees and says "an account" for everything else — no more identity
6542    /// than the `account` line above it already prints.
6543    #[must_use]
6544    pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
6545        self.runner_root
6546            .as_ref()
6547            .map(|(path, report)| (path.as_path(), report))
6548    }
6549
6550    /// Whether a registration exists at all.
6551    #[must_use]
6552    pub const fn is_installed(&self) -> bool {
6553        self.registration.is_some() || self.record.is_some()
6554    }
6555
6556    /// Whether the daemon is running now.
6557    #[must_use]
6558    pub fn is_running(&self) -> bool {
6559        self.registration
6560            .as_ref()
6561            .is_some_and(|registration| registration.running)
6562    }
6563
6564    /// **False whenever anything is wrong**, including a stale binary path.
6565    #[must_use]
6566    pub fn is_healthy(&self) -> bool {
6567        self.problems.is_empty()
6568    }
6569
6570    /// Everything that is wrong.
6571    #[must_use]
6572    pub fn problems(&self) -> &[StatusProblem] {
6573        &self.problems
6574    }
6575
6576    /// True statements that are not faults — a login-mode registration not
6577    /// running unattended, or an agent that has not yet reached GitHub.
6578    #[must_use]
6579    pub fn notes(&self) -> &[String] {
6580        &self.notes
6581    }
6582
6583    /// Reports that the daemon starts no runner because a required host check
6584    /// fails from its own session. That is the service not doing its job, so
6585    /// it is a problem: a host whose daemon refused every runner used to read
6586    /// `verdict healthy`.
6587    #[must_use]
6588    pub fn with_host_unfit(mut self, unfit: Option<crate::host_fitness::HostUnfitRecord>) -> Self {
6589        if let Some(unfit) = unfit {
6590            self.problems.push(StatusProblem {
6591                subject: HOST_UNFIT_SUBJECT,
6592                detail: format!(
6593                    "the service has started no runner since {}, because a required host check \
6594                     fails from its own session: {}",
6595                    unfit.since.to_rfc3339(),
6596                    unfit.describe()
6597                ),
6598            });
6599        }
6600        self
6601    }
6602
6603    /// Reports a daemon that is running and has stopped making progress. See
6604    /// [`crate::daemon_heartbeat`]: launchd, systemd and the SCM all report
6605    /// such a daemon running, so this used to read `verdict healthy`.
6606    #[must_use]
6607    pub fn with_stall(mut self, stall: Option<String>) -> Self {
6608        if let Some(detail) = stall {
6609            self.problems.push(StatusProblem {
6610                subject: DAEMON_STALLED_SUBJECT,
6611                detail,
6612            });
6613        }
6614        self
6615    }
6616
6617    /// Records whether this registration comes back by itself after an
6618    /// unattended restart, as a note. See [`unattended_gap`].
6619    #[must_use]
6620    pub fn with_unattended_gap(mut self, gap: Option<UnattendedGap>) -> Self {
6621        if let Some(gap) = &gap {
6622            self.notes.push(gap.reason.clone());
6623        }
6624        self.unattended_gap = gap;
6625        self
6626    }
6627
6628    /// Why this registration does not come back by itself after an
6629    /// unattended restart, when it does not.
6630    #[must_use]
6631    pub const fn unattended_gap(&self) -> Option<&UnattendedGap> {
6632        self.unattended_gap.as_ref()
6633    }
6634
6635    /// The service manager's command that restarts this registration, for its
6636    /// start mode (boot when unknown). See [`ServiceIdentity::restart_command`].
6637    #[must_use]
6638    pub fn restart_command(&self) -> String {
6639        self.identity
6640            .restart_command(self.start_mode().unwrap_or(StartMode::Boot))
6641    }
6642
6643    /// The recorded start mode. Journey 5 step 4. For another installation's
6644    /// service whose record this account cannot read, the mode the service
6645    /// manager's domain implies.
6646    #[must_use]
6647    pub fn start_mode(&self) -> Option<StartMode> {
6648        self.record
6649            .as_ref()
6650            .map(|record| record.start_mode)
6651            .or_else(|| {
6652                self.registration
6653                    .as_ref()
6654                    .filter(|_| self.is_unread())
6655                    .map(|registration| registration.start_mode)
6656            })
6657    }
6658
6659    /// The binary the service runs: the recorded one, or for another
6660    /// installation's service whose record this account cannot read, the one
6661    /// the service manager names.
6662    #[must_use]
6663    pub fn service_binary(&self) -> Option<PathBuf> {
6664        self.record
6665            .as_ref()
6666            .map(|record| record.binary.clone())
6667            .or_else(|| {
6668                self.registration
6669                    .as_ref()
6670                    .filter(|_| self.is_unread())
6671                    .and_then(Registration::binary)
6672            })
6673    }
6674
6675    /// The resolved absolute binary path and what became of it. Journey 5
6676    /// step 4 and `05-infrastructure.md` item 6.
6677    #[must_use]
6678    pub const fn binary(&self) -> Option<&BinaryPath> {
6679        self.binary.as_ref()
6680    }
6681
6682    /// The diagnostic log path. `05-infrastructure.md` item 4.
6683    #[must_use]
6684    pub fn log_file(&self) -> &Path {
6685        &self.log_file
6686    }
6687
6688    /// The last successful GitHub contact. Journey 5 step 4.
6689    #[must_use]
6690    pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
6691        self.last_github_contact
6692    }
6693
6694    /// Which store the daemon reads, and whether that agrees with the
6695    /// registration.
6696    #[must_use]
6697    pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
6698        self.store.as_ref()
6699    }
6700
6701    /// The record `install` wrote.
6702    #[must_use]
6703    pub const fn record(&self) -> Option<&InstallRecord> {
6704        self.record.as_ref()
6705    }
6706
6707    /// What the service manager says.
6708    #[must_use]
6709    pub const fn registration(&self) -> Option<&Registration> {
6710        self.registration.as_ref()
6711    }
6712}
6713
6714impl fmt::Display for ServiceStatus {
6715    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6716        writeln!(f, "Service: {}", self.identity)?;
6717        match (&self.record, &self.registration) {
6718            (None, None) => {
6719                writeln!(
6720                    f,
6721                    "  installed                 no. `service install` registers `{} {}`.",
6722                    SERVICE_NAME,
6723                    DAEMON_ARGUMENTS.join(" ")
6724                )?;
6725            }
6726            _ => {
6727                let manager = self
6728                    .registration
6729                    .as_ref()
6730                    .map(|registration| registration.manager.manager());
6731                writeln!(
6732                    f,
6733                    "  installed                 {}",
6734                    manager.unwrap_or("yes, but no service manager knows it")
6735                )?;
6736                writeln!(
6737                    f,
6738                    "  state                     {}",
6739                    if self.is_running() {
6740                        "running"
6741                    } else {
6742                        "not running"
6743                    }
6744                )?;
6745            }
6746        }
6747        if let Some(foreign) = &self.foreign {
6748            writeln!(
6749                f,
6750                "  installed from            {} -- another installation's, not this account's ({})",
6751                foreign.installed_by(),
6752                foreign.local_root.display()
6753            )?;
6754            // Without the record, the service manager's own facts.
6755            if foreign.is_unread()
6756                && let Some(registration) = &self.registration
6757            {
6758                writeln!(f, "  start mode                {}", registration.start_mode)?;
6759                if let Some(account) = &registration.account {
6760                    writeln!(f, "  account                   {account}")?;
6761                }
6762                if let Some(binary) = self.service_binary() {
6763                    writeln!(f, "  binary                    {}", binary.display())?;
6764                }
6765            }
6766        }
6767        if let Some(record) = &self.record {
6768            writeln!(f, "  start mode                {}", record.start_mode)?;
6769            let recorded = record.account.to_string();
6770            let account = registered_account(self.registration.as_ref(), &recorded);
6771            writeln!(f, "  account                   {account}")?;
6772            writeln!(f, "  restart on failure        {}", record.restart())?;
6773            writeln!(
6774                f,
6775                "  arguments                 {}",
6776                record.arguments.join(" ")
6777            )?;
6778        }
6779        if let Some(binary) = &self.binary {
6780            writeln!(f, "  binary                    {binary}")?;
6781        }
6782        writeln!(f, "  diagnostic log            {}", self.log_file.display())?;
6783        if let Some((path, report)) = &self.runner_root {
6784            // Named as the *default* rather than as "the runner root", because
6785            // it is only the effective one until an operator runs
6786            // `host set-runtime-root`. This crate cannot see that setting — it
6787            // lives in the application's store — so an unqualified label here
6788            // would contradict the `runner root … (host-configured)` row that
6789            // `status` and `host show` print from the value that is in force.
6790            writeln!(f, "  default runner root       {}", path.display())?;
6791            if *report != RootAccessReport::NotApplicable {
6792                writeln!(f, "  default root access       {report}")?;
6793            }
6794        }
6795        if let Some(store) = &self.store {
6796            writeln!(f, "  secret store              {store}")?;
6797        }
6798        // Not known, rather than "never", for a service whose records were not
6799        // read: its contact file is in a directory this account cannot open.
6800        if !self.is_unread() {
6801            writeln!(
6802                f,
6803                "  last GitHub contact       {}",
6804                match self.last_github_contact {
6805                    Some(at) => at.to_rfc3339(),
6806                    None => "never".to_string(),
6807                }
6808            )?;
6809        }
6810        for note in &self.notes {
6811            writeln!(f, "  note                      {note}")?;
6812        }
6813        for problem in &self.problems {
6814            writeln!(f, "  ERROR                     {problem}")?;
6815        }
6816        write!(f, "  verdict                   {}", self.verdict())
6817    }
6818}
6819
6820// ---------------------------------------------------------------------------
6821// The in-memory double
6822// ---------------------------------------------------------------------------
6823
6824/// A [`ControlFactory`] that registers nothing and remembers everything.
6825///
6826/// Public rather than `#[cfg(test)]` on purpose. `f3` builds three commands on
6827/// [`ServiceOperations`] and has to be able to test them without a service
6828/// manager, and a double that lived behind `#[cfg(test)]` here would be
6829/// invisible from another crate — which would leave `f3` either untested or
6830/// writing a second double that drifts from this one.
6831///
6832/// It is not a simulator. It stores what it was asked to register and reports it
6833/// back, which is exactly enough to exercise the logic that sits *above* a
6834/// service manager: the lock refusal, the record, the stale-path detection, the
6835/// start-mode switch, and the promise that uninstall deletes nothing else.
6836#[derive(Debug, Clone, Default)]
6837pub struct RecordingControls {
6838    state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
6839}
6840
6841#[derive(Debug, Default)]
6842struct RecordingState {
6843    registrations: BTreeMap<(StartMode, String), Registration>,
6844    definitions: BTreeMap<String, ServiceDefinition>,
6845    calls: Vec<String>,
6846    #[cfg(test)]
6847    install_failures: BTreeMap<StartMode, String>,
6848    #[cfg(test)]
6849    after_install: BTreeMap<StartMode, TestInstallSideEffect>,
6850}
6851
6852#[cfg(test)]
6853#[derive(Debug, Clone)]
6854enum TestInstallSideEffect {
6855    HideDirectory { directory: PathBuf, hidden: PathBuf },
6856}
6857
6858impl RecordingControls {
6859    /// A factory holding no registrations.
6860    #[must_use]
6861    pub fn new() -> Self {
6862        Self::default()
6863    }
6864
6865    /// Every call made through this factory, in order, as
6866    /// `"<operation> <name> (<mode>)"`.
6867    #[must_use]
6868    pub fn calls(&self) -> Vec<String> {
6869        self.state.lock().expect("not poisoned").calls.clone()
6870    }
6871
6872    /// Every registration currently held, with the domain it is in.
6873    #[must_use]
6874    pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
6875        self.state
6876            .lock()
6877            .expect("not poisoned")
6878            .registrations
6879            .iter()
6880            .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
6881            .collect()
6882    }
6883
6884    /// The definition applied for a registration, if it is held.
6885    #[must_use]
6886    pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
6887        self.state
6888            .lock()
6889            .expect("not poisoned")
6890            .definitions
6891            .get(name)
6892            .cloned()
6893    }
6894
6895    /// Edits a held registration, as something outside this product would.
6896    ///
6897    /// This is how the divergence and start-type problems are made reachable
6898    /// from a test: `sc config`, `launchctl` and `systemctl` can all change a
6899    /// registration after installation, and a status command that could not be
6900    /// shown detecting that would be a status command nobody had tested against
6901    /// the case it exists for.
6902    ///
6903    /// Does nothing when no registration of that name is held.
6904    pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
6905        let mut state = self.state.lock().expect("not poisoned");
6906        if let Some((_, registration)) = state
6907            .registrations
6908            .iter_mut()
6909            .find(|((_, held), _)| held == name)
6910        {
6911            edit(registration);
6912        }
6913    }
6914
6915    #[cfg(test)]
6916    fn fail_next_install(&self, mode: StartMode, detail: &str) {
6917        self.state
6918            .lock()
6919            .expect("not poisoned")
6920            .install_failures
6921            .insert(mode, detail.to_string());
6922    }
6923
6924    #[cfg(test)]
6925    fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
6926        self.state
6927            .lock()
6928            .expect("not poisoned")
6929            .after_install
6930            .insert(
6931                mode,
6932                TestInstallSideEffect::HideDirectory { directory, hidden },
6933            );
6934    }
6935}
6936
6937impl ControlFactory for RecordingControls {
6938    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6939        Ok(Box::new(RecordingControl {
6940            mode,
6941            state: std::sync::Arc::clone(&self.state),
6942        }))
6943    }
6944}
6945
6946#[derive(Debug)]
6947struct RecordingControl {
6948    mode: StartMode,
6949    state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
6950}
6951
6952impl RecordingControl {
6953    fn note(&self, operation: &str, name: &str) {
6954        self.state
6955            .lock()
6956            .expect("not poisoned")
6957            .calls
6958            .push(format!("{operation} {name} ({})", self.mode));
6959    }
6960}
6961
6962impl ServiceControl for RecordingControl {
6963    fn manager(&self) -> DefinitionKind {
6964        // The double reports the kind this host's real backend would, so a test
6965        // that asserts on the manager name asserts something true of the
6966        // platform it is running on.
6967        host_definition_kind(self.mode)
6968    }
6969
6970    fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6971        self.note("install", plan.identity().name());
6972        #[cfg(test)]
6973        if let Some(detail) = self
6974            .state
6975            .lock()
6976            .expect("not poisoned")
6977            .install_failures
6978            .remove(&self.mode)
6979        {
6980            return Err(ServiceError::Control {
6981                operation: "install",
6982                name: plan.identity().name().to_string(),
6983                manager: "recording control",
6984                detail,
6985            });
6986        }
6987        // The real definition, not a stub. Rendering is pure, so the double can
6988        // afford it -- and a stub would make `Installed::review` report that the
6989        // definition confirms nothing, which is exactly the shape of false
6990        // assurance a test double must never hand back.
6991        let definition = ServiceDefinition::for_host(plan)?;
6992        let mut state = self.state.lock().expect("not poisoned");
6993        state.registrations.insert(
6994            (self.mode, plan.identity().name().to_string()),
6995            Registration {
6996                manager: host_definition_kind(self.mode),
6997                start_mode: self.mode,
6998                command_line: plan.command_line(),
6999                account: (*plan.account() != ServiceAccount::InvokingUser)
7000                    .then(|| plan.account().as_str().to_string()),
7001                running: false,
7002                starts_automatically: true,
7003                restart_delay: Some(plan.restart().delay()),
7004            },
7005        );
7006        state
7007            .definitions
7008            .insert(plan.identity().name().to_string(), definition.clone());
7009        #[cfg(test)]
7010        let side_effect = state.after_install.remove(&self.mode);
7011        drop(state);
7012        #[cfg(test)]
7013        if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
7014            std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
7015            std::fs::write(&directory, b"blocks recreation")
7016                .expect("test fault can block record directory recreation");
7017        }
7018        Ok(definition)
7019    }
7020
7021    fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7022        self.note("uninstall", identity.name());
7023        let mut state = self.state.lock().expect("not poisoned");
7024        state.definitions.remove(identity.name());
7025        Ok(state
7026            .registrations
7027            .remove(&(self.mode, identity.name().to_string()))
7028            .is_some())
7029    }
7030
7031    fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7032        self.note("query", identity.name());
7033        Ok(self
7034            .state
7035            .lock()
7036            .expect("not poisoned")
7037            .registrations
7038            .get(&(self.mode, identity.name().to_string()))
7039            .cloned())
7040    }
7041
7042    fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7043        self.note("start", identity.name());
7044        let mut state = self.state.lock().expect("not poisoned");
7045        match state
7046            .registrations
7047            .get_mut(&(self.mode, identity.name().to_string()))
7048        {
7049            Some(registration) => {
7050                registration.running = true;
7051                Ok(())
7052            }
7053            None => Err(ServiceError::NotInstalled {
7054                name: identity.name().to_string(),
7055                operation: "start",
7056            }),
7057        }
7058    }
7059
7060    fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7061        self.note("stop", identity.name());
7062        let mut state = self.state.lock().expect("not poisoned");
7063        match state
7064            .registrations
7065            .get_mut(&(self.mode, identity.name().to_string()))
7066        {
7067            Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
7068            None => Err(ServiceError::NotInstalled {
7069                name: identity.name().to_string(),
7070                operation: "stop",
7071            }),
7072        }
7073    }
7074}
7075
7076impl ControlFactory for HostControls {
7077    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7078        sys::control(mode)
7079    }
7080
7081    fn installed_definition(&self, path: &Path) -> Option<String> {
7082        std::fs::read_to_string(path).ok()
7083    }
7084
7085    fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
7086        crate::unattended_login::probe()
7087    }
7088}
7089
7090/// The operator's home directory, when the platform reports one.
7091///
7092/// Only login-mode registrations need it — a LaunchAgent and a systemd user
7093/// unit both live under it — and it is resolved lazily so that a boot-mode
7094/// operation on an account with no profile is not refused for wanting something
7095/// it never uses.
7096#[must_use]
7097pub fn host_home() -> Option<PathBuf> {
7098    directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
7099}
7100
7101/// The Unix backends' name for the same thing.
7102#[cfg(unix)]
7103fn home_directory() -> Option<PathBuf> {
7104    host_home()
7105}
7106
7107/// Runs a command and returns its exit status with both streams as text.
7108///
7109/// The three Unix backends and the Windows Task Scheduler backend all drive a
7110/// stock command-line tool, and all four want the same three things back. The
7111/// tools are chosen for machine-readable, locale-independent output wherever one
7112/// exists — `systemctl is-active`, `launchctl print`, `schtasks /XML` — because
7113/// parsing a localised human-facing table is how a status command starts lying
7114/// on somebody else's machine.
7115fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
7116    let output = std::process::Command::new(program)
7117        .args(arguments)
7118        .output()?;
7119    Ok((
7120        output.status.success(),
7121        String::from_utf8_lossy(&output.stdout).into_owned(),
7122        String::from_utf8_lossy(&output.stderr).into_owned(),
7123    ))
7124}
7125
7126/// Which manager holds a given start-mode domain on the platform this binary
7127/// was built for.
7128#[must_use]
7129pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
7130    if cfg!(windows) {
7131        match mode {
7132            StartMode::Boot => DefinitionKind::WindowsService,
7133            StartMode::Login => DefinitionKind::WindowsScheduledTask,
7134        }
7135    } else if cfg!(target_os = "macos") {
7136        DefinitionKind::LaunchdPlist
7137    } else {
7138        DefinitionKind::SystemdUnit
7139    }
7140}
7141
7142/// Enables a launchd label after it has been bootstrapped.
7143///
7144/// `launchctl bootstrap` deliberately preserves a label's disabled bit. An
7145/// `enable` failure therefore means the new registration cannot satisfy its
7146/// start policy. Compensate by booting it out and removing the plist so a
7147/// failed install cannot look installed to `service status`.
7148#[cfg(any(target_os = "macos", test))]
7149fn enable_launchd_registration(
7150    mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
7151    domain: &str,
7152    service_target: &str,
7153    plist: &Path,
7154    name: &str,
7155    elevation_remedy: &'static str,
7156) -> Result<(), ServiceError> {
7157    let (enabled, cause) = launchctl(&[
7158        std::ffi::OsStr::new("enable"),
7159        std::ffi::OsStr::new(service_target),
7160    ]);
7161    if enabled {
7162        return Ok(());
7163    }
7164
7165    let (booted_out, bootout_detail) = launchctl(&[
7166        std::ffi::OsStr::new("bootout"),
7167        std::ffi::OsStr::new(service_target),
7168    ]);
7169    let removed = std::fs::remove_file(plist);
7170    if !booted_out || removed.is_err() {
7171        return Err(ServiceError::Rollback {
7172            operation: "enable launchd registration",
7173            name: name.to_string(),
7174            cause,
7175            rollback: format!(
7176                "launchctl bootout {domain}: {}; remove {}: {}",
7177                if booted_out {
7178                    "succeeded".to_string()
7179                } else {
7180                    bootout_detail
7181                },
7182                plist.display(),
7183                removed
7184                    .err()
7185                    .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
7186            ),
7187        });
7188    }
7189
7190    if cause.to_ascii_lowercase().contains("permission denied") {
7191        Err(ServiceError::NeedsElevation {
7192            operation: "enable",
7193            name: name.to_string(),
7194            detail: cause,
7195            remedy: elevation_remedy,
7196        })
7197    } else {
7198        Err(ServiceError::Control {
7199            operation: "enable",
7200            name: name.to_string(),
7201            manager: "launchd",
7202            detail: cause,
7203        })
7204    }
7205}
7206
7207// ---------------------------------------------------------------------------
7208// Windows
7209// ---------------------------------------------------------------------------
7210
7211/// A stop request delivered by the Windows Service Control Manager.
7212///
7213/// The application owns the drain policy; this platform boundary only turns
7214/// `SERVICE_CONTROL_STOP`/`SHUTDOWN` into an awaitable notification.
7215#[derive(Debug, Clone)]
7216pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
7217
7218impl ServiceShutdown {
7219    /// Waits until SCM asks the service to stop or shut down.
7220    pub async fn wait(mut self) {
7221        if !*self.0.borrow() {
7222            let _ = self.0.changed().await;
7223        }
7224    }
7225}
7226
7227/// Runs the production process as a real Windows service host.
7228///
7229/// `StartServiceCtrlDispatcher` must run in the service process's main thread,
7230/// so the callback is handed through a process-global slot to the entrypoint
7231/// invoked by SCM. The slot is single-use by design: one process hosts exactly
7232/// one own-process service.
7233#[cfg(windows)]
7234pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
7235where
7236    F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
7237{
7238    windows_host::run(Box::new(run))
7239}
7240
7241#[cfg(windows)]
7242mod windows_host {
7243    use std::ffi::OsString;
7244    use std::sync::{Arc, Mutex, OnceLock, mpsc};
7245    use std::time::Duration;
7246
7247    use windows_service::service::{
7248        ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
7249        ServiceType,
7250    };
7251    use windows_service::service_control_handler::{
7252        self, ServiceControlHandlerResult, ServiceStatusHandle,
7253    };
7254
7255    use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
7256
7257    type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
7258
7259    struct Invocation {
7260        run: Runner,
7261        result: mpsc::SyncSender<Result<u8, String>>,
7262    }
7263
7264    static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
7265
7266    windows_service::define_windows_service!(ffi_service_main, service_main);
7267
7268    pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
7269        let (result_tx, result_rx) = mpsc::sync_channel(1);
7270        let slot = INVOCATION.get_or_init(|| Mutex::new(None));
7271        let mut invocation = slot
7272            .lock()
7273            .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
7274        if invocation.is_some() {
7275            return Err(host_error(
7276                "prepare",
7277                "the service-host slot was already used",
7278            ));
7279        }
7280        *invocation = Some(Invocation {
7281            run,
7282            result: result_tx,
7283        });
7284        drop(invocation);
7285
7286        windows_service::service_dispatcher::start("", ffi_service_main)
7287            .map_err(|error| host_error("connect", &error.to_string()))?;
7288        result_rx
7289            .recv()
7290            .map_err(|error| host_error("finish", &error.to_string()))?
7291            .map_err(|detail| host_error("run", &detail))
7292    }
7293
7294    fn service_main(_arguments: Vec<OsString>) {
7295        let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
7296            return;
7297        };
7298        let result = run_service(invocation.run);
7299        let _ = invocation.result.send(result);
7300    }
7301
7302    fn run_service(run: Runner) -> Result<u8, String> {
7303        let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
7304        let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
7305        let handler_status = Arc::clone(&status);
7306        let handler = move |control| match control {
7307            ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
7308            ServiceControl::Stop | ServiceControl::Shutdown => {
7309                if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
7310                    let _ = handle.set_service_status(service_status(
7311                        ServiceState::StopPending,
7312                        ServiceControlAccept::empty(),
7313                        1,
7314                        Duration::from_secs(300),
7315                        0,
7316                    ));
7317                }
7318                let _ = stop_tx.send(true);
7319                ServiceControlHandlerResult::NoError
7320            }
7321            _ => ServiceControlHandlerResult::NotImplemented,
7322        };
7323        let handle = service_control_handler::register("", handler)
7324            .map_err(|error| format!("cannot register the service control handler: {error}"))?;
7325        *status
7326            .lock()
7327            .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
7328        handle
7329            .set_service_status(service_status(
7330                ServiceState::Running,
7331                ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
7332                0,
7333                Duration::default(),
7334                0,
7335            ))
7336            .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
7337
7338        let exit = run(ServiceShutdown(stop_rx));
7339        handle
7340            .set_service_status(service_status(
7341                ServiceState::Stopped,
7342                ServiceControlAccept::empty(),
7343                0,
7344                Duration::default(),
7345                u32::from(exit),
7346            ))
7347            .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
7348        Ok(exit)
7349    }
7350
7351    fn service_status(
7352        state: ServiceState,
7353        accepted: ServiceControlAccept,
7354        checkpoint: u32,
7355        wait_hint: Duration,
7356        exit: u32,
7357    ) -> ServiceStatus {
7358        ServiceStatus {
7359            service_type: ServiceType::OWN_PROCESS,
7360            current_state: state,
7361            controls_accepted: accepted,
7362            exit_code: if exit == 0 {
7363                ServiceExitCode::Win32(0)
7364            } else {
7365                ServiceExitCode::ServiceSpecific(exit)
7366            },
7367            checkpoint,
7368            wait_hint,
7369            process_id: None,
7370        }
7371    }
7372
7373    fn host_error(operation: &'static str, detail: &str) -> ServiceError {
7374        ServiceError::Control {
7375            operation,
7376            name: SERVICE_NAME.to_string(),
7377            manager: "the Windows Service Control Manager",
7378            detail: detail.to_string(),
7379        }
7380    }
7381}
7382
7383#[cfg(windows)]
7384mod sys {
7385    //! Two managers, because Windows has two answers.
7386    //!
7387    //! `--start-at boot` is a service in the Service Control Manager, which is
7388    //! the only Windows facility that starts something before anybody logs in.
7389    //! `--start-at login` is a Task Scheduler task with a logon trigger, which
7390    //! is the only Windows facility that starts something when somebody does.
7391    //! There is no single mechanism that does both: service trigger-start has no
7392    //! logon trigger, and a scheduled task cannot run before a session exists.
7393
7394    use std::ffi::{OsStr, OsString};
7395    use std::time::{Duration, Instant};
7396
7397    use runner_manager_domain::model::StartMode;
7398    use windows_service::service::{
7399        ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
7400        ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
7401        ServiceState, ServiceType,
7402    };
7403    use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
7404
7405    use super::{
7406        DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
7407        ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
7408    };
7409
7410    /// `ERROR_SERVICE_DOES_NOT_EXIST`. Not an error here: it is the answer to
7411    /// "is this registered".
7412    const SERVICE_DOES_NOT_EXIST: i32 = 1060;
7413    /// `ERROR_SERVICE_MARKED_FOR_DELETE`. `DeleteService` is asynchronous: the
7414    /// registration remains in this state until every open service handle is
7415    /// closed, and callers must not mistake that short window for a leak.
7416    const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
7417    /// `ERROR_ACCESS_DENIED`.
7418    const ACCESS_DENIED: i32 = 5;
7419    const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
7420    const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
7421
7422    const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
7423         choose \"Run as administrator\".";
7424
7425    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7426        Ok(match mode {
7427            StartMode::Boot => Box::new(ScmControl),
7428            StartMode::Login => Box::new(TaskControl),
7429        })
7430    }
7431
7432    // -- the Service Control Manager -----------------------------------------
7433
7434    #[derive(Debug)]
7435    struct ScmControl;
7436
7437    /// Turns a `windows-service` failure into this module's error, keeping the
7438    /// "needs elevation" case separate: an operator told "access is denied" and
7439    /// an operator told "run this elevated" are not equally well served.
7440    fn scm_error(
7441        operation: &'static str,
7442        name: &str,
7443        error: &windows_service::Error,
7444    ) -> ServiceError {
7445        let raw = match error {
7446            windows_service::Error::Winapi(io) => io.raw_os_error(),
7447            _ => None,
7448        };
7449        let detail = match error {
7450            windows_service::Error::Winapi(io) => io.to_string(),
7451            other => other.to_string(),
7452        };
7453        if raw == Some(ACCESS_DENIED) {
7454            return ServiceError::NeedsElevation {
7455                operation,
7456                name: name.to_string(),
7457                detail,
7458                remedy: ELEVATION_REMEDY,
7459            };
7460        }
7461        ServiceError::Control {
7462            operation,
7463            name: name.to_string(),
7464            manager: "the Windows Service Control Manager",
7465            detail,
7466        }
7467    }
7468
7469    fn open_manager(
7470        access: ServiceManagerAccess,
7471        operation: &'static str,
7472        name: &str,
7473    ) -> Result<ServiceManager, ServiceError> {
7474        ServiceManager::local_computer(None::<&OsStr>, access)
7475            .map_err(|error| scm_error(operation, name, &error))
7476    }
7477
7478    impl ServiceControl for ScmControl {
7479        fn manager(&self) -> DefinitionKind {
7480            DefinitionKind::WindowsService
7481        }
7482
7483        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7484            let spec = windows_service_spec(plan);
7485            let manager = open_manager(
7486                ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
7487                "install",
7488                &spec.name,
7489            )?;
7490            let info = ServiceInfo {
7491                name: OsString::from(&spec.name),
7492                display_name: OsString::from(&spec.display_name),
7493                // Never INTERACTIVE_PROCESS: see `windows_service_descriptor`.
7494                service_type: ServiceType::OWN_PROCESS,
7495                start_type: if spec.automatic_start {
7496                    ServiceStartType::AutoStart
7497                } else {
7498                    ServiceStartType::OnDemand
7499                },
7500                error_control: ServiceErrorControl::Normal,
7501                executable_path: plan.binary().to_path_buf(),
7502                launch_arguments: plan.arguments().to_vec(),
7503                dependencies: Vec::new(),
7504                // `None` is LocalSystem, which is the account `d2`'s DACL admits.
7505                account_name: spec.account.as_ref().map(OsString::from),
7506                // No password is ever taken, asked for, or stored. An account
7507                // that needed one would be an account this installer refuses.
7508                account_password: None,
7509            };
7510            let service = manager
7511                .create_service(
7512                    &info,
7513                    ServiceAccess::CHANGE_CONFIG
7514                        | ServiceAccess::QUERY_CONFIG
7515                        | ServiceAccess::QUERY_STATUS
7516                        | ServiceAccess::START
7517                        | ServiceAccess::STOP
7518                        | ServiceAccess::DELETE,
7519                )
7520                .map_err(|error| scm_error("install", &spec.name, &error))?;
7521            service
7522                .set_description(&spec.description)
7523                .map_err(|error| scm_error("describe", &spec.name, &error))?;
7524            service
7525                .update_failure_actions(ServiceFailureActions {
7526                    reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
7527                    reboot_msg: None,
7528                    command: None,
7529                    // Three identical restart actions rather than one: the
7530                    // Service Control Manager applies the first action to the
7531                    // first failure, the second to the second, and the last to
7532                    // every failure after that. One action would leave the
7533                    // second and later failures unhandled, which is a service
7534                    // that comes back once and then stays down.
7535                    actions: Some(vec![
7536                        ServiceAction {
7537                            action_type: ServiceActionType::Restart,
7538                            delay: spec.restart.delay(),
7539                        };
7540                        3
7541                    ]),
7542                })
7543                .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7544            // Without this the failure actions apply only to a crash, and a
7545            // daemon that exits non-zero is not a crash.
7546            service
7547                .set_failure_actions_on_non_crash_failures(true)
7548                .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7549            Ok(ServiceDefinition::windows_service(plan))
7550        }
7551
7552        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7553            let manager =
7554                open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
7555            let service = match manager.open_service(
7556                identity.name(),
7557                ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
7558            ) {
7559                Ok(service) => Some(service),
7560                Err(error) if is_missing(&error) => return Ok(false),
7561                // A previous caller has already asked SCM to delete this
7562                // registration. That is the desired state, not an uninstall
7563                // failure. Wait for SCM to release the name so a following
7564                // `service install` can use it immediately.
7565                Err(error) if is_marked_for_delete(&error) => None,
7566                Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
7567            };
7568            if let Some(service) = service {
7569                // A running service can be deleted, but it lingers until it stops.
7570                // Stopping first is what makes `uninstall` followed by `install`
7571                // work in one sitting, which is what the start-mode switch needs.
7572                if let Ok(status) = service.query_status()
7573                    && status.current_state != ServiceState::Stopped
7574                {
7575                    let _ = service.stop();
7576                }
7577                match service.delete() {
7578                    Ok(()) => {}
7579                    // DeleteService is idempotent from the operator's point of
7580                    // view. Another process can mark the service between our
7581                    // OpenService and DeleteService calls.
7582                    Err(error) if is_marked_for_delete(&error) => {}
7583                    Err(error) => {
7584                        return Err(scm_error("uninstall", identity.name(), &error));
7585                    }
7586                }
7587                drop(service);
7588            }
7589
7590            // `DeleteService` marks a registration for deletion and returns;
7591            // SCM removes it only after the last service handle closes. Drop
7592            // ours before polling, then wait for the observable postcondition
7593            // promised by `uninstall`: an immediate status check must not see
7594            // a registration that is merely on its way out. This also keeps a
7595            // stop/uninstall/install sequence deterministic on busy hosts.
7596            let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
7597                match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
7598                    Ok(service) => {
7599                        drop(service);
7600                        Ok(false)
7601                    }
7602                    Err(error) if is_missing(&error) => Ok(true),
7603                    Err(error) if is_marked_for_delete(&error) => Ok(false),
7604                    Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
7605                }
7606            })?;
7607            if !absent {
7608                return Err(ServiceError::Control {
7609                    operation: "verify uninstall of",
7610                    name: identity.name().to_string(),
7611                    manager: "the Windows Service Control Manager",
7612                    detail: format!(
7613                        "the registration was still visible {} seconds after DeleteService; \
7614                         retry `service uninstall` from an elevated prompt",
7615                        DELETE_TIMEOUT.as_secs()
7616                    ),
7617                });
7618            }
7619            Ok(true)
7620        }
7621
7622        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7623            let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
7624            let service = match manager.open_service(
7625                identity.name(),
7626                ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
7627            ) {
7628                Ok(service) => service,
7629                Err(error) if is_missing(&error) => return Ok(None),
7630                Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
7631            };
7632            let config = service
7633                .query_config()
7634                .map_err(|error| scm_error("inspect", identity.name(), &error))?;
7635            let status = service
7636                .query_status()
7637                .map_err(|error| scm_error("inspect", identity.name(), &error))?;
7638            let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
7639                actions
7640                    .actions
7641                    .and_then(|actions| actions.into_iter().next())
7642                    .filter(|action| action.action_type == ServiceActionType::Restart)
7643                    .map(|action| action.delay)
7644            });
7645            Ok(Some(Registration {
7646                manager: DefinitionKind::WindowsService,
7647                start_mode: StartMode::Boot,
7648                // `lpBinaryPathName` holds the executable *and* its arguments
7649                // as one string, which is why this module carries its own
7650                // parser rather than trusting `executable_path`'s name.
7651                command_line: config.executable_path.to_string_lossy().into_owned(),
7652                account: config
7653                    .account_name
7654                    .map(|account| super::windows_service_account(&account.to_string_lossy())),
7655                running: status.current_state == ServiceState::Running,
7656                starts_automatically: config.start_type == ServiceStartType::AutoStart,
7657                restart_delay,
7658            }))
7659        }
7660
7661        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7662            let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
7663            let service = manager
7664                .open_service(identity.name(), ServiceAccess::START)
7665                .map_err(|error| scm_error("start", identity.name(), &error))?;
7666            service
7667                .start::<&OsStr>(&[])
7668                .map_err(|error| scm_error("start", identity.name(), &error))
7669        }
7670
7671        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7672            let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
7673            let service = manager
7674                .open_service(
7675                    identity.name(),
7676                    ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
7677                )
7678                .map_err(|error| scm_error("stop", identity.name(), &error))?;
7679            let status = service
7680                .query_status()
7681                .map_err(|error| scm_error("stop", identity.name(), &error))?;
7682            if status.current_state == ServiceState::Stopped {
7683                return Ok(false);
7684            }
7685            service
7686                .stop()
7687                .map_err(|error| scm_error("stop", identity.name(), &error))?;
7688            Ok(true)
7689        }
7690    }
7691
7692    fn is_missing(error: &windows_service::Error) -> bool {
7693        matches!(error, windows_service::Error::Winapi(io)
7694            if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
7695    }
7696
7697    pub(super) fn is_marked_for_delete(error: &windows_service::Error) -> bool {
7698        matches!(error, windows_service::Error::Winapi(io)
7699            if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
7700    }
7701
7702    pub(super) fn wait_until_scm_absent(
7703        timeout: Duration,
7704        poll_interval: Duration,
7705        mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
7706    ) -> Result<bool, ServiceError> {
7707        let deadline = Instant::now() + timeout;
7708        loop {
7709            if probe_absent()? {
7710                return Ok(true);
7711            }
7712            if Instant::now() >= deadline {
7713                return Ok(false);
7714            }
7715            std::thread::sleep(poll_interval);
7716        }
7717    }
7718
7719    // -- Task Scheduler ------------------------------------------------------
7720
7721    #[derive(Debug)]
7722    struct TaskControl;
7723
7724    fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
7725        if detail.to_ascii_lowercase().contains("access is denied") {
7726            return ServiceError::NeedsElevation {
7727                operation,
7728                name: name.to_string(),
7729                detail,
7730                remedy: ELEVATION_REMEDY,
7731            };
7732        }
7733        ServiceError::Control {
7734            operation,
7735            name: name.to_string(),
7736            manager: "Windows Task Scheduler",
7737            detail,
7738        }
7739    }
7740
7741    fn schtasks(
7742        operation: &'static str,
7743        name: &str,
7744        arguments: &[&OsStr],
7745    ) -> Result<(bool, String), ServiceError> {
7746        match run("schtasks.exe", arguments) {
7747            Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
7748            Err(error) => Err(task_error(operation, name, error.to_string())),
7749        }
7750    }
7751
7752    /// Task Scheduler reads `/XML` files as UTF-16, so the document is written
7753    /// as UTF-16 little-endian with a byte-order mark rather than as UTF-8.
7754    fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
7755        let mut bytes = vec![0xFF, 0xFE];
7756        for unit in text.encode_utf16() {
7757            bytes.extend_from_slice(&unit.to_le_bytes());
7758        }
7759        std::fs::write(path, bytes)
7760    }
7761
7762    impl ServiceControl for TaskControl {
7763        fn manager(&self) -> DefinitionKind {
7764            DefinitionKind::WindowsScheduledTask
7765        }
7766
7767        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7768            let name = plan.identity().name().to_string();
7769            let principal = TaskPrincipal::current()?;
7770            let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
7771            let directory = tempfile::tempdir()
7772                .map_err(|error| task_error("install", &name, error.to_string()))?;
7773            let document = directory.path().join("task.xml");
7774            write_utf16(&document, definition.text())
7775                .map_err(|error| task_error("install", &name, error.to_string()))?;
7776            let (ok, message) = schtasks(
7777                "install",
7778                &name,
7779                &[
7780                    OsStr::new("/Create"),
7781                    OsStr::new("/TN"),
7782                    OsStr::new(&name),
7783                    OsStr::new("/XML"),
7784                    document.as_os_str(),
7785                ],
7786            )?;
7787            if !ok {
7788                return Err(task_error("install", &name, message));
7789            }
7790            Ok(definition)
7791        }
7792
7793        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7794            if self.query(identity)?.is_none() {
7795                return Ok(false);
7796            }
7797            let name = identity.name().to_string();
7798            let (ok, message) = schtasks(
7799                "uninstall",
7800                &name,
7801                &[
7802                    OsStr::new("/Delete"),
7803                    OsStr::new("/TN"),
7804                    OsStr::new(&name),
7805                    OsStr::new("/F"),
7806                ],
7807            )?;
7808            if !ok {
7809                return Err(task_error("uninstall", &name, message));
7810            }
7811            Ok(true)
7812        }
7813
7814        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7815            let name = identity.name().to_string();
7816            let (ok, document) = schtasks(
7817                "inspect",
7818                &name,
7819                &[
7820                    OsStr::new("/Query"),
7821                    OsStr::new("/TN"),
7822                    OsStr::new(&name),
7823                    OsStr::new("/XML"),
7824                    OsStr::new("ONE"),
7825                ],
7826            )?;
7827            if !ok {
7828                // `schtasks` reports a missing task and a broken Task Scheduler
7829                // the same way, with a non-zero exit; there is no distinct code.
7830                // Treating it as absence is the safe reading: the caller either
7831                // installs, which will then fail loudly, or reports "not
7832                // installed", which is what an operator with no task sees.
7833                return Ok(None);
7834            }
7835            let command = xml_value(&document, "Command").unwrap_or_default();
7836            let arguments = xml_value(&document, "Arguments").unwrap_or_default();
7837            let command_line = if arguments.is_empty() {
7838                super::quote_argument(&command)
7839            } else {
7840                format!("{} {arguments}", super::quote_argument(&command))
7841            };
7842            Ok(Some(Registration {
7843                manager: DefinitionKind::WindowsScheduledTask,
7844                start_mode: StartMode::Login,
7845                command_line,
7846                account: super::scheduled_task_account(xml_value(&document, "UserId")),
7847                running: task_is_running(&name),
7848                starts_automatically: super::windows_login_task_starts_automatically(&document),
7849                restart_delay: xml_value(&document, "Interval")
7850                    .as_deref()
7851                    .and_then(parse_iso8601),
7852            }))
7853        }
7854
7855        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7856            let name = identity.name().to_string();
7857            let (ok, message) = schtasks(
7858                "start",
7859                &name,
7860                &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
7861            )?;
7862            if ok {
7863                Ok(())
7864            } else {
7865                Err(task_error("start", &name, message))
7866            }
7867        }
7868
7869        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7870            let running = self
7871                .query(identity)?
7872                .is_some_and(|registration| registration.running);
7873            if !running {
7874                return Ok(false);
7875            }
7876            let name = identity.name().to_string();
7877            let (ok, message) = schtasks(
7878                "stop",
7879                &name,
7880                &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
7881            )?;
7882            if ok {
7883                Ok(true)
7884            } else {
7885                Err(task_error("stop", &name, message))
7886            }
7887        }
7888    }
7889
7890    /// Whether Task Scheduler reports the task as running.
7891    ///
7892    /// **This is the one value in this module read from localised output.**
7893    /// `schtasks /Query /FO CSV` prints a `Status` column in the machine's
7894    /// display language, and Task Scheduler exposes no locale-independent
7895    /// equivalent short of COM. On a non-English Windows this therefore reports
7896    /// `false` for a task that is in fact running.
7897    ///
7898    /// That is stated rather than hidden because of what does *not* depend on
7899    /// it: Journey 5's liveness question is answered by the last successful
7900    /// GitHub contact, which is a timestamp this product writes itself and no
7901    /// locale can move. Nothing in the Definition of Done rests on this
7902    /// boolean.
7903    fn task_is_running(name: &str) -> bool {
7904        let Ok((true, stdout, _)) = run(
7905            "schtasks.exe",
7906            &[
7907                OsStr::new("/Query"),
7908                OsStr::new("/TN"),
7909                OsStr::new(name),
7910                OsStr::new("/FO"),
7911                OsStr::new("CSV"),
7912                OsStr::new("/NH"),
7913            ],
7914        ) else {
7915            return false;
7916        };
7917        stdout
7918            .lines()
7919            .filter_map(|line| line.rsplit(',').next())
7920            .any(|status| {
7921                status
7922                    .trim()
7923                    .trim_matches('"')
7924                    .eq_ignore_ascii_case("running")
7925            })
7926    }
7927
7928    /// Parses the `PT<n>M` shape this module writes, and the `PT<n>S` shape
7929    /// Task Scheduler would accept if it took seconds. Anything richer -- days,
7930    /// hours, a combination -- is `None` rather than a guess.
7931    fn parse_iso8601(value: &str) -> Option<Duration> {
7932        let rest = value.strip_prefix("PT")?;
7933        if let Some(minutes) = rest.strip_suffix('M') {
7934            return minutes
7935                .parse::<u64>()
7936                .ok()
7937                .map(|minutes| Duration::from_secs(minutes * 60));
7938        }
7939        rest.strip_suffix('S')?
7940            .parse::<u64>()
7941            .ok()
7942            .map(Duration::from_secs)
7943    }
7944}
7945
7946// ---------------------------------------------------------------------------
7947// Unix: shared plumbing
7948// ---------------------------------------------------------------------------
7949
7950/// Writes a definition file, creating its directory, and says when the refusal
7951/// was a permissions one.
7952///
7953/// Both Unix backends write a file into a directory only `root` can write —
7954/// `/Library/LaunchDaemons` and `/etc/systemd/system` — and an operator who ran
7955/// `service install` without `sudo` deserves to be told that rather than handed
7956/// an `EACCES`.
7957#[cfg(unix)]
7958fn write_definition(
7959    operation: &'static str,
7960    name: &str,
7961    path: &Path,
7962    text: &str,
7963    remedy: &'static str,
7964) -> Result<(), ServiceError> {
7965    if let Some(parent) = path.parent()
7966        && let Err(error) = std::fs::create_dir_all(parent)
7967        && error.kind() != std::io::ErrorKind::AlreadyExists
7968    {
7969        return Err(definition_error(operation, name, error, remedy, parent));
7970    }
7971    std::fs::write(path, text)
7972        .map_err(|error| definition_error(operation, name, error, remedy, path))
7973}
7974
7975/// Replaces an installed definition with [`DefinitionDrift::rendered`].
7976///
7977/// Through a temporary file in the same directory and a rename, so a failure
7978/// at any point leaves the old definition in place rather than a truncated
7979/// one. The mode is set to `0644` before the rename, as `service install`
7980/// leaves it: launchd refuses a plist that others can write.
7981#[cfg(all(unix, any(target_os = "macos", test)))]
7982fn replace_definition(name: &str, drift: &DefinitionDrift) -> Result<(), ServiceError> {
7983    use std::io::Write as _;
7984
7985    let path = drift.path.as_path();
7986    let directory = path.parent().unwrap_or_else(|| Path::new("/"));
7987    let failed = |error: std::io::Error, at: &Path| {
7988        definition_error("rewrite", name, error, SUDO_REMEDY, at)
7989    };
7990    let mut staged =
7991        tempfile::NamedTempFile::new_in(directory).map_err(|error| failed(error, directory))?;
7992    staged
7993        .write_all(drift.rendered.as_bytes())
7994        .and_then(|()| staged.as_file().sync_all())
7995        .map_err(|error| failed(error, staged.path()))?;
7996    std::fs::set_permissions(
7997        staged.path(),
7998        <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o644),
7999    )
8000    .map_err(|error| failed(error, path))?;
8001    staged
8002        .persist(path)
8003        .map(drop)
8004        .map_err(|error| failed(error.error, path))
8005}
8006
8007#[cfg(unix)]
8008fn definition_error(
8009    operation: &'static str,
8010    name: &str,
8011    error: std::io::Error,
8012    remedy: &'static str,
8013    path: &Path,
8014) -> ServiceError {
8015    let detail = format!("{}: {error}", path.display());
8016    if error.kind() == std::io::ErrorKind::PermissionDenied {
8017        ServiceError::NeedsElevation {
8018            operation,
8019            name: name.to_string(),
8020            detail,
8021            remedy,
8022        }
8023    } else {
8024        ServiceError::Control {
8025            operation,
8026            name: name.to_string(),
8027            manager: "the local service manager",
8028            detail,
8029        }
8030    }
8031}
8032
8033/// `sudo` is the remedy on both Unixes, and the message says which command.
8034#[cfg(unix)]
8035const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
8036     `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
8037     agent not running until you sign in.";
8038
8039// ---------------------------------------------------------------------------
8040// macOS
8041// ---------------------------------------------------------------------------
8042
8043#[cfg(target_os = "macos")]
8044mod sys {
8045    //! One manager, two domains. `--start-at boot` is a LaunchDaemon in
8046    //! `/Library/LaunchDaemons`, loaded into launchd's `system` domain;
8047    //! `--start-at login` is a LaunchAgent in the operator's own
8048    //! `~/Library/LaunchAgents`, loaded into `gui/<uid>`.
8049    //!
8050    //! `launchctl print` is the only inspection command here whose output is
8051    //! parsed, and it is English-only on every macOS release — launchd has no
8052    //! localisation for it. The registration's *existence* is decided by the
8053    //! plist file rather than by that output, so a launchd that changed its
8054    //! wording would cost this module the running/not-running line and nothing
8055    //! else.
8056
8057    use std::ffi::OsStr;
8058    use std::path::PathBuf;
8059    use std::time::Duration;
8060
8061    use runner_manager_domain::model::StartMode;
8062
8063    use super::{
8064        DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
8065        SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
8066        enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
8067        write_definition, xml_value,
8068    };
8069
8070    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
8071        Ok(Box::new(LaunchdControl { mode }))
8072    }
8073
8074    #[derive(Debug)]
8075    struct LaunchdControl {
8076        mode: StartMode,
8077    }
8078
8079    impl LaunchdControl {
8080        /// launchd's domain target for this start mode.
8081        fn domain(&self) -> String {
8082            match self.mode {
8083                StartMode::Boot => "system".to_string(),
8084                // SAFETY: `getuid` reads the calling process's real user id and
8085                // cannot fail; it takes no arguments and touches no memory.
8086                StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
8087            }
8088        }
8089
8090        fn service_target(&self, identity: &ServiceIdentity) -> String {
8091            format!("{}/{}", self.domain(), identity.launchd_label())
8092        }
8093
8094        /// Where this domain's plist lives, when the domain has a home to put
8095        /// it in.
8096        fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
8097            let file = format!("{}.plist", identity.launchd_label());
8098            match self.mode {
8099                StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
8100                StartMode::Login => {
8101                    home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
8102                }
8103            }
8104        }
8105
8106        fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
8107            ServiceError::Control {
8108                operation,
8109                name: name.to_string(),
8110                manager: "launchd",
8111                detail,
8112            }
8113        }
8114
8115        fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
8116            match run("launchctl", arguments) {
8117                Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
8118                Err(error) => (false, error.to_string()),
8119            }
8120        }
8121    }
8122
8123    impl ServiceControl for LaunchdControl {
8124        fn manager(&self) -> DefinitionKind {
8125            DefinitionKind::LaunchdPlist
8126        }
8127
8128        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8129            let name = plan.identity().name().to_string();
8130            let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
8131            let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
8132                return Err(self.failed(
8133                    "install",
8134                    &name,
8135                    "this account has no home directory, so there is nowhere to put a \
8136                     LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
8137                     /Library/LaunchDaemons."
8138                        .to_string(),
8139                ));
8140            };
8141            write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8142            let target = self.domain();
8143            let (ok, message) = self.launchctl(&[
8144                OsStr::new("bootstrap"),
8145                OsStr::new(&target),
8146                path.as_os_str(),
8147            ]);
8148            if !ok {
8149                // Leave nothing behind: a plist launchd refused is a file that
8150                // would make the next `service status` claim a registration
8151                // that does not exist.
8152                let _ = std::fs::remove_file(&path);
8153                if message.to_ascii_lowercase().contains("permission denied") {
8154                    return Err(ServiceError::NeedsElevation {
8155                        operation: "install",
8156                        name,
8157                        detail: message,
8158                        remedy: SUDO_REMEDY,
8159                    });
8160                }
8161                return Err(self.failed("install", &name, message));
8162            }
8163            // A previously disabled label stays disabled through a bootstrap,
8164            // which is a service that is installed and will never start.
8165            let service_target = self.service_target(plan.identity());
8166            enable_launchd_registration(
8167                |arguments| self.launchctl(arguments),
8168                &target,
8169                &service_target,
8170                &path,
8171                &name,
8172                SUDO_REMEDY,
8173            )?;
8174            Ok(definition)
8175        }
8176
8177        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8178            let Some(path) = self.plist_path(identity) else {
8179                return Ok(false);
8180            };
8181            if !path.exists() {
8182                return Ok(false);
8183            }
8184            let target = self.service_target(identity);
8185            let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
8186            if !ok
8187                && !message.to_ascii_lowercase().contains("no such process")
8188                && !message.contains("113")
8189            {
8190                if message.to_ascii_lowercase().contains("permission denied") {
8191                    return Err(ServiceError::NeedsElevation {
8192                        operation: "uninstall",
8193                        name: identity.name().to_string(),
8194                        detail: message,
8195                        remedy: SUDO_REMEDY,
8196                    });
8197                }
8198                return Err(self.failed("uninstall", identity.name(), message));
8199            }
8200            // Only the definition. `05-infrastructure.md` item 5: nothing else
8201            // on this host is touched here or anywhere below it.
8202            std::fs::remove_file(&path).map_err(|error| {
8203                super::definition_error(
8204                    "uninstall",
8205                    identity.name(),
8206                    error,
8207                    SUDO_REMEDY,
8208                    path.as_path(),
8209                )
8210            })?;
8211            Ok(true)
8212        }
8213
8214        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8215            let Some(path) = self.plist_path(identity) else {
8216                return Ok(None);
8217            };
8218            let Ok(document) = std::fs::read_to_string(&path) else {
8219                return Ok(None);
8220            };
8221            let target = self.service_target(identity);
8222            let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
8223            Ok(Some(Registration {
8224                manager: DefinitionKind::LaunchdPlist,
8225                start_mode: self.mode,
8226                command_line: program_arguments(&document),
8227                // A LaunchDaemon without `UserName` runs as root; a LaunchAgent
8228                // runs as whoever signs in, which names no one in particular.
8229                account: plist_string_value(&document, "UserName").or_else(|| {
8230                    (self.mode == StartMode::Boot)
8231                        .then(|| super::ServiceAccount::Root.as_str().to_string())
8232                }),
8233                running: loaded && printed.contains("state = running"),
8234                starts_automatically: document.contains("<key>RunAtLoad</key>")
8235                    && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
8236                restart_delay: xml_value(
8237                    super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
8238                    "integer",
8239                )
8240                .and_then(|value| value.parse::<u64>().ok())
8241                .map(Duration::from_secs),
8242            }))
8243        }
8244
8245        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8246            let target = self.service_target(identity);
8247            let (ok, message) = self.launchctl(&[
8248                OsStr::new("kickstart"),
8249                OsStr::new("-k"),
8250                OsStr::new(&target),
8251            ]);
8252            if ok {
8253                Ok(())
8254            } else {
8255                Err(self.failed("start", identity.name(), message))
8256            }
8257        }
8258
8259        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8260            let running = self
8261                .query(identity)?
8262                .is_some_and(|registration| registration.running);
8263            if !running {
8264                return Ok(false);
8265            }
8266            let target = self.service_target(identity);
8267            let (ok, message) = self.launchctl(&[
8268                OsStr::new("kill"),
8269                OsStr::new("SIGTERM"),
8270                OsStr::new(&target),
8271            ]);
8272            if ok {
8273                Ok(true)
8274            } else {
8275                Err(self.failed("stop", identity.name(), message))
8276            }
8277        }
8278    }
8279
8280    /// Boots the job out and bootstraps it again from `plist`, from a shell
8281    /// that outlives this process.
8282    ///
8283    /// `setsid` puts the shell in a session and process group of its own. On a
8284    /// bootout launchd sends SIGTERM to the job's whole process group, the
8285    /// process that asked for the bootout included, and spares a child in
8286    /// another session (measured on macOS 27). Without `setsid` the helper
8287    /// survived that only by luck: one run in four left the job unloaded. The
8288    /// work runs in a background subshell, so the outer shell exits at once and
8289    /// is reaped here rather than left behind. `bootout` blocks until the daemon
8290    /// has exited; the bootstrap is retried for a minute because launchd
8291    /// refuses it while the old job is still being torn down. When the job is
8292    /// still loaded after a failed bootout, every bootstrap fails and the
8293    /// running daemon carries on as it was.
8294    pub(super) fn reload_detached(
8295        identity: &ServiceIdentity,
8296        mode: StartMode,
8297        plist: &std::path::Path,
8298    ) -> Result<(), ServiceError> {
8299        use std::os::unix::process::CommandExt;
8300
8301        const SCRIPT: &str = r#"(
8302  sleep 1
8303  /bin/launchctl bootout "$1"
8304  tries=0
8305  until /bin/launchctl bootstrap "$2" "$3"; do
8306    tries=$((tries + 1))
8307    if [ "$tries" -ge 60 ]; then
8308      echo "runner-manager: launchd did not load $3 again; run: $4" >&2
8309      exit 1
8310    fi
8311    sleep 1
8312  done
8313) </dev/null &"#;
8314
8315        let control = LaunchdControl { mode };
8316        // Once the job is booted out nothing holds the single-instance lock,
8317        // so a reinstall in the same start mode is the remedy.
8318        let reinstall = match mode {
8319            StartMode::Boot => "sudo runner-manager service install --start-at boot",
8320            StartMode::Login => "runner-manager service install --start-at login",
8321        };
8322        let mut command = std::process::Command::new("/bin/sh");
8323        command
8324            .args(["-c", SCRIPT, "runner-manager-reload"])
8325            .arg(control.service_target(identity))
8326            .arg(control.domain())
8327            .arg(plist)
8328            .arg(reinstall)
8329            .stdin(std::process::Stdio::null());
8330        // SAFETY: one async-signal-safe system call between `fork` and `exec`,
8331        // touching no memory, allocator or lock.
8332        unsafe {
8333            command.pre_exec(|| {
8334                if libc::setsid() == -1 {
8335                    return Err(std::io::Error::last_os_error());
8336                }
8337                Ok(())
8338            });
8339        }
8340        let status = command.status().map_err(|error| {
8341            control.failed("reload", identity.name(), format!("/bin/sh: {error}"))
8342        })?;
8343        if status.success() {
8344            Ok(())
8345        } else {
8346            Err(control.failed(
8347                "reload",
8348                identity.name(),
8349                format!("the reload helper exited with {status}"),
8350            ))
8351        }
8352    }
8353
8354    /// Rebuilds the command line from a plist's `ProgramArguments` array.
8355    fn program_arguments(document: &str) -> String {
8356        let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
8357            return String::new();
8358        };
8359        let Some(end) = rest.find("</array>") else {
8360            return String::new();
8361        };
8362        let mut out = Vec::new();
8363        let mut cursor = &rest[..end];
8364        while let Some(open) = cursor.find("<string>") {
8365            let after = &cursor[open + "<string>".len()..];
8366            let Some(close) = after.find("</string>") else {
8367                break;
8368            };
8369            out.push(quote_argument(&super::xml_unescape(&after[..close])));
8370            cursor = &after[close..];
8371        }
8372        out.join(" ")
8373    }
8374}
8375
8376// ---------------------------------------------------------------------------
8377// Linux and the other Unixes
8378// ---------------------------------------------------------------------------
8379
8380#[cfg(all(unix, not(target_os = "macos")))]
8381mod sys {
8382    //! One manager, two domains. `--start-at boot` is a system unit in
8383    //! `/etc/systemd/system`, wanted by `multi-user.target`; `--start-at login`
8384    //! is a user unit in `~/.config/systemd/user`, wanted by `default.target`.
8385    //!
8386    //! Every inspection here goes through `systemctl cat` and `systemctl show`,
8387    //! whose output is machine-readable `key=value` text rather than a
8388    //! sentence, so nothing in this module depends on the machine's display
8389    //! language.
8390
8391    use std::ffi::OsStr;
8392    use std::path::PathBuf;
8393
8394    use runner_manager_domain::model::StartMode;
8395
8396    use super::{
8397        DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
8398        SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
8399        home_directory, ini_directives, run, systemd_exec_command_line, systemd_timespan,
8400        write_definition,
8401    };
8402
8403    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
8404        Ok(Box::new(SystemdControl { mode }))
8405    }
8406
8407    #[derive(Debug)]
8408    struct SystemdControl {
8409        mode: StartMode,
8410    }
8411
8412    impl SystemdControl {
8413        fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
8414            let file = identity.systemd_unit();
8415            match self.mode {
8416                StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
8417                StartMode::Login => {
8418                    home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
8419                }
8420            }
8421        }
8422
8423        /// `systemctl`, with `--user` for the login domain.
8424        fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
8425            let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
8426            if self.mode == StartMode::Login {
8427                all.push(OsStr::new("--user"));
8428            }
8429            all.extend(arguments.iter().map(OsStr::new));
8430            match run("systemctl", &all) {
8431                Ok((ok, stdout, stderr)) => (
8432                    ok,
8433                    if stdout.trim().is_empty() {
8434                        stderr
8435                    } else {
8436                        stdout
8437                    },
8438                ),
8439                Err(error) => (false, error.to_string()),
8440            }
8441        }
8442
8443        fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
8444            ServiceError::Control {
8445                operation,
8446                name: name.to_string(),
8447                manager: "systemd",
8448                detail,
8449            }
8450        }
8451    }
8452
8453    impl ServiceControl for SystemdControl {
8454        fn manager(&self) -> DefinitionKind {
8455            DefinitionKind::SystemdUnit
8456        }
8457
8458        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8459            use std::os::unix::fs::MetadataExt as _;
8460
8461            let name = plan.identity().name().to_string();
8462            if self.mode == StartMode::Boot {
8463                // The credential store's directory as well as the four: the
8464                // service rewrites the credential there when GitHub rotates it.
8465                let refusals = super::permitted_paths(DefinitionKind::SystemdUnit, plan)
8466                    .iter()
8467                    .filter_map(|path| {
8468                        let path = std::path::Path::new(path);
8469                        let owner = std::fs::metadata(path).ok().map(|metadata| metadata.uid());
8470                        super::boot_directory_refusal(path, owner)
8471                    })
8472                    .collect::<Vec<_>>();
8473                if !refusals.is_empty() {
8474                    return Err(self.failed(
8475                        "install",
8476                        &name,
8477                        format!(
8478                            "{}. Run `{}` without --data-dir, which uses {} and {}; a \
8479                             directory there that root does not own is handed back with \
8480                             `sudo chown -R root:root <directory>`",
8481                            refusals.join("; "),
8482                            super::sudo_command("service install --start-at boot"),
8483                            crate::paths::SYSTEM_DATA_ROOT,
8484                            crate::paths::SYSTEM_LOGS_DIR,
8485                        ),
8486                    ));
8487                }
8488            }
8489            let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
8490            let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
8491                return Err(self.failed(
8492                    "install",
8493                    &name,
8494                    "this account has no home directory, so there is nowhere to put a systemd \
8495                     user unit. Use --start-at boot, which installs a system unit under \
8496                     /etc/systemd/system."
8497                        .to_string(),
8498                ));
8499            };
8500            write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8501            let unit = plan.identity().systemd_unit();
8502            let (reloaded, message) = self.systemctl(&["daemon-reload"]);
8503            if !reloaded {
8504                let _ = std::fs::remove_file(&path);
8505                return Err(self.failed("install", &name, message));
8506            }
8507            let (enabled, message) = self.systemctl(&["enable", &unit]);
8508            if !enabled {
8509                let _ = std::fs::remove_file(&path);
8510                let _ = self.systemctl(&["daemon-reload"]);
8511                return Err(self.failed("install", &name, message));
8512            }
8513            Ok(definition)
8514        }
8515
8516        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8517            let Some(path) = self.unit_path(identity) else {
8518                return Ok(false);
8519            };
8520            if !path.exists() {
8521                return Ok(false);
8522            }
8523            let unit = identity.systemd_unit();
8524            // `--now` stops it as well as disabling it. A failure here is not
8525            // fatal: the unit file is about to go, and refusing to remove it
8526            // because `systemctl` disliked something would leave the host with
8527            // a unit nothing manages.
8528            let _ = self.systemctl(&["disable", "--now", &unit]);
8529            std::fs::remove_file(&path).map_err(|error| {
8530                super::definition_error(
8531                    "uninstall",
8532                    identity.name(),
8533                    error,
8534                    SUDO_REMEDY,
8535                    path.as_path(),
8536                )
8537            })?;
8538            let _ = self.systemctl(&["daemon-reload"]);
8539            Ok(true)
8540        }
8541
8542        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8543            let Some(path) = self.unit_path(identity) else {
8544                return Ok(None);
8545            };
8546            if !path.exists() {
8547                return Ok(None);
8548            }
8549            let unit = identity.systemd_unit();
8550            // `cat` includes administrator drop-ins in systemd's order. Reading
8551            // only our base file hides User/ExecStart/RestartSec overrides, so
8552            // the file is read only when `systemctl` cannot answer.
8553            let effective = match self.systemctl(&["cat", &unit]) {
8554                (true, text) => text,
8555                _ => match std::fs::read_to_string(&path) {
8556                    Ok(document) => document,
8557                    Err(_) => return Ok(None),
8558                },
8559            };
8560            let directives = ini_directives(&effective, "Service");
8561            // One `show` answers what `is-active` and `is-enabled` each did.
8562            let (_, shown) = self.systemctl(&[
8563                "show",
8564                "--property=ActiveState",
8565                "--property=UnitFileState",
8566                &unit,
8567            ]);
8568            let state = |key: &str| {
8569                shown
8570                    .lines()
8571                    .find_map(|line| line.strip_prefix(key)?.strip_prefix('='))
8572                    .map(str::trim)
8573            };
8574            Ok(Some(Registration {
8575                manager: DefinitionKind::SystemdUnit,
8576                start_mode: self.mode,
8577                command_line: directives
8578                    .get("ExecStart")
8579                    .map(|value| systemd_exec_command_line(value))
8580                    .unwrap_or_default(),
8581                // A system unit without `User=` runs as root; a user unit runs
8582                // as whoever owns the session, which names no one in particular.
8583                account: directives
8584                    .get("User")
8585                    .filter(|user| !user.is_empty())
8586                    .cloned()
8587                    .or_else(|| {
8588                        (self.mode == StartMode::Boot)
8589                            .then(|| super::ServiceAccount::Root.as_str().to_string())
8590                    }),
8591                running: state("ActiveState") == Some("active"),
8592                starts_automatically: state("UnitFileState") == Some("enabled"),
8593                restart_delay: directives
8594                    .get("RestartSec")
8595                    .and_then(|value| systemd_timespan(value)),
8596            }))
8597        }
8598
8599        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8600            let unit = identity.systemd_unit();
8601            let (ok, message) = self.systemctl(&["start", &unit]);
8602            if ok {
8603                Ok(())
8604            } else {
8605                Err(self.failed("start", identity.name(), message))
8606            }
8607        }
8608
8609        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8610            let running = self
8611                .query(identity)?
8612                .is_some_and(|registration| registration.running);
8613            if !running {
8614                return Ok(false);
8615            }
8616            let unit = identity.systemd_unit();
8617            let (ok, message) = self.systemctl(&["stop", &unit]);
8618            if ok {
8619                Ok(true)
8620            } else {
8621                Err(self.failed("stop", identity.name(), message))
8622            }
8623        }
8624    }
8625}
8626
8627/// What the macOS tests that load real launch agents share.
8628#[cfg(all(test, target_os = "macos"))]
8629pub(crate) mod launchd_fixture {
8630    use std::path::{Path, PathBuf};
8631
8632    /// Opts a macOS host into the tests that load launch agents into the
8633    /// caller's GUI session. CI's macOS leg sets it, and
8634    /// `privileged_tests_are_wired_into_ci.rs` asserts that it still does.
8635    const ACCEPTANCE: &str = "RUNNER_MANAGER_LAUNCHD_ACCEPTANCE";
8636
8637    /// Whether this run may load launch agents. Says so when it may not, since
8638    /// the test then passes having measured nothing.
8639    pub(crate) fn enabled() -> bool {
8640        let enabled = std::env::var_os(ACCEPTANCE).is_some_and(|value| value == "1");
8641        if !enabled {
8642            eprintln!("skipped: set {ACCEPTANCE}=1 to load test launch agents");
8643        }
8644        enabled
8645    }
8646
8647    /// A copy of the running test binary in `directory`, for a launchd job to
8648    /// run as its program.
8649    ///
8650    /// A copy, because a launchd job whose program lives on an external volume
8651    /// waits on a privacy approval nobody gives before `main` runs (measured
8652    /// with a target directory under `/Volumes`), and the temporary directory
8653    /// is on the boot volume.
8654    pub(crate) fn job_binary(directory: &Path) -> PathBuf {
8655        let binary = directory.join("launch-agent-job");
8656        std::fs::copy(std::env::current_exe().expect("test binary"), &binary)
8657            .expect("test binary copied");
8658        binary
8659    }
8660
8661    /// A launch agent loaded into the caller's GUI domain, booted out on drop.
8662    pub(crate) struct LoadedAgent(String);
8663
8664    impl LoadedAgent {
8665        /// Bootstraps `plist`, whose `Label` is `label`, and fails the test when
8666        /// launchd refuses it.
8667        pub(crate) fn bootstrap(label: &str, plist: &Path) -> Self {
8668            // SAFETY: `getuid` reads the calling process's real user id and
8669            // cannot fail.
8670            let domain = format!("gui/{}", unsafe { libc::getuid() });
8671            // Armed before the bootstrap, so a half-loaded job is removed too.
8672            let loaded = Self(format!("{domain}/{label}"));
8673            let bootstrap = std::process::Command::new("launchctl")
8674                .arg("bootstrap")
8675                .arg(&domain)
8676                .arg(plist)
8677                .output()
8678                .expect("launchctl runs");
8679            assert!(
8680                bootstrap.status.success(),
8681                "launchctl bootstrap {domain} refused {label}, so nothing was measured: {}",
8682                String::from_utf8_lossy(&bootstrap.stderr)
8683            );
8684            loaded
8685        }
8686
8687        /// `gui/<uid>/<label>`.
8688        pub(crate) fn target(&self) -> &str {
8689            &self.0
8690        }
8691    }
8692
8693    impl Drop for LoadedAgent {
8694        fn drop(&mut self) {
8695            let _ = std::process::Command::new("launchctl")
8696                .args(["bootout", &self.0])
8697                .output();
8698        }
8699    }
8700}
8701
8702#[cfg(test)]
8703mod tests {
8704    use super::*;
8705
8706    use std::collections::BTreeMap;
8707
8708    #[test]
8709    fn task_scheduler_omitting_default_enabled_still_means_automatic() {
8710        assert!(windows_login_task_starts_automatically(
8711            "<Task><Triggers><LogonTrigger></LogonTrigger></Triggers></Task>"
8712        ));
8713        assert!(windows_login_task_starts_automatically(
8714            "<Task><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Task>"
8715        ));
8716        assert!(!windows_login_task_starts_automatically(
8717            "<Task><LogonTrigger><Enabled>false</Enabled></LogonTrigger></Task>"
8718        ));
8719        assert!(!windows_login_task_starts_automatically(
8720            "<Task><BootTrigger></BootTrigger></Task>"
8721        ));
8722    }
8723
8724    // -----------------------------------------------------------------------
8725    // Fixtures
8726    // -----------------------------------------------------------------------
8727
8728    /// A plan against paths that exist on no platform, so that every renderer
8729    /// can be asserted on every leg of the CI matrix.
8730    fn linux_plan(mode: StartMode) -> InstallPlan {
8731        InstallPlan::unchecked(
8732            ServiceIdentity::product(),
8733            mode,
8734            "/opt/runner-manager/bin/runner-manager",
8735            ServiceDirectories {
8736                config: PathBuf::from("/var/lib/runner-manager/config"),
8737                state: PathBuf::from("/var/lib/runner-manager/state"),
8738                runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
8739                logs: PathBuf::from("/var/log/runner-manager"),
8740            },
8741        )
8742        .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
8743    }
8744
8745    fn windows_plan(mode: StartMode) -> InstallPlan {
8746        InstallPlan::unchecked(
8747            ServiceIdentity::product(),
8748            mode,
8749            "C:\\Program Files\\runner-manager\\runner-manager.exe",
8750            ServiceDirectories {
8751                config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
8752                state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
8753                runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
8754                logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
8755            },
8756        )
8757    }
8758
8759    /// Replaces one fragment of a rendered definition, and **fails the test if
8760    /// the fragment was not there**.
8761    ///
8762    /// Without the assertion a renderer change would quietly turn every
8763    /// "widened definition is rejected" test below into a test that reviews the
8764    /// unmodified definition and passes for the wrong reason. This is the
8765    /// single guard that keeps that class of vacuity out of this file.
8766    fn edited(text: &str, from: &str, to: &str) -> String {
8767        assert!(
8768            text.contains(from),
8769            "the rendered definition does not contain `{from}`, so this test would assert \
8770             nothing about a widened one"
8771        );
8772        text.replace(from, to)
8773    }
8774
8775    /// Every file under `root`, keyed by its path relative to `root`.
8776    fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
8777        fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
8778            let Ok(entries) = std::fs::read_dir(directory) else {
8779                return;
8780            };
8781            for entry in entries.flatten() {
8782                let path = entry.path();
8783                if path.is_dir() {
8784                    walk(&path, out);
8785                } else if let Ok(bytes) = std::fs::read(&path) {
8786                    out.insert(path, bytes);
8787                }
8788            }
8789        }
8790        let mut out = BTreeMap::new();
8791        for root in roots {
8792            walk(root, &mut out);
8793        }
8794        out
8795    }
8796
8797    struct Host {
8798        _root: tempfile::TempDir,
8799        paths: AppPaths,
8800        binary: PathBuf,
8801        /// A runner root inside this host's own temporary tree.
8802        ///
8803        /// Without it every `install` in this module would create and
8804        /// re-permission the **real** `%SystemDrive%\rman` on the machine
8805        /// running the tests, and would fail outright on any machine where that
8806        /// directory already exists with the access control `C:\` gives it.
8807        /// A sibling of the four application-data directories rather than a
8808        /// child of one, so `b1`'s overlap check has nothing to object to.
8809        runner_root: LocalAbsolutePath,
8810        controls: RecordingControls,
8811    }
8812
8813    impl Host {
8814        fn new() -> Self {
8815            let root = tempfile::tempdir().expect("a temporary directory");
8816            let paths = AppPaths::rooted_at(root.path());
8817            paths.create_all().expect("the four directories");
8818            let binary = root.path().join(if cfg!(windows) {
8819                "runner-manager.exe"
8820            } else {
8821                "runner-manager"
8822            });
8823            std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
8824            let runner_root = LocalAbsolutePath::new(
8825                root.path()
8826                    .join("runner-root")
8827                    .to_str()
8828                    .expect("a unicode temporary path"),
8829            )
8830            .expect("a local absolute path");
8831            Self {
8832                _root: root,
8833                paths,
8834                binary,
8835                runner_root,
8836                controls: RecordingControls::new(),
8837            }
8838        }
8839
8840        fn operations(&self) -> ServiceOperations {
8841            ServiceOperations::with_controls(
8842                self.paths.clone(),
8843                ServiceIdentity::product(),
8844                std::sync::Arc::new(self.controls.clone()),
8845            )
8846            .with_runner_root(self.runner_root.clone())
8847        }
8848
8849        fn request(&self, mode: StartMode) -> InstallRequest {
8850            InstallRequest::new(mode).for_binary(&self.binary)
8851        }
8852    }
8853
8854    #[cfg(windows)]
8855    #[test]
8856    fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
8857        let probes = std::cell::Cell::new(0);
8858        let absent =
8859            super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
8860                let next = probes.get() + 1;
8861                probes.set(next);
8862                Ok(next == 3)
8863            })
8864            .expect("the simulated SCM probe succeeds");
8865
8866        assert!(absent);
8867        assert_eq!(
8868            probes.get(),
8869            3,
8870            "uninstall must recheck after transient presence instead of treating it as a leak"
8871        );
8872    }
8873
8874    #[cfg(windows)]
8875    #[test]
8876    fn windows_recognises_an_already_pending_service_deletion() {
8877        let error = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(1072));
8878
8879        assert!(super::sys::is_marked_for_delete(&error));
8880    }
8881
8882    #[test]
8883    fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
8884        let root = tempfile::tempdir().expect("a temporary directory");
8885        let plist = root.path().join("fixture.plist");
8886        std::fs::write(&plist, b"fixture").expect("a plist fixture");
8887        let calls = std::cell::RefCell::new(Vec::new());
8888
8889        let error = enable_launchd_registration(
8890            |arguments| {
8891                let call = arguments
8892                    .iter()
8893                    .map(|argument| argument.to_string_lossy().into_owned())
8894                    .collect::<Vec<_>>();
8895                let operation = call[0].clone();
8896                calls.borrow_mut().push(call);
8897                if operation == "enable" {
8898                    (false, "label remains disabled".to_string())
8899                } else {
8900                    (true, String::new())
8901                }
8902            },
8903            "system",
8904            "system/com.openai.runner-manager-selftest",
8905            &plist,
8906            "runner-manager-selftest",
8907            "rerun with administrative rights",
8908        )
8909        .expect_err("enable failure must fail the install");
8910
8911        assert!(
8912            matches!(
8913                error,
8914                ServiceError::Control {
8915                    operation: "enable",
8916                    ..
8917                }
8918            ),
8919            "{error}"
8920        );
8921        assert_eq!(calls.borrow().len(), 2);
8922        assert_eq!(calls.borrow()[0][0], "enable");
8923        assert_eq!(calls.borrow()[1][0], "bootout");
8924        assert!(!plist.exists(), "rollback must remove the plist");
8925    }
8926
8927    // -----------------------------------------------------------------------
8928    // Quoting, and reading a command line back
8929    // -----------------------------------------------------------------------
8930
8931    #[test]
8932    fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
8933        let plan = windows_plan(StartMode::Boot);
8934        let command_line = plan.command_line();
8935        assert!(
8936            command_line.starts_with('"'),
8937            "a path with a space must be quoted, got {command_line}"
8938        );
8939        assert_eq!(
8940            executable_from_command_line(&command_line).as_deref(),
8941            Some(plan.binary())
8942        );
8943    }
8944
8945    #[test]
8946    fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
8947        let plan = linux_plan(StartMode::Boot);
8948        let command_line = plan.command_line();
8949        assert!(!command_line.starts_with('"'), "got {command_line}");
8950        assert_eq!(
8951            executable_from_command_line(&command_line).as_deref(),
8952            Some(plan.binary())
8953        );
8954    }
8955
8956    #[test]
8957    fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
8958        // Not a path anybody has, but it is the input that separates a real
8959        // implementation of Windows' quoting rules from a `split_whitespace`.
8960        let awkward = r#"C:\odd "name"\rm.exe"#;
8961        let quoted = quote_argument(awkward);
8962        assert_eq!(
8963            executable_from_command_line(&format!("{quoted} daemon run"))
8964                .as_deref()
8965                .map(Path::to_string_lossy)
8966                .as_deref(),
8967            Some(awkward)
8968        );
8969    }
8970
8971    #[test]
8972    fn an_empty_command_line_has_no_executable() {
8973        assert_eq!(executable_from_command_line("   "), None);
8974        assert_eq!(executable_from_command_line(""), None);
8975    }
8976
8977    #[test]
8978    fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
8979        let awkward = r#"DOMAIN\R&D <team> "ops""#;
8980        assert_eq!(
8981            xml_escape(awkward),
8982            "DOMAIN\\R&amp;D &lt;team&gt; &quot;ops&quot;"
8983        );
8984        assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
8985    }
8986
8987    // -----------------------------------------------------------------------
8988    // The restart policy is bounded at both ends
8989    // -----------------------------------------------------------------------
8990
8991    #[test]
8992    fn a_restart_delay_under_the_floor_is_refused() {
8993        let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
8994            .expect_err("half a second is under the one-second floor");
8995        assert!(
8996            matches!(error, ServiceError::RestartDelay { .. }),
8997            "{error}"
8998        );
8999    }
9000
9001    #[test]
9002    fn a_restart_delay_over_the_ceiling_is_refused() {
9003        let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
9004            .expect_err("an hour is over the five-minute ceiling");
9005        assert!(
9006            matches!(error, ServiceError::RestartDelay { .. }),
9007            "{error}"
9008        );
9009    }
9010
9011    #[test]
9012    fn a_reset_window_no_longer_than_the_delay_is_refused() {
9013        let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
9014            .expect_err("a window equal to the delay can never elapse between restarts");
9015        assert!(
9016            matches!(error, ServiceError::RestartResetWindow { .. }),
9017            "{error}"
9018        );
9019    }
9020
9021    #[test]
9022    fn a_delay_inside_the_bound_is_accepted() {
9023        let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
9024            .expect("twenty seconds is inside the bound");
9025        assert_eq!(policy.delay(), Duration::from_secs(20));
9026        assert_eq!(policy.reset_after(), Duration::from_secs(300));
9027    }
9028
9029    // -----------------------------------------------------------------------
9030    // Identity
9031    // -----------------------------------------------------------------------
9032
9033    #[test]
9034    fn a_fixture_identity_can_never_be_the_product_identity() {
9035        let fixture = ServiceIdentity::fixture("abc123");
9036        assert!(fixture.is_fixture());
9037        assert!(!ServiceIdentity::product().is_fixture());
9038        assert_ne!(fixture.name(), ServiceIdentity::product().name());
9039        assert_ne!(
9040            fixture.launchd_label(),
9041            ServiceIdentity::product().launchd_label()
9042        );
9043        assert_ne!(
9044            fixture.systemd_unit(),
9045            ServiceIdentity::product().systemd_unit()
9046        );
9047    }
9048
9049    #[test]
9050    fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
9051        let fixture = ServiceIdentity::fixture("A b/c:\\d");
9052        assert!(
9053            fixture
9054                .name()
9055                .chars()
9056                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
9057            "got {}",
9058            fixture.name()
9059        );
9060    }
9061
9062    // -----------------------------------------------------------------------
9063    // The systemd unit
9064    // -----------------------------------------------------------------------
9065
9066    #[test]
9067    fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
9068        let unit = systemd_unit(&linux_plan(StartMode::Boot));
9069        assert!(unit.contains("KillMode=process\n"), "{unit}");
9070        assert!(unit.contains("Restart=on-failure\n"), "{unit}");
9071        assert!(unit.contains("RestartSec=15\n"), "{unit}");
9072        assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
9073        assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
9074        assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
9075    }
9076
9077    #[test]
9078    fn the_boot_unit_reads_the_live_store_instead_of_a_frozen_systemd_copy() {
9079        let unit = systemd_unit(&linux_plan(StartMode::Boot));
9080        assert!(
9081            !unit.contains("LoadCredential="),
9082            "a startup snapshot would shadow every rotated credential until restart:\n{unit}"
9083        );
9084    }
9085
9086    #[test]
9087    fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
9088        let unit = systemd_unit(&linux_plan(StartMode::Login));
9089        assert!(
9090            !unit.contains("LoadCredential="),
9091            "a user unit must not name a root-owned credential file, got:\n{unit}"
9092        );
9093        assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
9094    }
9095
9096    #[test]
9097    fn the_unit_can_atomically_replace_the_credential_and_write_only_required_directories() {
9098        let plan = linux_plan(StartMode::Boot);
9099        let unit = systemd_unit(&plan);
9100        let directives = ini_directives(&unit, "Service");
9101        let listed = split_quoted(
9102            directives
9103                .get("ReadWritePaths")
9104                .expect("the unit names its writable paths"),
9105        );
9106        assert_eq!(listed.len(), 5, "{listed:?}");
9107        for path in plan.directories().all() {
9108            assert!(
9109                listed.iter().any(|entry| entry == &path.to_string_lossy()),
9110                "{} is missing from {listed:?}",
9111                path.display()
9112            );
9113        }
9114        assert!(
9115            listed
9116                .iter()
9117                .any(|entry| entry == "/var/lib/runner-manager/secrets"),
9118            "atomic credential replacement needs its parent directory in {listed:?}"
9119        );
9120    }
9121
9122    #[test]
9123    fn the_unit_records_the_absolute_binary_path() {
9124        let plan = linux_plan(StartMode::Boot);
9125        let unit = systemd_unit(&plan);
9126        assert!(
9127            unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
9128            "{unit}"
9129        );
9130    }
9131
9132    // -----------------------------------------------------------------------
9133    // The launchd property list
9134    // -----------------------------------------------------------------------
9135
9136    #[test]
9137    fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
9138        let plist = launchd_plist(&linux_plan(StartMode::Boot));
9139        // A bare `KeepAlive` would also restart a job the operator stopped
9140        // deliberately, which turns `service stop` into a fight with launchd.
9141        assert!(
9142            plist.contains(
9143                "<key>KeepAlive</key>\n  <dict>\n    <key>SuccessfulExit</key>\n    <false/>\n"
9144            ),
9145            "{plist}"
9146        );
9147        assert!(
9148            plist.contains("<key>ThrottleInterval</key>\n  <integer>15</integer>"),
9149            "{plist}"
9150        );
9151    }
9152
9153    #[test]
9154    fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
9155        let daemon = launchd_plist(&linux_plan(StartMode::Boot));
9156        assert_eq!(
9157            plist_string_value(&daemon, "UserName").as_deref(),
9158            Some("root")
9159        );
9160        assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
9161
9162        let agent = launchd_plist(&linux_plan(StartMode::Login));
9163        assert_eq!(
9164            plist_string_value(&agent, "UserName"),
9165            None,
9166            "a LaunchAgent already runs as the operator:\n{agent}"
9167        );
9168    }
9169
9170    #[test]
9171    fn the_plist_runs_the_daemon_and_its_runners_as_an_interactive_job() {
9172        for mode in [StartMode::Boot, StartMode::Login] {
9173            let plan = linux_plan(mode);
9174            let plist = launchd_plist(&plan);
9175            assert_eq!(
9176                plist_string_value(&plist, "ProcessType").as_deref(),
9177                Some("Interactive"),
9178                "launchd applies ProcessType to every runner the daemon starts:\n{plist}"
9179            );
9180            let review = review_least_privilege(
9181                &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, plist),
9182                &plan,
9183            );
9184            assert!(
9185                review
9186                    .controls()
9187                    .iter()
9188                    .any(|control| control == "ProcessType=Interactive"),
9189                "{review}"
9190            );
9191            assert!(
9192                !review
9193                    .findings()
9194                    .iter()
9195                    .any(|finding| finding.subject == "ProcessType"),
9196                "{review}"
9197            );
9198        }
9199    }
9200
9201    /// The value 0.4.31 and earlier installed, and launchd's default, both
9202    /// throttle every runner. That is a shortfall the update repairs, not an
9203    /// excess: a scheduling class grants no authority.
9204    #[test]
9205    fn a_plist_that_throttles_its_runners_is_a_shortfall() {
9206        let plan = linux_plan(StartMode::Login);
9207        let rendered = launchd_plist(&plan);
9208        let interactive = "<key>ProcessType</key>\n  <string>Interactive</string>\n";
9209        for (label, throttled) in [
9210            (
9211                "Background",
9212                edited(
9213                    &rendered,
9214                    interactive,
9215                    "<key>ProcessType</key>\n  <string>Background</string>\n",
9216                ),
9217            ),
9218            ("absent", edited(&rendered, interactive, "")),
9219        ] {
9220            let review = review_least_privilege(
9221                &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, throttled),
9222                &plan,
9223            );
9224            assert!(review.is_least_privilege(), "{label}: {review}");
9225            assert!(
9226                review.findings().iter().any(|finding| {
9227                    finding.kind == FindingKind::Shortfall
9228                        && finding.subject == "ProcessType"
9229                        && finding.detail.contains(label)
9230                }),
9231                "{label}: {review}"
9232            );
9233        }
9234    }
9235
9236    #[test]
9237    fn a_plist_with_an_old_process_type_is_drift_and_an_operator_edit_is_not() {
9238        let host = Host::new();
9239        let identity = ServiceIdentity::product();
9240        let plan = InstallPlan::resolve(
9241            identity.clone(),
9242            &host.request(StartMode::Login),
9243            ServiceDirectories::of(&host.paths),
9244        )
9245        .expect("a resolvable plan");
9246        let installed = launchd_plist(&plan);
9247        let definition =
9248            ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, installed.clone());
9249        let mut record = InstallRecord::of(&plan, &definition, Utc::now());
9250        record.installed_by_version = "0.4.31".into();
9251        let path = PathBuf::from("/Users/op/Library/LaunchAgents/runner-manager.plist");
9252
9253        assert_eq!(
9254            launchd_definition_drift(&identity, &record, path.clone(), &installed),
9255            None
9256        );
9257        let edited_by_operator = edited(
9258            &installed,
9259            "</dict>\n</plist>\n",
9260            "  <key>Nice</key>\n  <integer>0</integer>\n</dict>\n</plist>\n",
9261        );
9262        assert_eq!(
9263            launchd_definition_drift(&identity, &record, path.clone(), &edited_by_operator),
9264            None,
9265            "the daemon repairs this drift itself, so it must not key on edits it did not cause"
9266        );
9267
9268        let written_by_0_4_31 = edited(
9269            &installed,
9270            "<string>Interactive</string>",
9271            "<string>Background</string>",
9272        );
9273        let drift = launchd_definition_drift(&identity, &record, path, &written_by_0_4_31)
9274            .expect("a Background plist is drift");
9275        assert_eq!(drift.manager, DefinitionKind::LaunchdPlist);
9276        assert_eq!(drift.start_mode, StartMode::Login);
9277        assert_eq!(
9278            drift.rendered, installed,
9279            "the repair must write exactly what `service install` writes"
9280        );
9281
9282        let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
9283            .with_definition_drift(Some(drift));
9284        let problem = status
9285            .problems()
9286            .iter()
9287            .find(|problem| problem.subject == "definition")
9288            .expect("the drift is reported");
9289        // The remedy is a restart: a running daemon holds the lock that refuses
9290        // `service install`.
9291        assert!(
9292            problem.detail.contains("0.4.31")
9293                && problem.detail.contains(
9294                    "launchctl kickstart -k gui/$(id -u)/io.github.IvanMurzak.runner-manager`"
9295                )
9296                && !problem.detail.contains("service install")
9297                && !problem.detail.contains("systemctl")
9298                && !problem.detail.contains("sudo"),
9299            "{}",
9300            problem.detail
9301        );
9302    }
9303
9304    #[cfg(unix)]
9305    #[test]
9306    fn a_replaced_definition_is_the_rendering_and_readable_by_launchd() {
9307        use std::os::unix::fs::PermissionsExt as _;
9308
9309        let root = tempfile::tempdir().expect("a temporary directory");
9310        let path = root
9311            .path()
9312            .join("io.github.IvanMurzak.runner-manager.plist");
9313        std::fs::write(&path, "<string>Background</string>").expect("old plist");
9314        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
9315        let drift = DefinitionDrift {
9316            path: path.clone(),
9317            installed_by_version: "0.4.31".into(),
9318            rendered: launchd_plist(&linux_plan(StartMode::Login)),
9319            start_mode: StartMode::Login,
9320            manager: DefinitionKind::LaunchdPlist,
9321        };
9322
9323        replace_definition("runner-manager", &drift).expect("replaced");
9324
9325        assert_eq!(std::fs::read_to_string(&path).unwrap(), drift.rendered);
9326        assert_eq!(
9327            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
9328            0o644
9329        );
9330        assert_eq!(
9331            std::fs::read_dir(root.path()).unwrap().count(),
9332            1,
9333            "no staging file is left beside the plist"
9334        );
9335    }
9336
9337    /// The job of [`a_job_reloads_itself_from_its_rewritten_plist`], run by
9338    /// launchd and by nothing else.
9339    ///
9340    /// It does what the daemon does when it starts: when its own plist (kept
9341    /// in its working directory) does not carry [`LAUNCHD_PROCESS_TYPE`], it
9342    /// rewrites the plist and asks for the reload from inside the job that the
9343    /// reload boots out. Either way it then stays up, to be booted out by the
9344    /// helper or, once reloaded, by the test.
9345    #[cfg(target_os = "macos")]
9346    #[test]
9347    #[ignore = "run only by launchd, as the job of the detached-reload test"]
9348    fn detached_reload_job() {
9349        let label = std::env::var("XPC_SERVICE_NAME").unwrap_or_default();
9350        let tag = label
9351            .split_once("-selftest-")
9352            .map(|(_, tag)| tag.to_string())
9353            .unwrap_or_else(|| panic!("run only by launchd for the reload test, not {label:?}"));
9354        let identity = ServiceIdentity::fixture(&tag);
9355        assert_eq!(identity.launchd_label(), label);
9356        let path = std::env::current_dir()
9357            .expect("working directory")
9358            .join(format!("{label}.plist"));
9359        let installed = std::fs::read_to_string(&path).expect("own plist");
9360        if plist_string_value(&installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE) {
9361            let drift = DefinitionDrift {
9362                path: path.clone(),
9363                installed_by_version: "0.4.31".into(),
9364                rendered: installed.replace(
9365                    "<string>Background</string>",
9366                    &format!("<string>{LAUNCHD_PROCESS_TYPE}</string>"),
9367                ),
9368                start_mode: StartMode::Login,
9369                manager: DefinitionKind::LaunchdPlist,
9370            };
9371            replace_definition(identity.name(), &drift).expect("plist replaced");
9372            super::sys::reload_detached(&identity, StartMode::Login, &path)
9373                .expect("helper started");
9374        }
9375        std::thread::sleep(Duration::from_secs(120));
9376    }
9377
9378    /// A job that rewrites its own plist and asks for the reload comes back
9379    /// under the rewritten plist.
9380    ///
9381    /// The request comes from inside the job, as the daemon's does, so the
9382    /// helper has to outlive the bootout of the very job that started it.
9383    /// launchd sends SIGTERM to the job's whole process group, so a helper that
9384    /// stayed in it would sometimes die before its bootstrap and leave the job
9385    /// unloaded. Measured with `setsid` removed, that failed one run in four
9386    /// here: this test catches it, though not on every run.
9387    #[cfg(target_os = "macos")]
9388    #[test]
9389    fn a_job_reloads_itself_from_its_rewritten_plist() {
9390        if !super::launchd_fixture::enabled() {
9391            return;
9392        }
9393        let root = tempfile::tempdir().expect("a temporary directory");
9394        let identity = ServiceIdentity::fixture(&format!("reload-{}", std::process::id()));
9395        let label = identity.launchd_label();
9396        let path = root.path().join(format!("{label}.plist"));
9397        let binary = super::launchd_fixture::job_binary(root.path());
9398        let mut plist = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
9399        plist.push_str("<plist version=\"1.0\">\n<dict>\n");
9400        plist.push_str(&plist_string("Label", &label));
9401        plist.push_str("  <key>ProgramArguments</key>\n  <array>\n");
9402        for argument in [
9403            binary.to_string_lossy().as_ref(),
9404            "--ignored",
9405            "--exact",
9406            "service::tests::detached_reload_job",
9407            "--test-threads=1",
9408        ] {
9409            plist.push_str(&format!("    <string>{}</string>\n", xml_escape(argument)));
9410        }
9411        plist.push_str("  </array>\n  <key>RunAtLoad</key>\n  <true/>\n");
9412        plist.push_str(&plist_string(
9413            "WorkingDirectory",
9414            &root.path().to_string_lossy(),
9415        ));
9416        plist.push_str(&plist_string("ProcessType", "Background"));
9417        plist.push_str("</dict>\n</plist>\n");
9418        std::fs::write(&path, plist).expect("plist written");
9419
9420        let loaded = super::launchd_fixture::LoadedAgent::bootstrap(&label, &path);
9421        let spawn_type = || {
9422            let printed = std::process::Command::new("launchctl")
9423                .args(["print", loaded.target()])
9424                .output()
9425                .expect("launchctl runs");
9426            String::from_utf8_lossy(&printed.stdout)
9427                .lines()
9428                .find_map(|line| {
9429                    line.trim()
9430                        .strip_prefix("spawn type = ")
9431                        .map(str::to_string)
9432                })
9433        };
9434        assert_eq!(spawn_type().as_deref(), Some("background (5)"));
9435
9436        let deadline = std::time::Instant::now() + Duration::from_secs(90);
9437        while spawn_type().as_deref() != Some("interactive (4)") {
9438            assert!(
9439                std::time::Instant::now() < deadline,
9440                "the job did not come back under its rewritten plist; launchd reports {:?}",
9441                spawn_type()
9442            );
9443            std::thread::sleep(Duration::from_millis(250));
9444        }
9445        let rewritten = std::fs::read_to_string(&path).expect("plist readable");
9446        assert_eq!(
9447            plist_string_value(&rewritten, "ProcessType").as_deref(),
9448            Some(LAUNCHD_PROCESS_TYPE)
9449        );
9450    }
9451
9452    #[test]
9453    fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
9454        let plist = launchd_plist(&linux_plan(StartMode::Boot));
9455        assert!(
9456            plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
9457            "{plist}"
9458        );
9459        assert!(plist.contains("<string>daemon</string>"), "{plist}");
9460        assert!(plist.contains("<string>run</string>"), "{plist}");
9461    }
9462
9463    #[test]
9464    fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
9465        assert_eq!(
9466            ServiceIdentity::product().launchd_label(),
9467            "io.github.IvanMurzak.runner-manager"
9468        );
9469    }
9470
9471    // -----------------------------------------------------------------------
9472    // The Task Scheduler document
9473    // -----------------------------------------------------------------------
9474
9475    #[test]
9476    fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
9477        let xml = windows_scheduled_task_xml(
9478            &windows_plan(StartMode::Login),
9479            &TaskPrincipal::named("HOST\\operator"),
9480        );
9481        assert!(xml.contains("<LogonTrigger>"), "{xml}");
9482        assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
9483        assert!(
9484            xml.contains("<LogonType>InteractiveToken</LogonType>"),
9485            "{xml}"
9486        );
9487        assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
9488        assert!(
9489            xml.contains("<Interval>PT1M</Interval>"),
9490            "Task Scheduler takes whole minutes only, and rejects the registration outright \
9491             for anything finer:\n{xml}"
9492        );
9493    }
9494
9495    #[test]
9496    fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
9497        // Measured against the real Task Scheduler, which answers `PT15S` with
9498        // "The task XML contains a value which is incorrectly formatted or out
9499        // of range". Rounding *up* is what keeps "does not restart-loop faster
9500        // than that bound" true on this manager.
9501        for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
9502            let policy =
9503                RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
9504                    .expect("inside the supported range");
9505            assert_eq!(
9506                policy
9507                    .effective_delay(DefinitionKind::WindowsScheduledTask)
9508                    .as_secs(),
9509                enforced,
9510                "a {asked}s delay must be enforced as {enforced}s"
9511            );
9512            assert!(
9513                policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
9514                "rounding must never shorten the bound"
9515            );
9516        }
9517    }
9518
9519    #[test]
9520    fn every_other_manager_enforces_the_delay_exactly_as_configured() {
9521        let policy = RestartPolicy::default();
9522        for kind in [
9523            DefinitionKind::WindowsService,
9524            DefinitionKind::LaunchdPlist,
9525            DefinitionKind::SystemdUnit,
9526        ] {
9527            assert_eq!(
9528                policy.effective_delay(kind),
9529                policy.delay(),
9530                "{kind:?} takes seconds and enforces exactly what it is given"
9531            );
9532        }
9533    }
9534
9535    #[test]
9536    fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
9537        let host = Host::new();
9538        let operations = host.operations();
9539        operations
9540            .install(&host.request(StartMode::Login))
9541            .expect("an install at login");
9542        // What a real Task Scheduler reports back for a 15-second policy.
9543        host.controls.edit("runner-manager", |registration| {
9544            registration.manager = DefinitionKind::WindowsScheduledTask;
9545            registration.restart_delay = Some(Duration::from_secs(60));
9546        });
9547
9548        let status = operations.status().expect("a status");
9549        assert!(
9550            status.is_healthy(),
9551            "minute granularity is the manager's, not a mis-registration: {status}"
9552        );
9553        assert!(
9554            status
9555                .notes()
9556                .iter()
9557                .any(|note| note.contains("whole minutes")),
9558            "but the operator must be told why 15 became 60: {status}"
9559        );
9560
9561        // The discriminator: a delay that is neither the configured one nor its
9562        // rounding is still a fault.
9563        host.controls.edit("runner-manager", |registration| {
9564            registration.restart_delay = Some(Duration::from_secs(1));
9565        });
9566        assert!(
9567            !operations.status().expect("a status").is_healthy(),
9568            "a one-second delay is not what any manager was asked for"
9569        );
9570    }
9571
9572    #[test]
9573    fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
9574        let plan = windows_plan(StartMode::Login);
9575        let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
9576        assert_eq!(
9577            xml_value(&xml, "Command").as_deref(),
9578            Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
9579            "{xml}"
9580        );
9581        assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
9582    }
9583
9584    #[test]
9585    fn an_account_name_holding_xml_punctuation_is_escaped() {
9586        let xml = windows_scheduled_task_xml(
9587            &windows_plan(StartMode::Login),
9588            &TaskPrincipal::named("R&D\\ops"),
9589        );
9590        assert!(xml.contains("<UserId>R&amp;D\\ops</UserId>"), "{xml}");
9591        assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
9592    }
9593
9594    // -----------------------------------------------------------------------
9595    // The Windows service descriptor
9596    // -----------------------------------------------------------------------
9597
9598    #[test]
9599    fn the_service_starts_automatically_under_the_account_the_store_admits() {
9600        let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
9601        let directives = ini_directives(&text, "windows-service");
9602        assert_eq!(
9603            directives.get("StartType").map(String::as_str),
9604            Some("AutoStart")
9605        );
9606        assert_eq!(
9607            directives.get("Account").map(String::as_str),
9608            Some("NT AUTHORITY\\SYSTEM")
9609        );
9610        assert_eq!(
9611            directives.get("ServiceType").map(String::as_str),
9612            Some("OWN_PROCESS")
9613        );
9614        assert_eq!(
9615            directives
9616                .get("FailureActionRestartDelaySecs")
9617                .map(String::as_str),
9618            Some("15")
9619        );
9620        assert_eq!(
9621            directives
9622                .get("FailureActionsOnNonCrashFailures")
9623                .map(String::as_str),
9624            Some("true"),
9625            "without this flag a non-zero exit is not a failure the manager restarts"
9626        );
9627    }
9628
9629    #[test]
9630    fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
9631        let spec = windows_service_spec(&windows_plan(StartMode::Boot));
9632        assert_eq!(spec.account, None);
9633        assert!(spec.automatic_start);
9634        assert!(
9635            spec.command_line.contains("daemon run"),
9636            "{}",
9637            spec.command_line
9638        );
9639    }
9640
9641    // -----------------------------------------------------------------------
9642    // Where each definition is installed
9643    // -----------------------------------------------------------------------
9644
9645    #[test]
9646    fn each_definition_goes_where_its_platform_expects_it() {
9647        let home = PathBuf::from("/home/op");
9648        assert_eq!(
9649            ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
9650            Some(Path::new(
9651                "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
9652            ))
9653        );
9654        assert_eq!(
9655            ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
9656            Some(Path::new(
9657                "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
9658            ))
9659        );
9660        assert_eq!(
9661            ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
9662            Some(Path::new("/etc/systemd/system/runner-manager.service"))
9663        );
9664        assert_eq!(
9665            ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
9666            Some(Path::new(
9667                "/home/op/.config/systemd/user/runner-manager.service"
9668            ))
9669        );
9670        assert_eq!(
9671            ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
9672            None,
9673            "the Service Control Manager has no file"
9674        );
9675    }
9676
9677    #[test]
9678    fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
9679        assert_eq!(
9680            ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
9681            None
9682        );
9683        assert_eq!(
9684            ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
9685            None
9686        );
9687    }
9688
9689    // -----------------------------------------------------------------------
9690    // The least-privilege review: the passing case, then every failing one
9691    // -----------------------------------------------------------------------
9692
9693    #[test]
9694    fn the_rendered_definitions_are_all_least_privilege() {
9695        let linux = linux_plan(StartMode::Boot);
9696        let windows = windows_plan(StartMode::Boot);
9697        for (definition, plan) in [
9698            (ServiceDefinition::systemd(&linux, None), &linux),
9699            (ServiceDefinition::launchd(&linux, None), &linux),
9700            (ServiceDefinition::windows_service(&windows), &windows),
9701        ] {
9702            let review = review_least_privilege(&definition, plan);
9703            assert!(
9704                review.is_least_privilege(),
9705                "{:?} should be least privilege, got:\n{review}",
9706                definition.kind()
9707            );
9708            assert!(
9709                !review.controls().is_empty(),
9710                "a review that confirms nothing proves nothing: {:?}",
9711                definition.kind()
9712            );
9713        }
9714    }
9715
9716    #[test]
9717    fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
9718        let plan = windows_plan(StartMode::Login);
9719        let definition =
9720            ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
9721        let review = review_least_privilege(&definition, &plan);
9722        assert!(review.is_least_privilege(), "{review}");
9723        assert!(
9724            review
9725                .controls()
9726                .iter()
9727                .any(|control| control.contains("LeastPrivilege")),
9728            "{review}"
9729        );
9730    }
9731
9732    #[test]
9733    fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
9734        let plan = linux_plan(StartMode::Boot);
9735        let rendered = systemd_unit(&plan);
9736        let widened = edited(
9737            &rendered,
9738            "ReadWritePaths=/var/lib/runner-manager/config",
9739            "ReadWritePaths=/etc /var/lib/runner-manager/config",
9740        );
9741        let review = review_least_privilege(
9742            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
9743            &plan,
9744        );
9745        assert!(!review.is_least_privilege(), "{review}");
9746        assert!(
9747            review
9748                .excesses()
9749                .iter()
9750                .any(|finding| finding.detail.contains("/etc")),
9751            "the review must name the directory it objects to: {review}"
9752        );
9753    }
9754
9755    #[test]
9756    fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
9757        let plan = linux_plan(StartMode::Boot);
9758        let rendered = systemd_unit(&plan);
9759        let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
9760        let review = review_least_privilege(
9761            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
9762            &plan,
9763        );
9764        assert!(!review.is_least_privilege(), "{review}");
9765        assert!(
9766            review
9767                .excesses()
9768                .iter()
9769                .any(|finding| finding.subject == "NoNewPrivileges"),
9770            "{review}"
9771        );
9772    }
9773
9774    /// Whether reviewing `unit` against `plan` reports `kind` for `subject`.
9775    fn reports(unit: &str, plan: &InstallPlan, subject: &str, kind: FindingKind) -> bool {
9776        review_least_privilege(
9777            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, unit.to_string()),
9778            plan,
9779        )
9780        .findings()
9781        .iter()
9782        .any(|finding| finding.subject == subject && finding.kind == kind)
9783    }
9784
9785    #[test]
9786    fn every_unit_drops_every_capability_and_boot_units_hide_home_trees() {
9787        let boot = systemd_unit(&linux_plan(StartMode::Boot));
9788        let directives = ini_directives(&boot, "Service");
9789        assert_eq!(directives["CapabilityBoundingSet"], "");
9790        assert_eq!(directives["AmbientCapabilities"], "");
9791        assert_eq!(directives["ProtectHome"], "tmpfs");
9792        assert!(!directives.contains_key("BindPaths"));
9793
9794        let login = systemd_unit(&linux_plan(StartMode::Login));
9795        let directives = ini_directives(&login, "Service");
9796        assert_eq!(directives["CapabilityBoundingSet"], "");
9797        assert!(!directives.contains_key("ProtectHome"));
9798
9799        let unprotected = boot.replace("ProtectHome=tmpfs\n", "");
9800        assert!(reports(
9801            &unprotected,
9802            &linux_plan(StartMode::Boot),
9803            "ProtectHome",
9804            FindingKind::Excess
9805        ));
9806        let inverted = boot.replace(
9807            "CapabilityBoundingSet=\n",
9808            "CapabilityBoundingSet=~CAP_SYS_ADMIN\n",
9809        );
9810        assert!(reports(
9811            &inverted,
9812            &linux_plan(StartMode::Boot),
9813            "CapabilityBoundingSet",
9814            FindingKind::Excess
9815        ));
9816    }
9817
9818    #[test]
9819    fn status_reports_the_registered_account_and_falls_back_to_the_record() {
9820        let recorded = ServiceAccount::for_start_mode(StartMode::Boot).to_string();
9821        for (registered, shown) in [(Some("ivan"), "ivan"), (None, recorded.as_str())] {
9822            let host = Host::new();
9823            let operations = host.operations();
9824            operations.install(&host.request(StartMode::Boot)).unwrap();
9825            host.controls.edit("runner-manager", |registration| {
9826                registration.account = registered.map(str::to_string);
9827            });
9828            let status = operations.status().unwrap().to_string();
9829            assert!(
9830                status.contains(&format!("account                   {shown}\n")),
9831                "{status}"
9832            );
9833        }
9834    }
9835
9836    #[cfg(target_os = "linux")]
9837    #[test]
9838    fn a_boot_install_refuses_a_unit_a_drop_in_runs_as_another_account() {
9839        let host = Host::new();
9840        let operations = host.operations();
9841        operations.install(&host.request(StartMode::Boot)).unwrap();
9842        host.controls.edit("runner-manager", |registration| {
9843            registration.account = Some("ivan".to_string());
9844        });
9845        let refused = operations
9846            .install(&host.request(StartMode::Boot))
9847            .expect_err("a unit running as ivan cannot use root's tree");
9848        let message = refused.to_string();
9849        assert!(message.contains("run as ivan"), "{message}");
9850        assert!(message.contains("Remove `User=`"), "{message}");
9851    }
9852
9853    #[test]
9854    fn manager_account_spellings_match_the_record() {
9855        assert_eq!(
9856            windows_service_account("LocalSystem"),
9857            ServiceAccount::LocalSystem.as_str()
9858        );
9859        assert_eq!(windows_service_account(".\\svc-runner"), ".\\svc-runner");
9860        assert_eq!(
9861            scheduled_task_account(Some("S-1-5-21-1-2-3-1001".into())),
9862            None
9863        );
9864        assert_eq!(
9865            scheduled_task_account(Some("HOST\\op".into())),
9866            Some("HOST\\op".to_string())
9867        );
9868    }
9869
9870    #[test]
9871    fn exec_start_prefixes_are_not_part_of_the_binary() {
9872        for (value, expected) in [
9873            ("/opt/rm daemon run", "/opt/rm daemon run"),
9874            ("-/opt/rm daemon run", "/opt/rm daemon run"),
9875            ("+!/opt/rm daemon run", "/opt/rm daemon run"),
9876            ("!!/opt/rm daemon run", "/opt/rm daemon run"),
9877            ("@/opt/rm runner-manager daemon run", "/opt/rm daemon run"),
9878            ("-@\"/opt/my rm\" rm daemon", "\"/opt/my rm\" daemon"),
9879        ] {
9880            assert_eq!(systemd_exec_command_line(value), expected, "{value}");
9881        }
9882        assert_eq!(
9883            executable_from_command_line(&systemd_exec_command_line("-/opt/rm daemon")),
9884            Some(PathBuf::from("/opt/rm"))
9885        );
9886    }
9887
9888    #[test]
9889    fn restart_sec_accepts_systemd_time_spans() {
9890        for (value, expected) in [
9891            ("15", Duration::from_secs(15)),
9892            ("15s", Duration::from_secs(15)),
9893            ("1min", Duration::from_secs(60)),
9894            ("1min 30s", Duration::from_secs(90)),
9895            ("1min30s", Duration::from_secs(90)),
9896            ("2h", Duration::from_secs(7_200)),
9897            ("500ms", Duration::from_millis(500)),
9898            ("1.5s", Duration::from_millis(1_500)),
9899        ] {
9900            assert_eq!(systemd_timespan(value), Some(expected), "{value}");
9901        }
9902        for value in ["", "infinity", "soon", "5 parsecs", "s"] {
9903            assert_eq!(systemd_timespan(value), None, "{value}");
9904        }
9905    }
9906
9907    #[cfg(unix)]
9908    #[test]
9909    fn the_system_tree_is_reported_as_roots_installation_under_var_lib() {
9910        let system = ServiceDirectories::of(&AppPaths::system());
9911        assert_eq!(system.root(), Path::new("/var/lib/runner-manager"));
9912        assert_eq!(
9913            data_root_owner(&system.root(), Some(Path::new("/home/op"))).as_deref(),
9914            Some("root")
9915        );
9916    }
9917
9918    #[test]
9919    fn a_boot_service_refuses_directories_it_could_not_open() {
9920        let var = Path::new("/var/lib/runner-manager/state");
9921        assert_eq!(boot_directory_refusal(var, Some(0)), None);
9922        let refused = boot_directory_refusal(var, Some(1000)).expect("another owner");
9923        assert!(refused.contains("uid 1000"), "{refused}");
9924        assert!(boot_directory_refusal(var, None).is_some());
9925        for hidden in ["/home/op/.config/runner-manager", "/root/.local/state/rm"] {
9926            let refused =
9927                boot_directory_refusal(Path::new(hidden), Some(0)).expect("a hidden tree");
9928            assert!(refused.contains("cannot see"), "{refused}");
9929        }
9930    }
9931
9932    #[cfg(unix)]
9933    #[test]
9934    fn a_moved_tree_keeps_files_modes_and_links_but_not_the_skipped_entry() {
9935        use std::os::unix::fs::PermissionsExt as _;
9936
9937        let from = tempfile::tempdir().unwrap();
9938        let to = tempfile::tempdir().unwrap();
9939        let target = to.path().join("state");
9940        std::fs::create_dir_all(from.path().join("journal/2026")).unwrap();
9941        std::fs::write(from.path().join("journal/2026/attempt"), b"entry").unwrap();
9942        std::fs::write(from.path().join("secret"), b"x").unwrap();
9943        std::fs::set_permissions(
9944            from.path().join("secret"),
9945            std::fs::Permissions::from_mode(0o600),
9946        )
9947        .unwrap();
9948        std::os::unix::fs::symlink("journal", from.path().join("latest")).unwrap();
9949        std::fs::create_dir(from.path().join("bin")).unwrap();
9950
9951        copy_tree(from.path(), &target, Some("bin")).unwrap();
9952
9953        assert_eq!(
9954            std::fs::read(target.join("journal/2026/attempt")).unwrap(),
9955            b"entry"
9956        );
9957        let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
9958        assert_eq!(mode(&target.join("secret")), 0o600);
9959        assert_eq!(mode(&target.join("journal")), 0o700);
9960        assert_eq!(
9961            std::fs::read_link(target.join("latest")).unwrap(),
9962            Path::new("journal")
9963        );
9964        assert!(!target.join("bin").exists());
9965
9966        // An occupied target is set aside whole, not merged into or deleted.
9967        std::fs::write(target.join("stale.sqlite3-wal"), b"stale").unwrap();
9968        set_aside(&target, "20261110T000000Z").unwrap();
9969        assert!(!target.exists());
9970        let aside = to.path().join("state.before-move-20261110T000000Z");
9971        assert_eq!(
9972            std::fs::read(aside.join("stale.sqlite3-wal")).unwrap(),
9973            b"stale"
9974        );
9975        copy_tree(from.path(), &target, Some("bin")).unwrap();
9976        assert!(!target.join("stale.sqlite3-wal").exists());
9977        set_aside(&to.path().join("absent"), "x").unwrap();
9978    }
9979
9980    #[test]
9981    fn systemd_drop_ins_replace_base_service_directives() {
9982        let base = systemd_unit(&linux_plan(StartMode::Boot));
9983        let combined = format!(
9984            "# /etc/systemd/system/runner-manager.service\n{base}\n\
9985             # /etc/systemd/system/runner-manager.service.d/account.conf\n\
9986             [Service]\nUser=ivan\nRestartSec=30\nExecStart=\n\
9987             ExecStart=/opt/runner-manager daemon run\n"
9988        );
9989        let directives = ini_directives(&combined, "Service");
9990        assert_eq!(directives["User"], "ivan");
9991        assert_eq!(directives["RestartSec"], "30");
9992        assert_eq!(directives["ExecStart"], "/opt/runner-manager daemon run");
9993        assert_eq!(directives["ProtectSystem"], "strict");
9994    }
9995
9996    #[test]
9997    fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
9998        let plan = linux_plan(StartMode::Boot);
9999        let rendered = systemd_unit(&plan);
10000        let widened = edited(
10001            &rendered,
10002            "CapabilityBoundingSet=\n",
10003            "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
10004        );
10005        let review = review_least_privilege(
10006            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10007            &plan,
10008        );
10009        assert!(!review.is_least_privilege(), "{review}");
10010        assert!(
10011            review
10012                .excesses()
10013                .iter()
10014                .any(|finding| finding.subject == "CapabilityBoundingSet"),
10015            "{review}"
10016        );
10017    }
10018
10019    #[test]
10020    fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
10021        let plan = linux_plan(StartMode::Boot);
10022        let rendered = systemd_unit(&plan);
10023        let widened = edited(
10024            &rendered,
10025            "[Install]",
10026            "ListenStream=127.0.0.1:9000\n\n[Install]",
10027        );
10028        let review = review_least_privilege(
10029            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10030            &plan,
10031        );
10032        assert!(
10033            !review.is_least_privilege(),
10034            "07-security.md rule 2 forbids any inbound surface: {review}"
10035        );
10036    }
10037
10038    #[test]
10039    fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
10040        let plan = linux_plan(StartMode::Boot);
10041        let rendered = systemd_unit(&plan);
10042        let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
10043        let review = review_least_privilege(
10044            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
10045            &plan,
10046        );
10047        assert!(
10048            review.is_least_privilege(),
10049            "too little authority is not an excess: {review}"
10050        );
10051        assert!(
10052            review
10053                .findings()
10054                .iter()
10055                .any(|finding| finding.kind == FindingKind::Shortfall
10056                    && finding.detail.contains("runtime")),
10057            "{review}"
10058        );
10059    }
10060
10061    #[test]
10062    fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
10063        let plan = linux_plan(StartMode::Login);
10064        let rendered = launchd_plist(&plan);
10065        let widened = edited(
10066            &rendered,
10067            "<key>ProcessType</key>",
10068            "<key>UserName</key>\n  <string>root</string>\n  <key>ProcessType</key>",
10069        );
10070        let review = review_least_privilege(
10071            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10072            &plan,
10073        );
10074        assert!(!review.is_least_privilege(), "{review}");
10075        assert!(
10076            review
10077                .excesses()
10078                .iter()
10079                .any(|finding| finding.subject == "UserName"),
10080            "{review}"
10081        );
10082    }
10083
10084    #[test]
10085    fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
10086        let plan = linux_plan(StartMode::Boot);
10087        let rendered = launchd_plist(&plan);
10088        let widened = edited(
10089            &rendered,
10090            "<key>SessionCreate</key>\n  <false/>",
10091            "<key>SessionCreate</key>\n  <true/>",
10092        );
10093        let review = review_least_privilege(
10094            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10095            &plan,
10096        );
10097        assert!(!review.is_least_privilege(), "{review}");
10098        assert!(
10099            review
10100                .excesses()
10101                .iter()
10102                .any(|finding| finding.subject == "SessionCreate"),
10103            "{review}"
10104        );
10105    }
10106
10107    #[test]
10108    fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
10109        let plan = linux_plan(StartMode::Boot);
10110        let rendered = launchd_plist(&plan);
10111        let widened = edited(
10112            &rendered,
10113            "<key>ProcessType</key>",
10114            "<key>MachServices</key>\n  <dict/>\n  <key>ProcessType</key>",
10115        );
10116        let review = review_least_privilege(
10117            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10118            &plan,
10119        );
10120        assert!(!review.is_least_privilege(), "{review}");
10121    }
10122
10123    #[test]
10124    fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
10125        let plan = windows_plan(StartMode::Login);
10126        let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
10127        let widened = edited(
10128            &rendered,
10129            "<RunLevel>LeastPrivilege</RunLevel>",
10130            "<RunLevel>HighestAvailable</RunLevel>",
10131        );
10132        let review = review_least_privilege(
10133            &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
10134            &plan,
10135        );
10136        assert!(!review.is_least_privilege(), "{review}");
10137        assert!(
10138            review
10139                .excesses()
10140                .iter()
10141                .any(|finding| finding.subject == "RunLevel"),
10142            "{review}"
10143        );
10144    }
10145
10146    #[test]
10147    fn a_task_that_would_store_a_password_is_not_least_privilege() {
10148        let plan = windows_plan(StartMode::Login);
10149        let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
10150        let widened = edited(
10151            &rendered,
10152            "<LogonType>InteractiveToken</LogonType>",
10153            "<LogonType>Password</LogonType>",
10154        );
10155        let review = review_least_privilege(
10156            &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
10157            &plan,
10158        );
10159        assert!(!review.is_least_privilege(), "{review}");
10160    }
10161
10162    #[test]
10163    fn an_interactive_windows_service_is_not_least_privilege() {
10164        let plan = windows_plan(StartMode::Boot);
10165        let rendered = windows_service_descriptor(&plan);
10166        let widened = edited(
10167            &rendered,
10168            "ServiceType=OWN_PROCESS",
10169            "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
10170        );
10171        let review = review_least_privilege(
10172            &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
10173            &plan,
10174        );
10175        assert!(!review.is_least_privilege(), "{review}");
10176        assert!(
10177            review
10178                .excesses()
10179                .iter()
10180                .any(|finding| finding.subject == "ServiceType"),
10181            "{review}"
10182        );
10183    }
10184
10185    #[test]
10186    fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
10187        let plan = windows_plan(StartMode::Boot);
10188        let rendered = windows_service_descriptor(&plan);
10189        let changed = edited(
10190            &rendered,
10191            "Account=NT AUTHORITY\\SYSTEM",
10192            "Account=NT AUTHORITY\\LocalService",
10193        );
10194        let review = review_least_privilege(
10195            &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
10196            &plan,
10197        );
10198        // LocalService is *less* privileged, so this is not an excess. It is
10199        // still wrong, because `d2`'s DACL names SY, BA and OW and nothing else:
10200        // the daemon would start and find no credential. Reporting it as a
10201        // shortfall rather than as an excess is the difference between a check
10202        // that understands the requirement and one that counts adjectives.
10203        assert!(review.is_least_privilege(), "{review}");
10204        assert!(
10205            review.findings().iter().any(|finding| {
10206                finding.kind == FindingKind::Shortfall && finding.subject == "Account"
10207            }),
10208            "{review}"
10209        );
10210    }
10211
10212    // -----------------------------------------------------------------------
10213    // The recorded absolute path, and what became of it
10214    // -----------------------------------------------------------------------
10215
10216    #[test]
10217    fn a_recorded_path_that_is_still_there_is_current() {
10218        let host = Host::new();
10219        let state = inspect_binary(&host.binary, Some(&host.binary));
10220        assert!(!state.is_error(), "{state}");
10221        assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
10222    }
10223
10224    #[test]
10225    fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
10226        let host = Host::new();
10227        // An `npm i -g` binary lives under the active Node installation's
10228        // global prefix. Switching Node versions moves the prefix, and the
10229        // recorded path stops existing while the registration survives.
10230        let recorded = host.binary.clone();
10231        let healthy = inspect_binary(&recorded, Some(&recorded));
10232        assert!(
10233            !healthy.is_error(),
10234            "the discriminator: before the binary moves, this must be healthy"
10235        );
10236
10237        std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
10238
10239        let state = inspect_binary(&recorded, Some(&recorded));
10240        assert!(state.is_error(), "{state}");
10241        assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
10242        assert!(
10243            state.to_string().contains("npm"),
10244            "the message must name the cause an operator will not otherwise connect: {state}"
10245        );
10246    }
10247
10248    #[test]
10249    fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
10250        let root = tempfile::tempdir().expect("a temporary directory");
10251        let state = inspect_binary(root.path(), None);
10252        assert!(state.is_error(), "{state}");
10253        assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
10254    }
10255
10256    #[test]
10257    fn a_registration_naming_a_different_binary_is_a_divergence() {
10258        let host = Host::new();
10259        let other = host.binary.with_file_name("something-else");
10260        let state = inspect_binary(&host.binary, Some(&other));
10261        assert!(state.is_error(), "{state}");
10262        assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
10263    }
10264
10265    #[test]
10266    fn absence_is_reported_before_divergence() {
10267        let host = Host::new();
10268        let recorded = host.binary.clone();
10269        std::fs::remove_file(&recorded).expect("removable");
10270        let other = recorded.with_file_name("something-else");
10271        // Both faults are true at once. The operator needs to hear about the
10272        // missing file, not about a disagreement between two paths of which one
10273        // does not exist.
10274        assert!(matches!(
10275            inspect_binary(&recorded, Some(&other)),
10276            BinaryPath::Missing { .. }
10277        ));
10278    }
10279
10280    // -----------------------------------------------------------------------
10281    // The record
10282    // -----------------------------------------------------------------------
10283
10284    #[test]
10285    fn the_record_round_trips_through_toml() {
10286        let host = Host::new();
10287        let plan = InstallPlan::resolve(
10288            ServiceIdentity::product(),
10289            &host.request(StartMode::Boot),
10290            ServiceDirectories::of(&host.paths),
10291        )
10292        .expect("a resolvable plan");
10293        let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
10294        let record = InstallRecord::of(&plan, &definition, Utc::now());
10295        record.write(&host.paths).expect("a writable record");
10296        let read = InstallRecord::read(&host.paths)
10297            .expect("a readable record")
10298            .expect("a record is there");
10299        assert_eq!(read, record);
10300        assert_eq!(read.binary, host.binary);
10301        assert!(read.binary.is_absolute());
10302    }
10303
10304    #[test]
10305    fn a_unit_written_by_an_older_build_is_reported_and_a_current_one_is_not() {
10306        let host = Host::new();
10307        let identity = ServiceIdentity::product();
10308        let plan = InstallPlan::resolve(
10309            identity.clone(),
10310            &host.request(StartMode::Boot),
10311            ServiceDirectories::of(&host.paths),
10312        )
10313        .expect("a resolvable plan");
10314        let installed = systemd_unit(&plan);
10315        let definition =
10316            ServiceDefinition::from_text(DefinitionKind::SystemdUnit, installed.clone());
10317        let mut record = InstallRecord::of(&plan, &definition, Utc::now());
10318        let path = PathBuf::from("/etc/systemd/system/runner-manager.service");
10319
10320        assert_eq!(
10321            systemd_definition_drift(&identity, &record, path.clone(), &installed),
10322            None,
10323            "the record must rebuild exactly the unit `service install` wrote"
10324        );
10325
10326        record.installed_by_version = "0.4.7".into();
10327        let frozen = installed.replace(
10328            "[Service]\n",
10329            "[Service]\nLoadCredential=runner-manager.user-access-token:/var/lib/runner-manager/secrets/user-access-token\n",
10330        );
10331        let drift = systemd_definition_drift(&identity, &record, path, &frozen)
10332            .expect("an outdated unit is drift");
10333        assert_eq!(drift.installed_by_version, "0.4.7");
10334        assert_eq!(drift.rendered, installed);
10335
10336        let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
10337            .with_definition_drift(Some(drift));
10338        assert!(!status.is_healthy());
10339        assert!(
10340            status
10341                .problems()
10342                .iter()
10343                .any(|problem| problem.subject == "definition" && problem.detail.contains("0.4.7")),
10344            "{status}"
10345        );
10346    }
10347
10348    #[test]
10349    fn a_rejected_credential_keeps_its_first_moment_and_makes_the_service_unhealthy() {
10350        let host = Host::new();
10351        assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
10352        let first = Utc::now() - chrono::Duration::hours(9);
10353        record_github_credential_rejected(&host.paths, first).unwrap();
10354        record_github_credential_rejected(&host.paths, Utc::now()).unwrap();
10355        let since = github_credential_rejected_since(&host.paths).unwrap();
10356        assert_eq!(since, Some(first));
10357
10358        let status = ServiceStatus::compose(
10359            ServiceIdentity::product(),
10360            None,
10361            None,
10362            None,
10363            None,
10364            &host.paths,
10365        )
10366        .with_credential_rejection(since);
10367        assert!(!status.is_healthy());
10368        assert_eq!(status.credential_rejected_since(), Some(first));
10369        assert!(status.to_string().contains("auth login"), "{status}");
10370
10371        clear_github_credential_rejection(&host.paths).unwrap();
10372        clear_github_credential_rejection(&host.paths).unwrap();
10373        assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
10374    }
10375
10376    /// A guest that started nothing for 13 days while this said `healthy`.
10377    #[test]
10378    fn blocked_launches_make_the_service_unhealthy_with_their_remedy() {
10379        use crate::launch_health::{
10380            BlockCause, Viewpoint, clear_launches_blocked, launches_blocked,
10381            record_launches_blocked,
10382        };
10383
10384        let host = Host::new();
10385        let compose = || {
10386            ServiceStatus::compose(
10387                ServiceIdentity::product(),
10388                None,
10389                None,
10390                None,
10391                None,
10392                &host.paths,
10393            )
10394            .with_launches_blocked(launches_blocked(&host.paths, Utc::now()))
10395        };
10396        assert!(
10397            !compose()
10398                .problems()
10399                .iter()
10400                .any(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
10401        );
10402        let blocked = BlockCause::FenceHeld {
10403            directory: PathBuf::from("launch-fence"),
10404            owner: None,
10405        }
10406        .blocked_since(Utc::now(), Viewpoint::Guest);
10407        record_launches_blocked(&host.paths, &blocked, Utc::now()).unwrap();
10408
10409        let status = compose();
10410        assert!(!status.is_healthy());
10411        assert_eq!(status.launches_blocked(), Some(&blocked));
10412        let problem = status
10413            .problems()
10414            .iter()
10415            .find(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
10416            .expect("reported under its own subject");
10417        assert!(problem.detail.contains("systemctl restart"), "{status}");
10418
10419        clear_launches_blocked(&host.paths).unwrap();
10420        assert_eq!(compose().launches_blocked(), None);
10421    }
10422
10423    /// The daemon's own "I cannot read my credential", which used to exist
10424    /// only in its log while `service status` said `healthy`.
10425    #[test]
10426    fn an_unreadable_credential_keeps_its_first_moment_and_names_the_exact_command() {
10427        let host = Host::new();
10428        assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
10429        let first = Utc::now() - chrono::Duration::minutes(40);
10430        record_credential_unreadable(&host.paths, first).unwrap();
10431        record_credential_unreadable(&host.paths, Utc::now()).unwrap();
10432        let since = credential_unreadable_since(&host.paths).unwrap();
10433        assert_eq!(since, Some(first), "a restart loop must not move the start");
10434
10435        let status = ServiceStatus::compose(
10436            ServiceIdentity::product(),
10437            None,
10438            None,
10439            None,
10440            None,
10441            &host.paths,
10442        )
10443        .with_credential_unreadable(since);
10444        assert!(!status.is_healthy());
10445        let problem = status
10446            .problems()
10447            .iter()
10448            .find(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT)
10449            .expect("reported under its own subject");
10450        // No record, so the default start mode, whose exact command is named.
10451        assert!(
10452            problem
10453                .detail
10454                .contains(&sign_in_instruction(StartMode::default())),
10455            "{status}"
10456        );
10457
10458        clear_credential_unreadable(&host.paths).unwrap();
10459        clear_credential_unreadable(&host.paths).unwrap();
10460        assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
10461        let healthy = ServiceStatus::compose(
10462            ServiceIdentity::product(),
10463            None,
10464            None,
10465            None,
10466            None,
10467            &host.paths,
10468        )
10469        .with_credential_unreadable(None);
10470        assert!(
10471            !healthy
10472                .problems()
10473                .iter()
10474                .any(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT),
10475            "{healthy}"
10476        );
10477    }
10478
10479    #[test]
10480    fn a_sudo_command_names_a_binary_sudo_would_not_find() {
10481        assert_eq!(sudo_program(None), "runner-manager");
10482        assert_eq!(
10483            sudo_program(Some(Path::new("/usr/local/bin/runner-manager"))),
10484            "runner-manager"
10485        );
10486        assert_eq!(
10487            sudo_program(Some(Path::new(
10488                "/home/op/.nvm/versions/node/v26.13.0/lib/node_modules/@ivan-murzak/\
10489                 runner-manager-linux-x64/bin/runner-manager"
10490            ))),
10491            "/home/op/.nvm/versions/node/v26.13.0/lib/node_modules/@ivan-murzak/\
10492             runner-manager-linux-x64/bin/runner-manager"
10493        );
10494        assert_eq!(
10495            sudo_program(Some(Path::new("/home/o'p/my bin/runner-manager"))),
10496            "'/home/o'\\''p/my bin/runner-manager'"
10497        );
10498        assert_eq!(
10499            sudo_command("service status"),
10500            "sudo runner-manager service status"
10501        );
10502    }
10503
10504    #[test]
10505    fn the_sign_in_instruction_names_the_start_mode_it_writes() {
10506        assert!(sign_in_instruction(StartMode::Login).contains("auth login --start-at login`"));
10507        assert!(sign_in_instruction(StartMode::Boot).contains("auth login --start-at boot`"));
10508        assert_eq!(
10509            sign_in_instruction(StartMode::Boot).starts_with("`sudo "),
10510            cfg!(unix),
10511            "a machine-scoped store needs root on Unix"
10512        );
10513        assert_eq!(
10514            sign_in_instruction(StartMode::Login).contains("SSH"),
10515            cfg!(target_os = "macos"),
10516            "only the macOS login keychain is out of an SSH session's reach"
10517        );
10518    }
10519
10520    /// The record is readable by the account whose directory it is in.
10521    ///
10522    /// A boot-mode `service install` runs under `sudo`, and `NamedTempFile`
10523    /// creates at `0600`, so the record landed `root`-owned and unreadable by
10524    /// the operator — `service status` then failed on their own host with
10525    /// `Permission denied`. Ownership cannot be reproduced in an unprivileged
10526    /// test, but the mode that made ownership fatal can, and it is the half
10527    /// this code controls.
10528    #[cfg(unix)]
10529    #[test]
10530    fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
10531        use std::os::unix::fs::PermissionsExt as _;
10532
10533        let host = Host::new();
10534        let plan = InstallPlan::resolve(
10535            ServiceIdentity::product(),
10536            &host.request(StartMode::Boot),
10537            ServiceDirectories::of(&host.paths),
10538        )
10539        .expect("a resolvable plan");
10540        let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
10541        InstallRecord::of(&plan, &definition, Utc::now())
10542            .write(&host.paths)
10543            .expect("a writable record");
10544
10545        let mode = std::fs::metadata(InstallRecord::path(&host.paths))
10546            .expect("the record is there")
10547            .permissions()
10548            .mode()
10549            & 0o777;
10550        assert_eq!(
10551            mode, 0o644,
10552            "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
10553             service install` wrote, and `service status` fails on their own host. It holds no \
10554             credential and sits in a 0700 directory, so 0644 discloses nothing"
10555        );
10556    }
10557
10558    /// A record written before the service ran a copy of its own must still
10559    /// load. It reads as `None`, which is the truth about it: that registration
10560    /// names the package manager's file and cannot be upgraded under itself.
10561    #[test]
10562    fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
10563        let host = Host::new();
10564        let path = InstallRecord::path(&host.paths);
10565        std::fs::write(
10566            &path,
10567            format!(
10568                "schema_version = {RECORD_SCHEMA_VERSION}
10569service_name = \"runner-manager\"
10570                 manager = \"systemd\"
10571start_mode = \"boot\"
10572account = \"root\"
10573                 binary = \"/x\"
10574arguments = []
10575restart_delay_secs = 15
10576                 restart_reset_secs = 600
10577log_file = \"/x\"
10578                 installed_at = \"2026-01-01T00:00:00Z\"
10579installed_by_version = \"0.1.0\"
10580                 [directories]
10581config = \"/a\"
10582state = \"/b\"
10583runtime = \"/c\"
10584logs = \"/d\"
10585"
10586            ),
10587        )
10588        .expect("a writable record");
10589        let read = InstallRecord::read(&host.paths)
10590            .expect("a record missing an optional field is still readable")
10591            .expect("a record is there");
10592        assert_eq!(
10593            read.source_binary, None,
10594            "the legacy layout has no source, and must not invent one"
10595        );
10596    }
10597
10598    /// The field that makes an upgrade possible survives the write.
10599    #[test]
10600    fn a_registration_remembers_the_file_it_was_copied_from() {
10601        let host = Host::new();
10602        let source = host.binary.with_file_name("npm-installed-runner-manager");
10603        std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
10604        let plan = InstallPlan::resolve(
10605            ServiceIdentity::product(),
10606            &host.request(StartMode::Boot).copied_from(&source),
10607            ServiceDirectories::of(&host.paths),
10608        )
10609        .expect("a resolvable plan");
10610        let definition = ServiceDefinition::from_text(
10611            DefinitionKind::SystemdUnit,
10612            "[Service]
10613",
10614        );
10615        let record = InstallRecord::of(&plan, &definition, Utc::now());
10616        record.write(&host.paths).expect("a writable record");
10617
10618        let read = InstallRecord::read(&host.paths)
10619            .expect("a readable record")
10620            .expect("a record is there");
10621        assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
10622        assert_ne!(
10623            read.source_binary.as_deref(),
10624            Some(read.binary.as_path()),
10625            "the whole point is that the two are different files: one the service holds open,              one the package manager is free to replace"
10626        );
10627    }
10628
10629    #[test]
10630    fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
10631        let host = Host::new();
10632        let path = InstallRecord::path(&host.paths);
10633        std::fs::write(
10634            &path,
10635            format!(
10636                "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
10637                 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
10638                 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
10639                 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
10640                 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
10641                RECORD_SCHEMA_VERSION + 1
10642            ),
10643        )
10644        .expect("a writable record");
10645        let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
10646        assert!(
10647            matches!(error, ServiceError::RecordUnreadable { .. }),
10648            "{error}"
10649        );
10650        assert!(
10651            error.to_string().contains("service uninstall"),
10652            "the message must say how to recover: {error}"
10653        );
10654    }
10655
10656    #[test]
10657    fn no_record_is_not_an_error() {
10658        let host = Host::new();
10659        assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
10660        assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
10661    }
10662
10663    // -----------------------------------------------------------------------
10664    // The last successful GitHub contact
10665    // -----------------------------------------------------------------------
10666
10667    #[test]
10668    fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
10669        let host = Host::new();
10670        assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
10671    }
10672
10673    #[test]
10674    fn the_heartbeat_round_trips_to_the_second() {
10675        let host = Host::new();
10676        let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
10677            .expect("a valid timestamp")
10678            .with_timezone(&Utc);
10679        record_github_contact(&host.paths, at).expect("a writable heartbeat");
10680        assert_eq!(
10681            last_github_contact(&host.paths).expect("readable"),
10682            Some(at)
10683        );
10684    }
10685
10686    #[test]
10687    fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
10688        let host = Host::new();
10689        std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
10690        let error = last_github_contact(&host.paths)
10691            .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
10692        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
10693    }
10694
10695    /// The surface the three-hour outage did not have.
10696    ///
10697    /// A root that refuses a launch does so before any attempt row exists, and
10698    /// the daemon's log scrubs the paths out of the sentence, so this file is
10699    /// the only place the directory and the remediation can reach the operator.
10700    #[test]
10701    fn a_runner_root_refusal_round_trips_for_service_status() {
10702        let host = Host::new();
10703        let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
10704
10705        assert!(
10706            runner_root_refusals(&host.paths)
10707                .expect("readable")
10708                .is_empty(),
10709            "no record means every policy is placing runners"
10710        );
10711
10712        record_runner_root_refusal(
10713            &host.paths,
10714            "policy-a",
10715            at,
10716            "denied_by_privacy_policy",
10717            "/Volumes/NVME/runners",
10718            "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
10719        )
10720        .expect("a writable record");
10721
10722        let refusals = runner_root_refusals(&host.paths).expect("readable");
10723        assert_eq!(refusals.len(), 1);
10724        assert_eq!(refusals[0].policy, "policy-a");
10725        assert_eq!(refusals[0].at, at);
10726        assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
10727        assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
10728        assert!(
10729            refusals[0].detail.contains("/Volumes/NVME/runners")
10730                && refusals[0].detail.contains("Full Disk Access"),
10731            "the path and the remediation are the whole point of this file: {refusals:?}"
10732        );
10733    }
10734
10735    /// The regression that would put the outage straight back.
10736    ///
10737    /// A host runs several policies and they do not share a fate. When the
10738    /// record was one host-wide slot, the policy that placed a runner deleted
10739    /// the record of the policy that could not — on the same reconcile pass —
10740    /// and `service status` reported a healthy host that had started zero
10741    /// runners for an entire target.
10742    #[test]
10743    fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
10744        let host = Host::new();
10745        let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
10746        record_runner_root_refusal(
10747            &host.paths,
10748            "broken",
10749            at,
10750            "denied_by_privacy_policy",
10751            "/Volumes/NVME/runners",
10752            "detail",
10753        )
10754        .expect("a writable record");
10755        record_runner_root_refusal(
10756            &host.paths,
10757            "also-broken",
10758            at,
10759            "not_writable",
10760            "/srv/other",
10761            "detail",
10762        )
10763        .expect("a writable record");
10764
10765        // The policy with its own working root succeeds and clears only itself.
10766        clear_runner_root_refusal(&host.paths, "healthy")
10767            .expect("clearing an absent policy is fine");
10768        clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
10769
10770        let refusals = runner_root_refusals(&host.paths).expect("readable");
10771        assert_eq!(
10772            refusals
10773                .iter()
10774                .map(|r| r.policy.as_str())
10775                .collect::<Vec<_>>(),
10776            vec!["broken"],
10777            "the policy that is still refused must keep its record"
10778        );
10779    }
10780
10781    /// Clearing is what keeps a fixed host from reporting a stale fault, and the
10782    /// file goes when the last entry does.
10783    #[test]
10784    fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
10785        let host = Host::new();
10786        record_runner_root_refusal(
10787            &host.paths,
10788            "p",
10789            Utc::now(),
10790            "not_writable",
10791            "/srv/x",
10792            "detail",
10793        )
10794        .expect("a writable record");
10795
10796        clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
10797        assert!(
10798            !root_refusal_path(&host.paths).exists(),
10799            "a host with nothing refused leaves nothing behind"
10800        );
10801        clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
10802    }
10803
10804    /// Same rule as the heartbeat: absence means "every policy is placing
10805    /// runners", so a record that cannot be parsed must not be reported as one.
10806    #[test]
10807    fn a_malformed_refusal_is_an_error_and_not_silently_none() {
10808        let host = Host::new();
10809        std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
10810        let error = runner_root_refusals(&host.paths)
10811            .expect_err("an unparseable record is not the same as no record");
10812        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
10813    }
10814
10815    /// The half of this feature that turns the file into a diagnosis, and the
10816    /// half that had no test: `service status` must actually read it.
10817    ///
10818    /// A **note** and not a problem, deliberately. `service status` runs as
10819    /// whoever typed it, must stay readable on a host with nothing installed,
10820    /// and drives an exit code -- and nothing an operator can type clears this
10821    /// record, so a problem would fail that exit code on a host whose root was
10822    /// fixed but which has had no queued job since, with a printed remedy
10823    /// (`service uninstall && service install`) that does not touch `state/`.
10824    #[test]
10825    fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
10826        let host = Host::new();
10827        record_runner_root_refusal(
10828            &host.paths,
10829            "policy-a",
10830            DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
10831            "denied_by_privacy_policy",
10832            "/Volumes/NVME/runners",
10833            "Grant Full Disk Access to the program that runs the service",
10834        )
10835        .expect("a writable record");
10836
10837        let status = host.operations().status().expect("a readable status");
10838        let notes = status.notes().join("\n");
10839
10840        assert!(
10841            notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
10842            "the directory and the remediation the log had to scrub must appear here: {notes}"
10843        );
10844        assert!(
10845            notes.contains("policy-a"),
10846            "the operator has to know which target placed no runner: {notes}"
10847        );
10848        assert!(
10849            !status
10850                .problems()
10851                .iter()
10852                .any(|problem| problem.subject == "runner root"),
10853            "a record nothing an operator types can clear must not drive the exit code"
10854        );
10855    }
10856
10857    // -----------------------------------------------------------------------
10858    // -----------------------------------------------------------------------
10859    // Install
10860    // -----------------------------------------------------------------------
10861
10862    #[test]
10863    fn install_records_the_absolute_binary_path_and_the_four_directories() {
10864        let host = Host::new();
10865        let installed = host
10866            .operations()
10867            .install(&host.request(StartMode::Boot))
10868            .expect("an install against the recording controls");
10869
10870        assert_eq!(installed.record.binary, host.binary);
10871        assert!(installed.record.binary.is_absolute());
10872        assert_eq!(installed.record.start_mode, StartMode::Boot);
10873        assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
10874        assert_eq!(
10875            installed.record.directories,
10876            ServiceDirectories::of(&host.paths)
10877        );
10878        assert_eq!(
10879            installed.record.log_file,
10880            host.paths.logs_dir().join(LOG_FILE_STEM)
10881        );
10882        assert_eq!(installed.record.restart_delay_secs, 15);
10883
10884        let registrations = host.controls.registrations();
10885        assert_eq!(registrations.len(), 1);
10886        assert_eq!(registrations[0].0, StartMode::Boot);
10887        assert_eq!(registrations[0].1, "runner-manager");
10888    }
10889
10890    #[test]
10891    fn install_is_refused_while_the_single_instance_lock_is_held() {
10892        let host = Host::new();
10893        // The discriminator: with the lock free, the same call succeeds.
10894        {
10895            let operations = host.operations();
10896            operations
10897                .install(&host.request(StartMode::Boot))
10898                .expect("an install with the lock free");
10899            operations.uninstall().expect("a clean slate");
10900        }
10901
10902        let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
10903            .expect("this process takes the lock first");
10904
10905        let error = host
10906            .operations()
10907            .install(&host.request(StartMode::Boot))
10908            .expect_err("a second agent must not be registered while one is running");
10909        assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
10910        assert!(
10911            error.to_string().contains("already running"),
10912            "the message must be actionable: {error}"
10913        );
10914        assert!(
10915            host.controls.registrations().is_empty(),
10916            "a refused install must register nothing"
10917        );
10918        assert_eq!(
10919            InstallRecord::read(&host.paths).expect("readable"),
10920            None,
10921            "a refused install must write no record"
10922        );
10923    }
10924
10925    /// Installing over the same start mode replaces the registration.
10926    ///
10927    /// It used to be refused, and the refusal is what broke a real host: the
10928    /// caller has already swapped the binary the service runs by the time this
10929    /// is reached — that is the documented way to upgrade by hand — so refusing
10930    /// left a daemon running an unregistered binary and, on macOS, one the
10931    /// stored credential's keychain grant did not name.
10932    #[test]
10933    fn installing_over_the_same_start_mode_replaces_the_registration() {
10934        let host = Host::new();
10935        let operations = host.operations();
10936        operations
10937            .install(&host.request(StartMode::Boot))
10938            .expect("the first install");
10939
10940        let again = operations
10941            .install(&host.request(StartMode::Boot))
10942            .expect("an install over the same mode replaces rather than refusing");
10943
10944        assert!(
10945            again.replaced_existing,
10946            "the operator is told this replaced something rather than made it"
10947        );
10948        assert_eq!(
10949            host.controls.registrations().len(),
10950            1,
10951            "replacing must not leave two registrations behind"
10952        );
10953        assert_eq!(
10954            InstallRecord::read(&host.paths)
10955                .expect("readable")
10956                .expect("a record")
10957                .start_mode,
10958            StartMode::Boot
10959        );
10960    }
10961
10962    /// The *other* mode is still refused, because changing it moves the
10963    /// registration between two service managers and changes the account and
10964    /// the secret store with it. `set_start_mode` is what does that.
10965    #[test]
10966    fn installing_over_the_other_start_mode_is_refused() {
10967        let host = Host::new();
10968        let operations = host.operations();
10969        operations
10970            .install(&host.request(StartMode::Boot))
10971            .expect("the first install");
10972
10973        let error = operations
10974            .install(&host.request(StartMode::Login))
10975            .expect_err("a mode change is not an install");
10976        assert!(
10977            matches!(
10978                error,
10979                ServiceError::AlreadyInstalled {
10980                    existing: StartMode::Boot,
10981                    requested: StartMode::Login,
10982                    ..
10983                }
10984            ),
10985            "{error}"
10986        );
10987        assert!(
10988            !error
10989                .to_string()
10990                .contains("switch the start mode in place,"),
10991            "the old remedy named a capability no command offers; the terminal UI is where \
10992             the start mode moves: {error}"
10993        );
10994        assert_eq!(host.controls.registrations().len(), 1);
10995    }
10996
10997    #[test]
10998    fn install_rolls_back_the_registration_when_record_persistence_fails() {
10999        let host = Host::new();
11000        let record_path = InstallRecord::path(&host.paths);
11001        std::fs::create_dir(&record_path).expect("a directory blocks the record file");
11002
11003        let error = host
11004            .operations()
11005            .install(&host.request(StartMode::Boot))
11006            .expect_err("record persistence must fail");
11007
11008        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11009        assert!(
11010            host.controls.registrations().is_empty(),
11011            "a failed install must not leave a live unrecorded registration"
11012        );
11013        assert!(
11014            host.controls
11015                .calls()
11016                .iter()
11017                .any(|call| call == "uninstall runner-manager (boot)"),
11018            "the registration must be explicitly rolled back: {:?}",
11019            host.controls.calls()
11020        );
11021        assert!(
11022            !host.runner_root.as_path().exists(),
11023            "the rollback must take the runner root this install created with it; a directory \
11024             prepared for a registration that does not exist is litter, and on Windows it is \
11025             litter with a security descriptor"
11026        );
11027    }
11028
11029    // -----------------------------------------------------------------------
11030    // The runner root (b2)
11031    // -----------------------------------------------------------------------
11032
11033    #[test]
11034    fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
11035        use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
11036
11037        // The mapping `prepare_runner_root` reads, asserted against the same
11038        // function the registration's own principal comes from — which is the
11039        // point of deriving it there rather than restating it.
11040        assert_eq!(
11041            ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
11042            ServiceAccount::LocalSystem
11043        );
11044        assert_eq!(
11045            ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
11046            ServiceAccount::InvokingUser
11047        );
11048
11049        let boot = default_root_sddl(&RootAdmission::LocalSystem);
11050        assert!(!grants_broad_write(&boot), "{boot}");
11051        assert!(
11052            !boot.contains("S-1-5-21"),
11053            "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
11054        );
11055
11056        // A login task runs under a *filtered* token — `RunLevel` is
11057        // `LeastPrivilege`, see `windows_scheduled_task_xml` — in which
11058        // Administrators is deny-only. Without an ace of its own the account
11059        // the task runs as would be admitted by nothing at all.
11060        let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
11061        assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
11062        assert!(!grants_broad_write(&login), "{login}");
11063    }
11064
11065    #[test]
11066    fn an_install_reports_the_runner_root_it_prepared() {
11067        let host = Host::new();
11068        let installed = host
11069            .operations()
11070            .install(&host.request(StartMode::Boot))
11071            .expect("an install");
11072        let rendered = installed.runner_root.to_string();
11073        assert!(
11074            !rendered.contains("S-1-5-21"),
11075            "the report must add no identity to the output: {rendered}"
11076        );
11077        if cfg!(windows) {
11078            assert_eq!(
11079                installed.runner_root.path(),
11080                Some(host.runner_root.as_path())
11081            );
11082            assert!(
11083                host.runner_root.as_path().is_dir(),
11084                "the directory jobs would run in has to exist once the service is registered"
11085            );
11086        } else {
11087            assert_eq!(
11088                installed.runner_root,
11089                crate::runner_root_access::RootAccessSummary::NotApplicable,
11090                "macOS and Linux keep the runtime directory they have always used"
11091            );
11092        }
11093    }
11094
11095    #[test]
11096    fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
11097        let host = Host::new();
11098        let operations = host.operations();
11099        operations
11100            .install(&host.request(StartMode::Boot))
11101            .expect("an install at boot");
11102
11103        let change = operations
11104            .set_start_mode(StartMode::Login)
11105            .expect("a switch to login");
11106
11107        assert!(change.changed);
11108        if cfg!(windows) {
11109            assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
11110            assert!(
11111                host.runner_root.as_path().is_dir(),
11112                "the switch must not remove the directory it reconciled"
11113            );
11114        }
11115        // The operator is told, because the account that may write there has
11116        // moved with the mode.
11117        assert!(
11118            change.to_string().contains("runner root"),
11119            "{}",
11120            change.to_string()
11121        );
11122    }
11123
11124    #[test]
11125    fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
11126        let host = Host::new();
11127        let operations = host.operations();
11128        operations
11129            .install(&host.request(StartMode::Boot))
11130            .expect("an install at boot");
11131
11132        let change = operations
11133            .set_start_mode(StartMode::Boot)
11134            .expect("a switch to the mode already in force");
11135
11136        assert!(!change.changed);
11137        assert_eq!(
11138            change.runner_root,
11139            crate::runner_root_access::RootAccessSummary::NotApplicable,
11140            "nothing moves, so nothing about the root's access control has to; reconciling here \
11141             would turn a no-op command into one that can fail on a permission it does not need"
11142        );
11143    }
11144
11145    #[cfg(windows)]
11146    #[test]
11147    fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
11148        let host = Host::new();
11149        host.controls
11150            .fail_next_install(StartMode::Boot, "injected registration failure");
11151
11152        let error = host
11153            .operations()
11154            .install(&host.request(StartMode::Boot))
11155            .expect_err("the manager refuses the registration");
11156
11157        assert!(matches!(error, ServiceError::Control { .. }), "{error}");
11158        assert!(
11159            !host.runner_root.as_path().exists(),
11160            "the directory was created for a registration that does not exist"
11161        );
11162    }
11163
11164    #[cfg(windows)]
11165    #[test]
11166    fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
11167        let host = Host::new();
11168        // What a directory created below `C:\` with inheritance left on looks
11169        // like, built deliberately because a per-account `%TEMP%` never
11170        // produces one by accident.
11171        crate::runner_root_access::create_with_descriptor_for_tests(
11172            host.runner_root.as_path(),
11173            "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
11174        )
11175        .expect("a deliberately open runner root");
11176        let before = crate::runner_root_access::report(host.runner_root.as_path());
11177
11178        let error = host
11179            .operations()
11180            .install(&host.request(StartMode::Boot))
11181            .expect_err("an open runner root is refused");
11182
11183        assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
11184        assert!(
11185            error.to_string().contains("nothing was registered"),
11186            "{error}"
11187        );
11188        assert!(
11189            host.controls.registrations().is_empty(),
11190            "the refusal has to come before the platform is asked to register anything: {:?}",
11191            host.controls.calls()
11192        );
11193        assert_eq!(
11194            crate::runner_root_access::report(host.runner_root.as_path()),
11195            before,
11196            "an open directory is refused rather than tightened: its contents cannot be trusted, \
11197             so adopting it would be worse than declining it"
11198        );
11199    }
11200
11201    #[cfg(windows)]
11202    #[test]
11203    fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
11204        let host = Host::new();
11205        let operations = host.operations();
11206        operations
11207            .install(&host.request(StartMode::Boot))
11208            .expect("an install");
11209        assert!(host.runner_root.as_path().is_dir());
11210
11211        operations.uninstall().expect("an uninstall");
11212
11213        assert!(
11214            host.runner_root.as_path().is_dir(),
11215            "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
11216             runner root may hold an operator's retained workspaces."
11217        );
11218    }
11219
11220    #[test]
11221    fn install_reviews_what_it_registered() {
11222        let host = Host::new();
11223        let installed = host
11224            .operations()
11225            .install(&host.request(StartMode::Boot))
11226            .expect("an install");
11227        assert!(
11228            installed.review.is_least_privilege(),
11229            "{}",
11230            installed.review
11231        );
11232        assert!(
11233            !installed.review.controls().is_empty(),
11234            "a review that confirms nothing proves nothing: {}",
11235            installed.review
11236        );
11237        assert_eq!(
11238            installed.review.kind(),
11239            host_definition_kind(StartMode::Boot),
11240            "the review must be of the definition this host's manager was given"
11241        );
11242        assert!(
11243            !installed.review.account().justification().is_empty(),
11244            "a privileged account with no stated reason is an unreviewed one"
11245        );
11246    }
11247
11248    // -----------------------------------------------------------------------
11249    // Uninstall deletes nothing else
11250    // -----------------------------------------------------------------------
11251
11252    #[test]
11253    fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
11254        let host = Host::new();
11255        let operations = host.operations();
11256        operations
11257            .install(&host.request(StartMode::Boot))
11258            .expect("an install");
11259
11260        // The files `05-infrastructure.md` says must survive. Written *after*
11261        // the install so that the snapshot below is of a host in the state an
11262        // operator's would be in.
11263        let config = host.paths.config_dir();
11264        std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
11265        std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
11266        std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
11267        std::fs::write(
11268            host.paths
11269                .state_dir()
11270                .join("packages/2.330.0/runner.tar.gz"),
11271            b"cached package",
11272        )
11273        .expect("writable");
11274        std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
11275        std::fs::write(
11276            host.paths.state_dir().join("secrets/user-access-token"),
11277            b"a stand-in for the stored credential",
11278        )
11279        .expect("writable");
11280        std::fs::write(
11281            host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
11282            b"diagnostics",
11283        )
11284        .expect("writable");
11285
11286        let roots: Vec<PathBuf> = host
11287            .paths
11288            .all()
11289            .iter()
11290            .map(|(_, path)| (*path).to_path_buf())
11291            .collect();
11292        let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
11293        let before = snapshot(&roots);
11294
11295        // Non-vacuity: a comparison of two empty maps would pass whatever
11296        // `uninstall` did.
11297        assert!(
11298            before.len() >= 6,
11299            "the fixture must actually contain the files this test is about, got {before:#?}"
11300        );
11301        let record_path = InstallRecord::path(&host.paths);
11302        assert!(
11303            before.contains_key(&record_path),
11304            "the install record must be present before uninstall"
11305        );
11306
11307        let uninstalled = operations.uninstall().expect("an uninstall");
11308        assert!(uninstalled.removed_registration);
11309        assert!(uninstalled.removed_record);
11310
11311        let after = snapshot(&roots);
11312
11313        // Exactly one thing changed, and it is the registration's own record.
11314        let mut expected = before.clone();
11315        expected.remove(&record_path);
11316        assert_eq!(
11317            after, expected,
11318            "uninstall must remove its own record and nothing else"
11319        );
11320        assert!(
11321            !record_path.exists(),
11322            "the record itself must go, or `uninstall` did nothing at all"
11323        );
11324        assert!(
11325            uninstalled
11326                .preserved
11327                .iter()
11328                .all(|path| roots.contains(&path.as_path())),
11329            "the preserved list must name the four directories: {uninstalled}"
11330        );
11331    }
11332
11333    #[test]
11334    fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
11335        let host = Host::new();
11336        let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
11337        assert!(!uninstalled.removed_registration);
11338        assert!(!uninstalled.removed_record);
11339    }
11340
11341    #[test]
11342    fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
11343        let host = Host::new();
11344        let operations = host.operations();
11345        operations
11346            .install(&host.request(StartMode::Boot))
11347            .expect("an install");
11348        std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
11349
11350        let uninstalled = operations.uninstall().expect("an uninstall");
11351        assert!(
11352            uninstalled.removed_registration,
11353            "a lost record must not strand a registration"
11354        );
11355        assert!(host.controls.registrations().is_empty());
11356    }
11357
11358    // -----------------------------------------------------------------------
11359    // Switching start mode
11360    // -----------------------------------------------------------------------
11361
11362    #[test]
11363    fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
11364        let host = Host::new();
11365        let operations = host.operations();
11366        operations
11367            .install(&host.request(StartMode::Boot))
11368            .expect("an install at boot");
11369
11370        // The discriminator. If the switch re-resolved the binary the way
11371        // `install` does, it would either fail here or silently record the test
11372        // binary instead. Removing the file makes the difference visible.
11373        std::fs::remove_file(&host.binary).expect("the installed binary goes away");
11374
11375        let change = operations
11376            .set_start_mode(StartMode::Login)
11377            .expect("a switch that does not reinstall the product");
11378        assert!(change.changed);
11379        assert_eq!(change.from, StartMode::Boot);
11380        assert_eq!(change.to, StartMode::Login);
11381        assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
11382
11383        let record = InstallRecord::read(&host.paths)
11384            .expect("readable")
11385            .expect("a record");
11386        assert_eq!(record.start_mode, StartMode::Login);
11387        assert_eq!(
11388            record.binary, host.binary,
11389            "the recorded path must survive the switch untouched"
11390        );
11391
11392        let registrations = host.controls.registrations();
11393        assert_eq!(registrations.len(), 1, "{registrations:?}");
11394        assert_eq!(registrations[0].0, StartMode::Login);
11395        assert!(
11396            registrations[0]
11397                .2
11398                .command_line
11399                .contains(&host.binary.to_string_lossy().into_owned()),
11400            "{:?}",
11401            registrations[0].2
11402        );
11403    }
11404
11405    #[test]
11406    fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
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        let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
11413        host.controls
11414            .fail_next_install(StartMode::Login, "injected target failure");
11415
11416        let error = operations
11417            .set_start_mode(StartMode::Login)
11418            .expect_err("the target manager refuses the install");
11419
11420        assert!(matches!(error, ServiceError::Control { .. }), "{error}");
11421        assert_eq!(
11422            std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
11423            record_before
11424        );
11425        let registrations = host.controls.registrations();
11426        assert_eq!(registrations.len(), 1, "{registrations:?}");
11427        assert_eq!(registrations[0].0, StartMode::Boot);
11428    }
11429
11430    #[test]
11431    fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
11432        let host = Host::new();
11433        let operations = host.operations();
11434        operations
11435            .install(&host.request(StartMode::Boot))
11436            .expect("an install at boot");
11437        let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
11438        let config = host.paths.config_dir().to_path_buf();
11439        let hidden = config.with_file_name("config-hidden-by-fault");
11440        host.controls.hide_directory_after_install(
11441            StartMode::Login,
11442            config.clone(),
11443            hidden.clone(),
11444        );
11445
11446        let error = operations
11447            .set_start_mode(StartMode::Login)
11448            .expect_err("the injected filesystem fault prevents persistence");
11449
11450        std::fs::remove_file(&config).expect("remove the injected blocker");
11451        std::fs::rename(&hidden, &config).expect("restore the record directory");
11452        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11453        assert_eq!(
11454            std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
11455            record_before
11456        );
11457        let registrations = host.controls.registrations();
11458        assert_eq!(registrations.len(), 1, "{registrations:?}");
11459        assert_eq!(registrations[0].0, StartMode::Boot);
11460        assert!(
11461            host.controls
11462                .calls()
11463                .iter()
11464                .any(|call| call == "uninstall runner-manager (login)"),
11465            "the target must be rolled back: {:?}",
11466            host.controls.calls()
11467        );
11468    }
11469
11470    #[test]
11471    fn switching_to_the_mode_already_in_force_registers_nothing_again() {
11472        let host = Host::new();
11473        let operations = host.operations();
11474        operations
11475            .install(&host.request(StartMode::Boot))
11476            .expect("an install");
11477        let before = host.controls.calls().len();
11478
11479        let change = operations
11480            .set_start_mode(StartMode::Boot)
11481            .expect("a no-op switch");
11482        assert!(!change.changed);
11483        assert_eq!(
11484            host.controls.calls().len(),
11485            before,
11486            "a no-op switch must not touch the service manager"
11487        );
11488    }
11489
11490    #[test]
11491    fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
11492        let host = Host::new();
11493        let error = host
11494            .operations()
11495            .set_start_mode(StartMode::Login)
11496            .expect_err("there is nothing to switch");
11497        assert!(
11498            matches!(error, ServiceError::NotInstalled { .. }),
11499            "{error}"
11500        );
11501    }
11502
11503    // -----------------------------------------------------------------------
11504    // Status
11505    // -----------------------------------------------------------------------
11506
11507    #[test]
11508    fn status_reports_the_four_facts_journey_five_asks_for() {
11509        let host = Host::new();
11510        let operations = host.operations();
11511        operations
11512            .install(&host.request(StartMode::Boot))
11513            .expect("an install");
11514        let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
11515            .expect("a valid timestamp")
11516            .with_timezone(&Utc);
11517        record_github_contact(&host.paths, at).expect("a heartbeat");
11518
11519        let status = operations.status().expect("a status");
11520        assert_eq!(status.start_mode(), Some(StartMode::Boot));
11521        assert_eq!(
11522            status.binary().map(BinaryPath::recorded),
11523            Some(host.binary.as_path())
11524        );
11525        assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
11526        assert_eq!(status.last_github_contact(), Some(at));
11527        assert!(status.is_installed());
11528        assert!(status.is_healthy(), "{status}");
11529
11530        let printed = status.to_string();
11531        for fragment in [
11532            "start mode",
11533            "diagnostic log",
11534            "last GitHub contact",
11535            "binary",
11536        ] {
11537            assert!(printed.contains(fragment), "{printed}");
11538        }
11539    }
11540
11541    /// A record this account may not read is reported, and `status` keeps
11542    /// going.
11543    ///
11544    /// It used to end the command with `Permission denied`, which is exactly
11545    /// what an operator met on a boot-mode host: `sudo service install` wrote
11546    /// the record `0600` and `root`-owned, and `service status` — a command
11547    /// that asks no privilege of anybody — then printed nothing at all about a
11548    /// registration the service manager could describe perfectly well.
11549    ///
11550    /// Ownership cannot be reproduced without privileges; the mode that made it
11551    /// fatal can.
11552    #[cfg(unix)]
11553    #[test]
11554    fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
11555        use std::os::unix::fs::PermissionsExt as _;
11556
11557        // `root` is not subject to the mode bits and would read the record
11558        // regardless, leaving the assertions below testing nothing.
11559        // SAFETY: `geteuid` takes no argument and cannot fail.
11560        if unsafe { libc::geteuid() } == 0 {
11561            return;
11562        }
11563
11564        let host = Host::new();
11565        let operations = host.operations();
11566        operations
11567            .install(&host.request(StartMode::Boot))
11568            .expect("an install");
11569        assert!(
11570            operations.status().expect("a status").is_healthy(),
11571            "the discriminator: healthy before the record is made unreadable"
11572        );
11573
11574        let record = InstallRecord::path(&host.paths);
11575        std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
11576            .expect("the mode is applied");
11577
11578        let status = operations
11579            .status()
11580            .expect("a record this account may not read is reported, not thrown");
11581        assert!(status.is_installed(), "{status}");
11582        assert!(!status.is_healthy(), "{status}");
11583
11584        let printed = status.to_string();
11585        assert!(
11586            printed.contains("this account may not read it"),
11587            "the operator is told which of the two states this is: {printed}"
11588        );
11589        assert!(
11590            !printed.contains("there is no install record"),
11591            "a record that is there and unreadable is not a record that is missing, and the \
11592             missing one's remedy starts with `service uninstall`: {printed}"
11593        );
11594
11595        // Left readable so the fixture's own cleanup is not the thing that
11596        // fails on a host where TempDir removal walks the tree.
11597        std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
11598            .expect("the mode is restored");
11599    }
11600
11601    #[test]
11602    fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
11603        let host = Host::new();
11604        let operations = host.operations();
11605        operations
11606            .install(&host.request(StartMode::Boot))
11607            .expect("an install");
11608
11609        // The discriminator: healthy first, so a `is_healthy` that always
11610        // returned false could not pass this test.
11611        assert!(
11612            operations.status().expect("a status").is_healthy(),
11613            "the freshly installed host must be healthy"
11614        );
11615
11616        std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
11617
11618        let status = operations.status().expect("a status");
11619        assert!(!status.is_healthy(), "{status}");
11620        assert!(
11621            status
11622                .problems()
11623                .iter()
11624                .any(|problem| problem.subject == "binary"),
11625            "{status}"
11626        );
11627        assert!(status.to_string().contains("STALE"), "{status}");
11628    }
11629
11630    #[test]
11631    fn status_reports_a_registration_that_would_not_start_at_boot() {
11632        let host = Host::new();
11633        let operations = host.operations();
11634        operations
11635            .install(&host.request(StartMode::Boot))
11636            .expect("an install");
11637        assert!(operations.status().expect("a status").is_healthy());
11638
11639        host.controls.edit("runner-manager", |registration| {
11640            registration.starts_automatically = false;
11641        });
11642
11643        let status = operations.status().expect("a status");
11644        assert!(!status.is_healthy(), "{status}");
11645        assert!(
11646            status
11647                .problems()
11648                .iter()
11649                .any(|problem| problem.detail.contains("after a reboot")),
11650            "{status}"
11651        );
11652    }
11653
11654    #[test]
11655    fn install_starts_the_registration_and_a_later_stop_is_unhealthy() {
11656        let host = Host::new();
11657        let operations = host.operations();
11658        operations
11659            .install(&host.request(StartMode::Login))
11660            .expect("install and immediate start");
11661
11662        assert!(
11663            operations.status().expect("running status").is_running(),
11664            "install must not wait for the next login trigger"
11665        );
11666        operations.stop().expect("stop the registration");
11667        let status = operations.status().expect("stopped status");
11668        assert!(!status.is_healthy(), "{status}");
11669        assert!(
11670            status
11671                .problems()
11672                .iter()
11673                .any(|problem| problem.subject == "runtime"),
11674            "{status}"
11675        );
11676        let rendered = status.to_string();
11677        assert!(
11678            rendered.contains("runner-manager service install --start-at login"),
11679            "{rendered}"
11680        );
11681        assert!(
11682            !rendered.contains("runner-manager service start"),
11683            "{rendered}"
11684        );
11685    }
11686
11687    #[test]
11688    fn status_reports_a_restart_policy_something_else_edited() {
11689        let host = Host::new();
11690        let operations = host.operations();
11691        operations
11692            .install(&host.request(StartMode::Boot))
11693            .expect("an install");
11694        assert!(operations.status().expect("a status").is_healthy());
11695
11696        host.controls.edit("runner-manager", |registration| {
11697            registration.restart_delay = Some(Duration::from_secs(1));
11698        });
11699
11700        let status = operations.status().expect("a status");
11701        assert!(!status.is_healthy(), "{status}");
11702        assert!(
11703            status
11704                .problems()
11705                .iter()
11706                .any(|problem| problem.subject == "restart policy"),
11707            "{status}"
11708        );
11709    }
11710
11711    #[test]
11712    fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
11713        let host = Host::new();
11714        let operations = host.operations();
11715        operations
11716            .install(&host.request(StartMode::Boot))
11717            .expect("an install");
11718        let other = host.binary.with_file_name("someone-elses.exe");
11719        std::fs::write(&other, b"x").expect("writable");
11720
11721        host.controls.edit("runner-manager", |registration| {
11722            registration.command_line = quote_argument(&other.to_string_lossy());
11723        });
11724
11725        let status = operations.status().expect("a status");
11726        assert!(!status.is_healthy(), "{status}");
11727        assert!(
11728            matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
11729            "{status}"
11730        );
11731    }
11732
11733    #[test]
11734    fn status_reports_a_record_no_service_manager_knows_about() {
11735        let host = Host::new();
11736        let operations = host.operations();
11737        operations
11738            .install(&host.request(StartMode::Boot))
11739            .expect("an install");
11740        // Something removed the registration behind this product's back.
11741        for mode in [StartMode::Boot, StartMode::Login] {
11742            host.controls
11743                .control(mode)
11744                .expect("a control")
11745                .uninstall(&ServiceIdentity::product())
11746                .expect("removed");
11747        }
11748
11749        let status = operations.status().expect("a status");
11750        assert!(!status.is_healthy(), "{status}");
11751        assert!(
11752            status
11753                .problems()
11754                .iter()
11755                .any(|problem| problem.subject == "registration"),
11756            "{status}"
11757        );
11758    }
11759
11760    #[test]
11761    fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
11762        let host = Host::new();
11763        let status = host.operations().status().expect("a status");
11764        assert!(!status.is_installed());
11765        assert!(
11766            status.is_healthy(),
11767            "a host that never installed the service has no fault to report: {status}"
11768        );
11769        assert!(status.to_string().contains("installed"), "{status}");
11770    }
11771
11772    #[test]
11773    fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
11774        let host = Host::new();
11775        let operations = host.operations();
11776        operations
11777            .install(&host.request(StartMode::Login))
11778            .expect("an install at login");
11779        let status = operations.status().expect("a status");
11780        assert!(
11781            status
11782                .notes()
11783                .iter()
11784                .any(|note| note.contains("does not run until the operator signs in")),
11785            "05-infrastructure.md requires `service status` to say so: {status}"
11786        );
11787    }
11788
11789    /// A Mac that signs its runner account in automatically does resume, and
11790    /// `service status` used to tell it otherwise.
11791    #[test]
11792    fn a_mac_with_automatic_login_as_the_service_account_resumes_after_a_reboot() {
11793        use crate::unattended_login::{AutoLogin, UnattendedLogin};
11794        let automatic = UnattendedLogin {
11795            auto_login: AutoLogin::As("ivan".into()),
11796            filevault_on: Some(false),
11797        };
11798        assert_eq!(
11799            unattended_gap(StartMode::Login, "ivan", Some(&automatic)),
11800            None
11801        );
11802
11803        let off = UnattendedLogin {
11804            auto_login: AutoLogin::Off,
11805            filevault_on: Some(false),
11806        };
11807        let gap = unattended_gap(StartMode::Login, "ivan", Some(&off)).expect("a gap");
11808        assert!(gap.reason.contains("automatic login is off"), "{gap:?}");
11809        assert!(gap.reason.contains("Automatically log in as"), "{gap:?}");
11810
11811        let locked = UnattendedLogin {
11812            auto_login: AutoLogin::As("ivan".into()),
11813            filevault_on: Some(true),
11814        };
11815        let gap = unattended_gap(StartMode::Boot, "root", Some(&locked)).expect("a gap");
11816        assert!(gap.reason.contains("FileVault is on"), "{gap:?}");
11817
11818        // An unanswered probe is no gap: `host doctor` says what it could not
11819        // read.
11820        let unread = UnattendedLogin {
11821            auto_login: AutoLogin::As("ivan".into()),
11822            filevault_on: None,
11823        };
11824        assert_eq!(
11825            unattended_gap(StartMode::Login, "ivan", Some(&unread)),
11826            None
11827        );
11828
11829        // Where nothing can be probed, a login registration still says so.
11830        let gap = unattended_gap(StartMode::Login, "ivan", None).expect("a gap");
11831        assert!(
11832            gap.reason
11833                .contains("does not run until the operator signs in")
11834        );
11835        assert_eq!(unattended_gap(StartMode::Boot, "root", None), None);
11836    }
11837
11838    /// A daemon blocked in a file system call is still "running" to the
11839    /// service manager; its heartbeat is what says it is stuck.
11840    #[test]
11841    fn a_daemon_that_stopped_beating_makes_the_service_unhealthy() {
11842        let host = Host::new();
11843        let operations = host.operations();
11844        operations
11845            .install(&host.request(StartMode::Login))
11846            .expect("an install at login");
11847        let stalled_problem = |operations: &ServiceOperations| {
11848            operations
11849                .status()
11850                .expect("a status")
11851                .problems()
11852                .iter()
11853                .find(|problem| problem.subject == DAEMON_STALLED_SUBJECT)
11854                .map(|problem| problem.detail.clone())
11855        };
11856        crate::daemon_heartbeat::beat(&operations.paths, Utc::now()).unwrap();
11857        assert_eq!(stalled_problem(&operations), None, "beating");
11858
11859        crate::daemon_heartbeat::beat(
11860            &operations.paths,
11861            Utc::now() - chrono::Duration::minutes(20),
11862        )
11863        .unwrap();
11864        let detail = stalled_problem(&operations).expect("a stall is a problem");
11865        assert!(detail.contains("has made no progress since"), "{detail}");
11866        // The restart is the service manager's command for this login-mode
11867        // registration: the product has no `service stop` or `service start`.
11868        let restart = ServiceIdentity::product().restart_command(StartMode::Login);
11869        assert!(detail.contains(&restart), "{detail}");
11870        assert!(!detail.contains("runner-manager service stop"), "{detail}");
11871        assert!(!detail.contains("runner-manager service start"), "{detail}");
11872    }
11873
11874    /// Each platform's restart is its service manager's own command for the
11875    /// registration's start mode, never a `runner-manager service` subcommand
11876    /// that does not exist.
11877    #[test]
11878    fn the_restart_command_is_the_service_managers_own() {
11879        let identity = ServiceIdentity::product();
11880        let boot = identity.restart_command(StartMode::Boot);
11881        let login = identity.restart_command(StartMode::Login);
11882        for command in [&boot, &login] {
11883            assert!(!command.contains("runner-manager service"), "{command}");
11884            assert!(command.contains(identity.name()), "{command}");
11885        }
11886        assert_ne!(boot, login);
11887        if cfg!(windows) {
11888            assert!(
11889                boot.starts_with("Restart-Service -Name runner-manager"),
11890                "{boot}"
11891            );
11892            assert!(
11893                login.contains("schtasks /Run /TN runner-manager"),
11894                "{login}"
11895            );
11896        } else if cfg!(target_os = "macos") {
11897            assert!(
11898                boot.starts_with("sudo launchctl kickstart -k system/"),
11899                "{boot}"
11900            );
11901            assert!(login.starts_with("launchctl kickstart -k gui/"), "{login}");
11902        } else {
11903            assert_eq!(boot, "sudo systemctl restart runner-manager.service");
11904            assert_eq!(login, "systemctl --user restart runner-manager.service");
11905        }
11906    }
11907
11908    /// A daemon refusing every runner is not a healthy service.
11909    #[test]
11910    fn a_daemon_that_finds_the_host_unfit_makes_the_service_unhealthy() {
11911        let host = Host::new();
11912        let operations = host.operations();
11913        operations
11914            .install(&host.request(StartMode::Login))
11915            .expect("an install at login");
11916        let paths = operations.paths.clone();
11917        crate::host_fitness::record_host_unfit(
11918            &paths,
11919            &[crate::host_fitness::UnfitFinding {
11920                id: "host.runner_root_responsive".into(),
11921                detail: "macOS has not let the service list /Volumes/NVME/runners".into(),
11922                remedy: Some("click Allow".into()),
11923            }],
11924            Utc::now(),
11925        )
11926        .unwrap();
11927        let status = operations.status().expect("a status");
11928        assert!(!status.is_healthy(), "{status}");
11929        let problem = status
11930            .problems()
11931            .iter()
11932            .find(|problem| problem.subject == HOST_UNFIT_SUBJECT)
11933            .expect("the refusal is a problem");
11934        assert!(
11935            problem.detail.contains("/Volumes/NVME/runners"),
11936            "{problem:?}"
11937        );
11938        assert!(problem.detail.contains("Fix: click Allow"), "{problem:?}");
11939
11940        // A record nobody has re-stamped for a while is a stopped daemon's,
11941        // not a refusal in force.
11942        crate::host_fitness::clear_host_unfit(&paths).unwrap();
11943        crate::host_fitness::record_host_unfit(
11944            &paths,
11945            &[],
11946            Utc::now() - chrono::Duration::hours(2),
11947        )
11948        .unwrap();
11949        let status = operations.status().expect("a status");
11950        assert!(
11951            !status
11952                .problems()
11953                .iter()
11954                .any(|problem| problem.subject == HOST_UNFIT_SUBJECT),
11955            "{status}"
11956        );
11957    }
11958
11959    // -----------------------------------------------------------------------
11960    // Start and stop
11961    // -----------------------------------------------------------------------
11962
11963    #[test]
11964    fn start_and_stop_reach_the_domain_that_holds_the_registration() {
11965        let host = Host::new();
11966        let operations = host.operations();
11967        operations
11968            .install(&host.request(StartMode::Login))
11969            .expect("an install at login");
11970        operations.start().expect("a start");
11971        assert!(operations.status().expect("a status").is_running());
11972        assert!(operations.stop().expect("a stop"));
11973        assert!(!operations.status().expect("a status").is_running());
11974    }
11975
11976    #[test]
11977    fn starting_a_host_with_no_registration_is_refused() {
11978        let host = Host::new();
11979        let error = host.operations().start().expect_err("nothing to start");
11980        assert!(
11981            matches!(error, ServiceError::NotInstalled { .. }),
11982            "{error}"
11983        );
11984    }
11985
11986    // -----------------------------------------------------------------------
11987    // The seam with `d2`
11988    // -----------------------------------------------------------------------
11989
11990    /// Requirement 2 is *"an account that can **read the secret store**"*, and
11991    /// on Windows what an account may read is decided by a DACL in a file this
11992    /// task does not own. Asserting the account name alone would be asserting
11993    /// this module against itself; this reads `d2`'s protection back off a real
11994    /// store and checks that it admits the account this installer registers.
11995    ///
11996    /// No privileges are needed. `d2` documents that a rooted machine-scoped
11997    /// store *"is protected and encrypted exactly as the standard one is"* —
11998    /// the Windows backend picks its DACL from the scope, not from the site —
11999    /// so a store in a temporary directory carries the access control the real
12000    /// one does.
12001    #[cfg(windows)]
12002    #[test]
12003    fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
12004        use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
12005
12006        let root = tempfile::tempdir().expect("a temporary directory");
12007        let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
12008            .expect("a rooted machine-scoped store");
12009        store
12010            .store(&secrecy::SecretString::from("a stand-in for the token"))
12011            .expect("the store accepts a value");
12012        let protection = store.protection().expect("the DACL can be read back");
12013
12014        assert!(
12015            protection.description().contains(";;;SY)"),
12016            "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
12017             read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
12018            protection.description()
12019        );
12020        assert_eq!(
12021            ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
12022            ServiceAccount::LocalSystem,
12023            "and that is the account this installer registers, which is why SY is what matters"
12024        );
12025        assert!(
12026            !protection.readable_by_other_local_users(),
12027            "the same DACL must still exclude ordinary local users: {}",
12028            protection.description()
12029        );
12030
12031        // The other half of the analysis in `docs/service-account.md`, and the
12032        // reason LocalService and NetworkService are not used: the DACL names
12033        // three trustees and neither of them is among them.
12034        for rejected in [";;;LS)", ";;;NS)"] {
12035            assert!(
12036                !protection.description().contains(rejected),
12037                "if the store ever admitted {rejected}, the least-privilege analysis in \
12038                 docs/service-account.md would need redoing: {}",
12039                protection.description()
12040            );
12041        }
12042    }
12043
12044    // -- another installation's service ---------------------------------------
12045
12046    /// A request that records `host`'s own four directories on the command
12047    /// line, the way `service install` registers one.
12048    fn request_with_directories(host: &Host, mode: StartMode) -> InstallRequest {
12049        let mut arguments: Vec<OsString> = DAEMON_ARGUMENTS.iter().map(OsString::from).collect();
12050        arguments.extend(ServiceDirectories::of(&host.paths).arguments());
12051        host.request(mode).with_arguments(arguments)
12052    }
12053
12054    /// The operations a second account on the same machine gets: its own
12055    /// directories, and the machine's one service manager.
12056    fn second_account(account: &Host, machine: &Host) -> ServiceOperations {
12057        ServiceOperations::with_controls(
12058            account.paths.clone(),
12059            ServiceIdentity::product(),
12060            std::sync::Arc::new(machine.controls.clone()),
12061        )
12062        .with_runner_root(account.runner_root.clone())
12063    }
12064
12065    /// The owner's boot service, and a second account holding the record of
12066    /// an earlier login install of its own -- the layout an owner reported on
12067    /// a Windows host with two local accounts.
12068    fn a_machine_with_a_stale_second_account() -> (Host, Host) {
12069        let owner = Host::new();
12070        owner
12071            .operations()
12072            .install(&request_with_directories(&owner, StartMode::Boot))
12073            .expect("the owner's boot install");
12074        let earlier = Host::new();
12075        earlier
12076            .operations()
12077            .install(&request_with_directories(&earlier, StartMode::Login))
12078            .expect("the second account's earlier login install");
12079        assert!(
12080            InstallRecord::read(&earlier.paths).unwrap().is_some(),
12081            "the earlier install left its record behind"
12082        );
12083        (owner, earlier)
12084    }
12085
12086    /// The advice that, followed from the second account, breaks or replaces
12087    /// the owner's healthy service.
12088    const DESTRUCTIVE_ADVICE: [&str; 4] = [
12089        "service uninstall",
12090        "service install`",
12091        "service install --",
12092        "auth login",
12093    ];
12094
12095    #[test]
12096    fn a_service_installed_from_another_data_root_is_reported_as_that_installations() {
12097        let (owner, earlier) = a_machine_with_a_stale_second_account();
12098
12099        let status = second_account(&earlier, &owner).status().expect("a status");
12100
12101        let foreign = status.foreign().expect("the service is the owner's");
12102        assert_eq!(foreign.directories, ServiceDirectories::of(&owner.paths));
12103        assert_eq!(foreign.root, ServiceDirectories::of(&owner.paths).root());
12104        assert!(foreign.unreadable.is_none(), "{status}");
12105        assert!(
12106            status.is_healthy(),
12107            "the owner's service is healthy, and a stale record elsewhere is not its fault: \
12108             {status}"
12109        );
12110        for subject in [
12111            "binary",
12112            "start mode",
12113            "secret store",
12114            "record",
12115            "registration",
12116        ] {
12117            assert!(
12118                !status
12119                    .problems()
12120                    .iter()
12121                    .any(|problem| problem.subject == subject),
12122                "{subject} compared the second account's record against the owner's \
12123                 registration: {status}"
12124            );
12125        }
12126        let rendered = status.to_string();
12127        for advice in DESTRUCTIVE_ADVICE {
12128            assert!(!rendered.contains(advice), "{advice}: {rendered}");
12129        }
12130        assert!(rendered.contains("another installation's"), "{rendered}");
12131        assert!(
12132            status
12133                .notes()
12134                .iter()
12135                .any(|note| note.contains("unused local configuration")
12136                    && note.contains("host forget-local")),
12137            "{status}"
12138        );
12139        // What the owner recorded, not what the second account did.
12140        assert_eq!(status.start_mode(), Some(StartMode::Boot));
12141    }
12142
12143    #[test]
12144    fn a_foreign_service_whose_records_cannot_be_read_reports_only_what_the_manager_says() {
12145        let (owner, earlier) = a_machine_with_a_stale_second_account();
12146        // Stands in for a profile this account may not open: there is nothing
12147        // to read where the registration points.
12148        InstallRecord::remove(&owner.paths).expect("the owner's record removed");
12149
12150        let operations = second_account(&earlier, &owner);
12151        let status = operations.status().expect("a status");
12152        let foreign = status.foreign().expect("the service is the owner's");
12153        assert!(foreign.unreadable.is_some(), "{status}");
12154        assert!(
12155            status.is_healthy(),
12156            "running, and nothing it could judge: {status}"
12157        );
12158        let rendered = status.to_string();
12159        for advice in DESTRUCTIVE_ADVICE {
12160            assert!(!rendered.contains(advice), "{advice}: {rendered}");
12161        }
12162        assert!(rendered.contains("running; installed from"), "{rendered}");
12163        assert!(!rendered.contains("last GitHub contact"), "{rendered}");
12164
12165        // Stopped, it is a problem -- with a place to look, not a reinstall.
12166        owner.operations().stop().expect("stopped");
12167        let stopped = operations.status().expect("a status");
12168        assert!(!stopped.is_healthy(), "{stopped}");
12169        let rendered = stopped.to_string();
12170        for advice in DESTRUCTIVE_ADVICE {
12171            assert!(!rendered.contains(advice), "{advice}: {rendered}");
12172        }
12173    }
12174
12175    #[test]
12176    fn this_accounts_own_registration_still_reports_every_mismatch() {
12177        let host = Host::new();
12178        let operations = host.operations();
12179        operations
12180            .install(&request_with_directories(&host, StartMode::Boot))
12181            .expect("an install");
12182        let other = host.binary.with_file_name("someone-elses");
12183        std::fs::write(&other, b"x").expect("writable");
12184        // Something edited the registration, keeping its directories.
12185        host.controls.edit("runner-manager", |registration| {
12186            let mut arguments = split_command_line(&registration.command_line);
12187            arguments[0] = other.to_string_lossy().into_owned();
12188            registration.command_line = arguments
12189                .iter()
12190                .map(|argument| quote_argument(argument))
12191                .collect::<Vec<_>>()
12192                .join(" ");
12193            registration.start_mode = StartMode::Login;
12194        });
12195
12196        let status = operations.status().expect("a status");
12197        assert!(status.foreign().is_none(), "{status}");
12198        assert!(
12199            matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
12200            "{status}"
12201        );
12202        assert!(
12203            status
12204                .problems()
12205                .iter()
12206                .any(|problem| problem.subject == "start mode"),
12207            "{status}"
12208        );
12209        assert!(status.to_string().contains("service uninstall"), "{status}");
12210    }
12211
12212    #[test]
12213    fn directories_read_back_from_a_command_line_with_spaces() {
12214        let root = Path::new("/Users/someone/Library/Application Support/runner manager");
12215        let directories = ServiceDirectories {
12216            config: root.join("config"),
12217            state: root.join("data").join("state"),
12218            runtime: root.join("data").join("runtime"),
12219            logs: root.join("data").join("logs"),
12220        };
12221        let mut command_line = quote_argument("/usr/local/bin/runner-manager");
12222        for argument in ["daemon", "run"]
12223            .map(OsString::from)
12224            .into_iter()
12225            .chain(directories.arguments())
12226        {
12227            command_line.push(' ');
12228            command_line.push_str(&quote_argument(&argument.to_string_lossy()));
12229        }
12230        assert_eq!(
12231            ServiceDirectories::from_command_line(&command_line),
12232            Some(directories.clone())
12233        );
12234        assert_eq!(directories.root(), root);
12235        assert_eq!(
12236            ServiceDirectories::from_command_line("runner-manager daemon run"),
12237            None,
12238            "a registration that names no directories cannot be judged foreign"
12239        );
12240    }
12241
12242    #[test]
12243    fn the_owner_of_a_data_root_is_read_from_its_path() {
12244        let users = Path::new(if cfg!(windows) { r"C:\Users" } else { "/home" });
12245        let home = users.join("ivand");
12246        assert_eq!(
12247            data_root_owner(&users.join("yuriv").join("data"), Some(&home)).as_deref(),
12248            Some("yuriv")
12249        );
12250        assert_eq!(
12251            data_root_owner(
12252                Path::new(if cfg!(windows) { r"D:\rm" } else { "/opt/rm" }),
12253                Some(&home)
12254            ),
12255            None
12256        );
12257        if cfg!(windows) {
12258            assert_eq!(
12259                data_root_owner(
12260                    Path::new(r"C:\Windows\System32\config\systemprofile\AppData"),
12261                    Some(&home)
12262                )
12263                .as_deref(),
12264                Some("LocalSystem")
12265            );
12266        } else {
12267            assert_eq!(
12268                data_root_owner(Path::new("/root/.config/runner-manager"), Some(&home)).as_deref(),
12269                Some("root")
12270            );
12271        }
12272    }
12273
12274    #[test]
12275    fn forget_local_archives_the_unused_record_and_never_touches_the_service_root() {
12276        let (owner, earlier) = a_machine_with_a_stale_second_account();
12277        let owner_before = snapshot(&[owner._root.path()]);
12278        let config = earlier.paths.config_dir().to_path_buf();
12279        let record = std::fs::read(InstallRecord::path(&earlier.paths)).unwrap();
12280
12281        let forgotten = second_account(&earlier, &owner)
12282            .forget_local(Utc::now())
12283            .expect("archived");
12284
12285        assert_eq!(
12286            snapshot(&[owner._root.path()]),
12287            owner_before,
12288            "the owner's directories must not change by one byte"
12289        );
12290        let archive = forgotten.archive.expect("something was archived");
12291        assert!(archive.starts_with(&config), "{}", archive.display());
12292        assert_eq!(
12293            std::fs::read(archive.join(RECORD_FILE)).unwrap(),
12294            record,
12295            "moved, not rewritten or deleted"
12296        );
12297        assert!(InstallRecord::read(&earlier.paths).unwrap().is_none());
12298        assert_eq!(
12299            forgotten.service_root,
12300            Some(ServiceDirectories::of(&owner.paths).root())
12301        );
12302        // Afterwards the second account's status has no unused record to note.
12303        let status = second_account(&earlier, &owner).status().expect("status");
12304        assert!(
12305            !status
12306                .notes()
12307                .iter()
12308                .any(|note| note.contains("unused local configuration")),
12309            "{status}"
12310        );
12311    }
12312
12313    #[test]
12314    fn forget_local_refuses_a_configuration_a_service_runs_against() {
12315        let (owner, earlier) = a_machine_with_a_stale_second_account();
12316        let before = snapshot(&[owner._root.path()]);
12317        let refused = owner.operations().forget_local(Utc::now());
12318        assert!(
12319            matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
12320            "{refused:?}"
12321        );
12322        assert_eq!(snapshot(&[owner._root.path()]), before);
12323
12324        // Nor while an agent holds the second account's own lock.
12325        let _agent = HostLock::try_acquire(&earlier.paths, LockKind::SingleInstance)
12326            .expect("the agent's lock");
12327        let refused = second_account(&earlier, &owner).forget_local(Utc::now());
12328        assert!(
12329            matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
12330            "{refused:?}"
12331        );
12332        assert!(InstallRecord::read(&earlier.paths).unwrap().is_some());
12333    }
12334
12335    #[test]
12336    fn a_readable_foreign_service_keeps_its_diagnoses_and_loses_its_remedies() {
12337        let (owner, earlier) = a_machine_with_a_stale_second_account();
12338        // The owner's service has every problem whose remedy acts on the
12339        // directories of whoever runs it.
12340        owner.operations().stop().expect("stopped");
12341        record_github_credential_rejected(&owner.paths, Utc::now()).expect("rejected");
12342        record_credential_unreadable(&owner.paths, Utc::now()).expect("unreadable");
12343
12344        let status = second_account(&earlier, &owner).status().expect("a status");
12345        assert!(status.foreign().is_some_and(|foreign| !foreign.is_unread()));
12346        let subjects: Vec<_> = status.problems().iter().map(|p| p.subject).collect();
12347        for subject in [
12348            "runtime",
12349            "github credential",
12350            CREDENTIAL_UNREADABLE_SUBJECT,
12351        ] {
12352            assert!(subjects.contains(&subject), "{subject}: {status}");
12353        }
12354        let rendered = status.to_string();
12355        for advice in DESTRUCTIVE_ADVICE {
12356            assert!(!rendered.contains(advice), "{advice}: {rendered}");
12357        }
12358        for problem in status.problems() {
12359            assert!(problem.detail.contains("Act on it"), "{problem}");
12360        }
12361    }
12362
12363    #[test]
12364    fn no_command_of_this_account_changes_another_installations_service() {
12365        let (owner, earlier) = a_machine_with_a_stale_second_account();
12366        let operations = second_account(&earlier, &owner);
12367        let foreign = |result: Result<(), ServiceError>| {
12368            assert!(
12369                matches!(result, Err(ServiceError::ForeignInstallation { .. })),
12370                "{result:?}"
12371            );
12372        };
12373        foreign(
12374            operations
12375                .install(&request_with_directories(&earlier, StartMode::Boot))
12376                .map(drop),
12377        );
12378        foreign(operations.uninstall().map(drop));
12379        foreign(operations.set_start_mode(StartMode::Boot).map(drop));
12380        foreign(operations.start());
12381        foreign(operations.stop().map(drop));
12382
12383        let registration = owner
12384            .controls
12385            .control(StartMode::Boot)
12386            .unwrap()
12387            .query(&ServiceIdentity::product())
12388            .unwrap()
12389            .expect("the owner's registration is still there");
12390        assert_eq!(
12391            ServiceDirectories::from_command_line(&registration.command_line),
12392            Some(ServiceDirectories::of(&owner.paths)),
12393            "and still runs against the owner's directories"
12394        );
12395        assert!(registration.running, "and was never stopped");
12396        assert!(
12397            InstallRecord::read(&earlier.paths).unwrap().is_some(),
12398            "the refused uninstall kept this account's record"
12399        );
12400    }
12401
12402    #[test]
12403    fn forget_local_leaves_agent_directories_nested_in_the_configuration_alone() {
12404        // macOS puts state, runtime and logs inside the configuration
12405        // directory: `config_local_dir` and `data_local_dir` are one folder.
12406        let root = tempfile::tempdir().unwrap();
12407        let config = root.path().join("Application Support");
12408        let paths = AppPaths::from_directories(
12409            &config,
12410            config.join("state"),
12411            config.join("runtime"),
12412            config.join("logs"),
12413        );
12414        paths.create_all().unwrap();
12415        std::fs::write(config.join(RECORD_FILE), b"an earlier install").unwrap();
12416        std::fs::write(paths.logs_dir().join("runner-manager.log"), b"log").unwrap();
12417        let operations = ServiceOperations::with_controls(
12418            paths.clone(),
12419            ServiceIdentity::product(),
12420            std::sync::Arc::new(RecordingControls::new()),
12421        );
12422
12423        let forgotten = operations.forget_local(Utc::now()).expect("archived");
12424
12425        assert_eq!(forgotten.moved, vec![config.join(RECORD_FILE)]);
12426        for kept in [paths.state_dir(), paths.runtime_dir(), paths.logs_dir()] {
12427            assert!(kept.is_dir(), "{} was moved", kept.display());
12428        }
12429        assert!(paths.logs_dir().join("runner-manager.log").is_file());
12430    }
12431
12432    #[test]
12433    fn a_remedy_is_cut_from_every_detail_a_builder_writes() {
12434        for (detail, diagnosis) in [
12435            (
12436                "x holds an automatic registration, but the daemon is stopped. Run `runner-manager \
12437                 service install --start-at boot`; if the service manager denies replacement",
12438                "x holds an automatic registration, but the daemon is stopped",
12439            ),
12440            (
12441                "store -- MISMATCH. Run `auth login` again so the token is stored",
12442                "store -- MISMATCH",
12443            ),
12444            (
12445                "unit differs, so the daemon runs under outdated settings. Re-register it with \
12446                 `sudo runner-manager service install --start-at boot`",
12447                "unit differs, so the daemon runs under outdated settings",
12448            ),
12449            (
12450                "it starts no runner; the log has it. Sign in again: `runner-manager auth login`",
12451                "it starts no runner; the log has it",
12452            ),
12453            (
12454                "C:\\a\\runner-manager.exe -- STALE: moved. Run `service uninstall` and `service \
12455                 install`",
12456                "C:\\a\\runner-manager.exe -- STALE: moved",
12457            ),
12458            (
12459                "a new builder: run `runner-manager service install` here, or not",
12460                "the installation's own records report this",
12461            ),
12462        ] {
12463            assert_eq!(diagnosis_only(detail), diagnosis, "{detail}");
12464        }
12465    }
12466}