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;
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/// The agent's last runner-root refusal, for `service status` to report.
159///
160/// See [`record_runner_root_refusal`] for the contract.
161pub const ROOT_REFUSAL_FILE: &str = "runner-root-refusal.toml";
162
163/// The rotating diagnostic log the **daemon** writes, inside `logs/`.
164///
165/// The appender adds its own date suffix, so this is the stem rather than a
166/// file that exists. `service status` reports the directory and the stem,
167/// because that is what an operator needs in order to find today's file and
168/// yesterday's.
169///
170/// It is [`crate::logging::SERVICE_LOG_STEM`] and not the operator's, because a
171/// boot-mode daemon runs under a different account and writes a different file.
172/// [`crate::logging::LogRole`] says why the two were separated.
173pub const LOG_FILE_STEM: &str = crate::logging::SERVICE_LOG_STEM;
174
175// ---------------------------------------------------------------------------
176// Identity
177// ---------------------------------------------------------------------------
178
179/// What the operating system calls this registration.
180///
181/// A type rather than three constants, for one reason that is not tidiness: the
182/// privileged installer tests register a **real** service on a **real** machine,
183/// and they must not be able to collide with — or remove — an operator's
184/// installation. [`ServiceIdentity::fixture`] produces a name that is
185/// unmistakably a test artefact, and every backend takes its name from here, so
186/// there is no path by which a test reaches the product's own registration.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct ServiceIdentity {
189    name: String,
190    display_name: String,
191    description: String,
192}
193
194impl ServiceIdentity {
195    /// The registration an operator installs.
196    #[must_use]
197    pub fn product() -> Self {
198        Self {
199            name: SERVICE_NAME.to_string(),
200            display_name: DISPLAY_NAME.to_string(),
201            description: DESCRIPTION.to_string(),
202        }
203    }
204
205    /// A disposable registration for a privileged test, named so that it cannot
206    /// be mistaken for — or collide with — [`ServiceIdentity::product`].
207    ///
208    /// `tag` distinguishes concurrent runs; it is reduced to ASCII alphanumerics
209    /// and `-` so that it is a legal service name, launchd label, systemd unit
210    /// name, and Task Scheduler task name at once.
211    #[must_use]
212    pub fn fixture(tag: &str) -> Self {
213        let tag: String = tag
214            .chars()
215            .map(|c| {
216                if c.is_ascii_alphanumeric() {
217                    c.to_ascii_lowercase()
218                } else {
219                    '-'
220                }
221            })
222            .collect();
223        let name = format!("{SERVICE_NAME}-selftest-{tag}");
224        Self {
225            display_name: format!("{DISPLAY_NAME} (self-test fixture {tag})"),
226            description: "Disposable fixture created by runner-manager's own installer tests. \
227                          Safe to remove."
228                .to_string(),
229            name,
230        }
231    }
232
233    /// Whether this identity is a disposable test fixture.
234    ///
235    /// The privileged tests assert this before they delete anything, which is
236    /// the mechanical half of *"never touch a service you did not create"*.
237    #[must_use]
238    pub fn is_fixture(&self) -> bool {
239        self.name.starts_with(&format!("{SERVICE_NAME}-selftest-"))
240    }
241
242    /// The Windows service name, the systemd unit stem, and the Task Scheduler
243    /// task name.
244    #[must_use]
245    pub fn name(&self) -> &str {
246        &self.name
247    }
248
249    /// The human-readable name.
250    #[must_use]
251    pub fn display_name(&self) -> &str {
252        &self.display_name
253    }
254
255    /// One line of explanation.
256    #[must_use]
257    pub fn description(&self) -> &str {
258        &self.description
259    }
260
261    /// The launchd label — reverse-domain, as launchd expects.
262    ///
263    /// Built from the same three product segments [`crate::paths`] resolves the
264    /// application-data directories with, so a label and a directory cannot
265    /// drift apart.
266    #[must_use]
267    pub fn launchd_label(&self) -> String {
268        format!(
269            "{}.{}.{}",
270            crate::paths::QUALIFIER,
271            crate::paths::ORGANIZATION,
272            self.name
273        )
274    }
275
276    /// The systemd unit file name.
277    #[must_use]
278    pub fn systemd_unit(&self) -> String {
279        format!("{}.service", self.name)
280    }
281}
282
283impl fmt::Display for ServiceIdentity {
284    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
285        f.write_str(&self.name)
286    }
287}
288
289// ---------------------------------------------------------------------------
290// Restart policy
291// ---------------------------------------------------------------------------
292
293/// The restart-on-failure policy, and the bound on how fast it may retry.
294///
295/// `05-infrastructure.md` item 3: *"set a restart-on-failure policy with bounded
296/// delay"*. Both halves matter and they are different requirements. A service
297/// that does not come back after a crash defeats Journey 5; a service that comes
298/// back instantly, forever, turns one bad configuration into a fork bomb against
299/// GitHub's rate limit. So the delay has a floor as well as a ceiling, and the
300/// floor is what `does not restart-loop faster than that bound` is measured
301/// against.
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub struct RestartPolicy {
304    delay: Duration,
305    reset_after: Duration,
306}
307
308impl RestartPolicy {
309    /// The shortest delay this will accept.
310    ///
311    /// One second is not a tuning preference. Below it, launchd's own throttle
312    /// and systemd's start-limit logic both take over and the configured number
313    /// stops being the number in force, so a policy under this floor would be a
314    /// value `service status` reported and the platform ignored.
315    pub const MIN_DELAY: Duration = Duration::from_secs(1);
316
317    /// The longest delay this will accept. Past five minutes an operator
318    /// watching a restart would reasonably conclude the service is simply gone.
319    pub const MAX_DELAY: Duration = Duration::from_secs(300);
320
321    /// What `service install` uses when nothing is said.
322    pub const DEFAULT_DELAY: Duration = Duration::from_secs(15);
323
324    /// How long the service must stay up before its failure count is forgotten.
325    pub const DEFAULT_RESET_AFTER: Duration = Duration::from_secs(600);
326
327    /// # Errors
328    ///
329    /// [`ServiceError::RestartDelay`] when `delay` is outside
330    /// [`RestartPolicy::MIN_DELAY`]..=[`RestartPolicy::MAX_DELAY`], or when
331    /// `reset_after` is not longer than `delay` — a reset window shorter than
332    /// the delay can never elapse between two restarts, so the failure count
333    /// would reset on every attempt and no start-limit could ever trip.
334    pub fn new(delay: Duration, reset_after: Duration) -> Result<Self, ServiceError> {
335        if delay < Self::MIN_DELAY || delay > Self::MAX_DELAY {
336            return Err(ServiceError::RestartDelay {
337                requested_secs: delay.as_secs(),
338                min_secs: Self::MIN_DELAY.as_secs(),
339                max_secs: Self::MAX_DELAY.as_secs(),
340            });
341        }
342        if reset_after <= delay {
343            return Err(ServiceError::RestartResetWindow {
344                reset_secs: reset_after.as_secs(),
345                delay_secs: delay.as_secs(),
346            });
347        }
348        Ok(Self { delay, reset_after })
349    }
350
351    /// How long the service manager waits before restarting a failed service.
352    #[must_use]
353    pub const fn delay(&self) -> Duration {
354        self.delay
355    }
356
357    /// How long the service must run before its failure count resets.
358    #[must_use]
359    pub const fn reset_after(&self) -> Duration {
360        self.reset_after
361    }
362
363    /// The delay a given manager can actually express.
364    ///
365    /// Three of the four take seconds and enforce exactly what they are given.
366    /// **Windows Task Scheduler does not**: `RestartOnFailure/Interval` is
367    /// expressed in whole minutes with a one-minute floor, and it *rejects* the
368    /// registration outright rather than rounding — `PT15S` comes back as
369    /// "The task XML contains a value which is incorrectly formatted or out of
370    /// range", which is how this was found.
371    ///
372    /// So the interval is rounded **up**, never down, and never below one
373    /// minute. The direction is the point: the requirement is that the service
374    /// *"does not restart-loop faster than that bound"*, and a delay longer
375    /// than the configured one still satisfies it, while a shorter one would
376    /// not. `service status` reports the difference as a note so that an
377    /// operator reading `15s` in the record and `60s` from the manager is told
378    /// why rather than left to wonder.
379    #[must_use]
380    pub const fn effective_delay(&self, kind: DefinitionKind) -> Duration {
381        match kind {
382            DefinitionKind::WindowsScheduledTask => {
383                let seconds = self.delay.as_secs();
384                let minutes = seconds.div_ceil(60);
385                Duration::from_secs(if minutes == 0 { 60 } else { minutes * 60 })
386            }
387            _ => self.delay,
388        }
389    }
390}
391
392impl Default for RestartPolicy {
393    fn default() -> Self {
394        Self {
395            delay: Self::DEFAULT_DELAY,
396            reset_after: Self::DEFAULT_RESET_AFTER,
397        }
398    }
399}
400
401impl fmt::Display for RestartPolicy {
402    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
403        write!(
404            f,
405            "restart after {}s, failure count resets after {}s",
406            self.delay.as_secs(),
407            self.reset_after.as_secs()
408        )
409    }
410}
411
412// ---------------------------------------------------------------------------
413// The account
414// ---------------------------------------------------------------------------
415
416/// The account a registration runs under.
417///
418/// Not an operator choice. It is a function of the start mode and the platform,
419/// because the start mode decides which secret store the daemon must read and
420/// the store's own access control decides which accounts can read it. See
421/// `docs/service-account.md`, and [`review_least_privilege`] for the check.
422#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
423#[serde(rename_all = "snake_case")]
424pub enum ServiceAccount {
425    /// `NT AUTHORITY\SYSTEM`. The only stock Windows account named by the
426    /// machine-scoped store's DACL.
427    LocalSystem,
428    /// `root`. What a LaunchDaemon and a systemd system unit run as, and what
429    /// the macOS System Keychain's root-only master key requires.
430    Root,
431    /// The operator's own account, for a login-mode registration.
432    InvokingUser,
433}
434
435impl ServiceAccount {
436    /// The account a given kind of definition obliges.
437    ///
438    /// Keyed on the definition rather than on `cfg!(windows)`, and the
439    /// difference is not academic: a Windows developer rendering the launchd
440    /// property list would otherwise write `UserName = NT AUTHORITY\SYSTEM`
441    /// into it, which is not an account macOS has. A definition's account is a
442    /// property of the definition, and the whole point of rendering being pure
443    /// is that any host can render any platform's and get the right answer.
444    #[must_use]
445    pub const fn for_definition(kind: DefinitionKind, mode: StartMode) -> Self {
446        match (kind, mode) {
447            (DefinitionKind::WindowsService, _) => Self::LocalSystem,
448            (DefinitionKind::WindowsScheduledTask, _) | (_, StartMode::Login) => Self::InvokingUser,
449            (_, StartMode::Boot) => Self::Root,
450        }
451    }
452
453    /// The account the given start mode obliges on the platform this binary was
454    /// built for.
455    #[must_use]
456    pub const fn for_start_mode(mode: StartMode) -> Self {
457        Self::for_definition(host_definition_kind(mode), mode)
458    }
459
460    /// How the platform spells it.
461    #[must_use]
462    pub const fn as_str(&self) -> &'static str {
463        match self {
464            Self::LocalSystem => "NT AUTHORITY\\SYSTEM",
465            Self::Root => "root",
466            Self::InvokingUser => "the invoking user",
467        }
468    }
469
470    /// Why this is the *minimum* account that can do the job, not merely the
471    /// convenient one.
472    ///
473    /// Printed by `service status` and by [`review_least_privilege`], because a
474    /// privileged account with no stated reason is indistinguishable from a
475    /// privileged account nobody thought about.
476    #[must_use]
477    pub const fn justification(&self) -> &'static str {
478        match self {
479            Self::LocalSystem => {
480                "the machine-scoped store's DACL names SY, BA and OW only; LocalService and \
481                 NetworkService cannot read it, and widening the DACL to reach them would grant \
482                 every service on this host read access to the one credential this product holds"
483            }
484            Self::Root => {
485                "a boot-time registration runs outside every login session: on macOS the System \
486                 Keychain is unlocked by /var/db/SystemKey, which is root-only, and on Linux the \
487                 machine-scoped store is a 0600 file under /var/lib that only root can open \
488                 before a session exists"
489            }
490            Self::InvokingUser => {
491                "a login-mode registration reads the user-scoped store, which is deliberately \
492                 readable by exactly one account and needs no elevation at all"
493            }
494        }
495    }
496
497    /// Whether registering under this account needs administrative rights.
498    #[must_use]
499    pub const fn needs_elevation(&self) -> bool {
500        matches!(self, Self::LocalSystem | Self::Root)
501    }
502}
503
504impl fmt::Display for ServiceAccount {
505    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
506        f.write_str(self.as_str())
507    }
508}
509
510// ---------------------------------------------------------------------------
511// Errors
512// ---------------------------------------------------------------------------
513
514/// Something went wrong installing, inspecting, or removing the registration.
515///
516/// Every variant is formatted straight into an operator-facing message, so each
517/// one says what to do next rather than only what failed. No variant carries a
518/// secret: this module never reads the token, only the *location* `d2` publishes
519/// for exactly this purpose.
520#[derive(Debug, thiserror::Error)]
521pub enum ServiceError {
522    /// The single-instance lock is held, so a second agent must not be
523    /// registered.
524    ///
525    /// `05-infrastructure.md` item 1. The message is `d1`'s, verbatim, because
526    /// `d1` already worked out what to tell an operator who lost that race.
527    #[error("cannot install the service while an agent is already running on this host: {source}")]
528    LockHeld {
529        /// What `d1` said, including who holds it.
530        #[source]
531        source: Box<LockError>,
532    },
533
534    /// The lock could not be inspected at all, which is not the same as it being
535    /// free.
536    #[error(
537        "cannot tell whether an agent is already running on this host, so the service was not \
538         installed: {source}. Fix the reported problem with the state directory and try again."
539    )]
540    LockUnreadable {
541        /// The underlying failure.
542        #[source]
543        source: Box<LockError>,
544    },
545
546    /// The runner root this registration would run jobs under could not be
547    /// created, inspected, or given the access the registration's account needs.
548    ///
549    /// Reported *before* anything is registered. A registration whose workspaces
550    /// would land in a directory ordinary local users can write is a
551    /// registration that should not exist, and `04-security-recovery.md` asks
552    /// for exactly that refusal rather than a warning.
553    #[error(
554        "this registration would run jobs under the default runner root, and that root could \
555         not be prepared, so nothing was registered: {source}"
556    )]
557    RunnerRoot {
558        /// What [`crate::runner_root_access`] reported, including the remedy.
559        #[source]
560        source: Box<RootAccessError>,
561    },
562
563    /// The path of the running binary could not be resolved.
564    #[error(
565        "cannot resolve the absolute path of this executable, so there is nothing to register: \
566         {detail}. Run the installer from the installed binary rather than through a shell \
567         function or a wrapper that replaces argv[0]."
568    )]
569    BinaryPath {
570        /// What the platform reported.
571        detail: String,
572    },
573
574    /// The binary named for the registration is not there.
575    #[error(
576        "{} is not a file, so registering it would create a service that cannot start. Install \
577         the product first, then run `service install` from the installed binary.",
578        path.display()
579    )]
580    BinaryMissing {
581        /// The path that was to be registered.
582        path: PathBuf,
583    },
584
585    /// The requested restart delay is outside the bound.
586    #[error(
587        "a restart delay of {requested_secs}s is outside the supported range \
588         {min_secs}s-{max_secs}s. Below the floor the platform's own throttle overrides the \
589         value, so `service status` would report a delay that is not the one in force."
590    )]
591    RestartDelay {
592        /// What was asked for.
593        requested_secs: u64,
594        /// The floor.
595        min_secs: u64,
596        /// The ceiling.
597        max_secs: u64,
598    },
599
600    /// The failure-count reset window is not longer than the restart delay.
601    #[error(
602        "a failure-count reset window of {reset_secs}s is not longer than the {delay_secs}s \
603         restart delay, so the count would reset between every pair of restarts and no \
604         start limit could ever apply."
605    )]
606    RestartResetWindow {
607        /// What was asked for.
608        reset_secs: u64,
609        /// The delay it has to exceed.
610        delay_secs: u64,
611    },
612
613    /// A registration is already there, in the **other** start mode.
614    ///
615    /// Only the other one. A registration in the mode being asked for is
616    /// replaced rather than refused — see [`Operations::install`], and the
617    /// outage that taught us why.
618    #[error(
619        "{name} is already registered to start at {existing}, and this asks for {requested}. \
620         Changing the start mode moves the registration between two different service \
621         managers, so it is not something an install does by itself: run `service uninstall` \
622         first, or change the start mode in the terminal UI (`runner-manager tui`), which \
623         switches it in place and keeps the registration running throughout."
624    )]
625    AlreadyInstalled {
626        /// Which registration.
627        name: String,
628        /// The mode it currently carries.
629        existing: StartMode,
630        /// The mode this install asked for.
631        requested: StartMode,
632    },
633
634    /// No registration is there.
635    #[error("{name} is not registered on this host, so there is nothing to {operation}.")]
636    NotInstalled {
637        /// Which registration.
638        name: String,
639        /// What was being attempted.
640        operation: &'static str,
641    },
642
643    /// The record `install` wrote could not be read or written.
644    #[error("cannot {operation} the service record {}: {detail}", path.display())]
645    Record {
646        /// Read, write, or remove.
647        operation: &'static str,
648        /// The record file.
649        path: PathBuf,
650        /// The underlying failure.
651        detail: String,
652    },
653
654    /// The record is there but is not one this version wrote.
655    #[error(
656        "the service record {} was not written by this product, or was written by a version \
657         this one cannot read: {detail}. Run `service uninstall` and `service install` again; \
658         neither touches configuration, secrets, or the cache.",
659        path.display()
660    )]
661    RecordUnreadable {
662        /// The record file.
663        path: PathBuf,
664        /// What was wrong with it.
665        detail: String,
666    },
667
668    /// The record is there and **this account** may not read it.
669    ///
670    /// Its own variant rather than a [`ServiceError::Record`] with a
671    /// `Permission denied` in the detail, because it is the one record failure
672    /// that is not a fault in the record: `service status` reports it and
673    /// carries on, where every other read failure ends the command. See
674    /// [`Operations::status`].
675    ///
676    /// A version before this one wrote the record at `0600` through a temporary
677    /// file, so a boot-mode `sudo service install` left it owned by `root` and
678    /// unreadable by the operator whose profile it is in. `service status` then
679    /// failed on their own host, with a remedy naming the command that had just
680    /// failed.
681    #[error(
682        "the service record {} is there and this account may not read it: {detail}. It was \
683         written by whichever account installed the service -- on a boot-mode host, `sudo \
684         service install`. Running `service install` again rewrites it readable; until then \
685         only what the service manager itself reports is available.",
686        path.display()
687    )]
688    RecordNotPermitted {
689        /// The record file.
690        path: PathBuf,
691        /// What the operating system said.
692        detail: String,
693    },
694
695    /// The application-data directories could not be resolved or created.
696    #[error("cannot prepare this host's application-data directories: {source}")]
697    Paths {
698        /// The underlying failure.
699        #[source]
700        source: Box<crate::paths::PathsError>,
701    },
702
703    /// The platform's service manager refused, or could not be reached.
704    #[error("cannot {operation} {name} through {manager}: {detail}")]
705    Control {
706        /// What was being attempted.
707        operation: &'static str,
708        /// Which registration.
709        name: String,
710        /// Which service manager.
711        manager: &'static str,
712        /// What it said.
713        detail: String,
714    },
715
716    /// An operation failed and its compensating action failed too.
717    #[error(
718        "cannot {operation} {name}: {cause}. The attempted rollback also failed: {rollback}. \
719         Inspect `service status` before retrying."
720    )]
721    Rollback {
722        /// The transaction that could not be completed.
723        operation: &'static str,
724        /// Which registration was involved.
725        name: String,
726        /// The original failure.
727        cause: String,
728        /// The failure while restoring the previous state.
729        rollback: String,
730    },
731
732    /// The operation needs administrative rights it does not have.
733    #[error("{operation} {name} needs administrative rights: {detail}. {remedy}")]
734    NeedsElevation {
735        /// What was being attempted.
736        operation: &'static str,
737        /// Which registration.
738        name: String,
739        /// What the platform said.
740        detail: String,
741        /// How to get them, in this platform's own terms.
742        remedy: &'static str,
743    },
744}
745
746// ---------------------------------------------------------------------------
747// Directories
748// ---------------------------------------------------------------------------
749
750/// The four directories the registration was installed against.
751///
752/// Recorded rather than re-derived, because `05-infrastructure.md` item 2 speaks
753/// of the *configured* cache and runtime directories: the account the service
754/// runs under and the account that ran `service install` do not always resolve
755/// the same platform-standard locations, and a claim about what the service can
756/// write is only checkable against a specific set of paths.
757#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
758pub struct ServiceDirectories {
759    /// Non-secret TOML and the SQLite database.
760    pub config: PathBuf,
761    /// The agent lock, the attempt journal, and the runner package cache.
762    pub state: PathBuf,
763    /// Per-attempt disposable runner workspaces.
764    pub runtime: PathBuf,
765    /// Rotating redacted diagnostics.
766    pub logs: PathBuf,
767}
768
769impl ServiceDirectories {
770    /// Snapshots the four directories a resolved [`AppPaths`] names.
771    #[must_use]
772    pub fn of(paths: &AppPaths) -> Self {
773        Self {
774            config: paths.config_dir().to_path_buf(),
775            state: paths.state_dir().to_path_buf(),
776            runtime: paths.runtime_dir().to_path_buf(),
777            logs: paths.logs_dir().to_path_buf(),
778        }
779    }
780
781    /// The four, in the order `05-infrastructure.md` lists them.
782    #[must_use]
783    pub fn all(&self) -> [&Path; 4] {
784        [
785            self.config.as_path(),
786            self.state.as_path(),
787            self.runtime.as_path(),
788            self.logs.as_path(),
789        ]
790    }
791
792    /// The rotating diagnostic log's stem, which is what `service status`
793    /// reports and what `05-infrastructure.md` item 4 requires be preserved.
794    #[must_use]
795    pub fn log_file(&self) -> PathBuf {
796        self.logs.join(LOG_FILE_STEM)
797    }
798}
799
800// ---------------------------------------------------------------------------
801// The install plan
802// ---------------------------------------------------------------------------
803
804/// What `service install` was asked for, before anything has been resolved.
805///
806/// Separate from [`InstallPlan`] because a request is the operator's words and
807/// a plan is what the host actually supports: the plan carries a *resolved
808/// absolute* binary path, the account the start mode obliges, and the four
809/// directories the registration is installed against, none of which the caller
810/// supplies.
811#[derive(Debug, Clone, PartialEq, Eq)]
812pub struct InstallRequest {
813    start_mode: StartMode,
814    binary: Option<PathBuf>,
815    source_binary: Option<PathBuf>,
816    arguments: Vec<OsString>,
817    restart: RestartPolicy,
818    on_demand: bool,
819}
820
821impl InstallRequest {
822    /// Register the binary that is running this call.
823    #[must_use]
824    pub fn new(start_mode: StartMode) -> Self {
825        Self {
826            start_mode,
827            binary: None,
828            source_binary: None,
829            arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
830            restart: RestartPolicy::default(),
831            on_demand: false,
832        }
833    }
834
835    /// Registers the service but does not ask the manager to start it by
836    /// itself.
837    ///
838    /// Production never uses this: a boot-mode registration that does not start
839    /// at boot is the failure `service status` reports. **The privileged
840    /// installer tests use it for every fixture they create**, so that a
841    /// registration which somehow escaped its cleanup guard cannot start with
842    /// the owner's machine on the next reboot. A leaked service is bad; a
843    /// leaked service that runs is worse, and the difference costs one flag.
844    #[must_use]
845    pub const fn started_on_demand(mut self) -> Self {
846        self.on_demand = true;
847        self
848    }
849
850    /// Register a named binary instead of the running one.
851    ///
852    /// The privileged installer tests use this to register a fixture service
853    /// host. Production does not: `05-infrastructure.md` item 6 is specifically
854    /// about the path of *the running binary*, and letting an operator name a
855    /// different one would defeat the stale-path detection it asks for.
856    #[must_use]
857    pub fn for_binary(mut self, binary: impl Into<PathBuf>) -> Self {
858        self.binary = Some(binary.into());
859        self
860    }
861
862    /// Where the registered binary was copied from.
863    ///
864    /// # Why a registration has a source at all
865    ///
866    /// A service registered directly against a package manager's own file can
867    /// never be upgraded on Windows, because the running service holds that
868    /// file open and `npm i -g` cannot replace it. What it does instead is
869    /// worse than failing: it rewrites the package metadata, reports success,
870    /// and leaves the old executable in place — so the operator is told the new
871    /// version is installed while the old one keeps running. That was observed,
872    /// twice in a row, before this existed.
873    ///
874    /// So the service runs a copy the product owns, and this records where that
875    /// copy came from. The source is what a package manager updates, what
876    /// [`inspect_binary`] watches for item 6's stale-path case, and what the
877    /// daemon compares its own version against to know an upgrade is waiting.
878    #[must_use]
879    pub fn copied_from(mut self, source: impl Into<PathBuf>) -> Self {
880        self.source_binary = Some(source.into());
881        self
882    }
883
884    /// Replace the registered arguments. Defaults to [`DAEMON_ARGUMENTS`].
885    #[must_use]
886    pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
887    where
888        I: IntoIterator<Item = S>,
889        S: Into<OsString>,
890    {
891        self.arguments = arguments.into_iter().map(Into::into).collect();
892        self
893    }
894
895    /// Replace the restart-on-failure policy. Defaults to
896    /// [`RestartPolicy::default`].
897    #[must_use]
898    pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
899        self.restart = restart;
900        self
901    }
902
903    /// The start mode asked for.
904    #[must_use]
905    pub const fn start_mode(&self) -> StartMode {
906        self.start_mode
907    }
908}
909
910/// Everything a backend needs, resolved and validated.
911///
912/// Constructing one is where `05-infrastructure.md` item 6 is actually
913/// satisfied: the binary path is made absolute and is confirmed to be a file
914/// *before* anything is registered, so a registration that would fail at boot
915/// is refused at install time instead.
916#[derive(Debug, Clone, PartialEq, Eq)]
917pub struct InstallPlan {
918    identity: ServiceIdentity,
919    start_mode: StartMode,
920    binary: PathBuf,
921    source_binary: Option<PathBuf>,
922    arguments: Vec<OsString>,
923    account: ServiceAccount,
924    restart: RestartPolicy,
925    directories: ServiceDirectories,
926    secret_guard: Option<PathBuf>,
927    on_demand: bool,
928}
929
930impl InstallPlan {
931    /// Resolves a request against this host.
932    ///
933    /// # Errors
934    ///
935    /// [`ServiceError::BinaryPath`] when the running executable cannot be
936    /// located, and [`ServiceError::BinaryMissing`] when the path that would be
937    /// registered is not a file.
938    pub fn resolve(
939        identity: ServiceIdentity,
940        request: &InstallRequest,
941        directories: ServiceDirectories,
942    ) -> Result<Self, ServiceError> {
943        let binary = match &request.binary {
944            Some(named) => absolute(named)?,
945            None => running_executable()?,
946        };
947        if !binary.is_file() {
948            return Err(ServiceError::BinaryMissing { path: binary });
949        }
950        // `d2` publishes the guard file the store lives in so the systemd
951        // sandbox can admit only its parent for atomic token rotation. A store
952        // that cannot be resolved is not fatal here; it merely cannot receive
953        // that narrow writable-path exception.
954        let secret_guard = crate::secrets::PlatformSecretStore::for_start_mode(request.start_mode)
955            .ok()
956            .map(|store| store.guard());
957        Ok(Self {
958            identity,
959            start_mode: request.start_mode,
960            binary,
961            source_binary: request.source_binary.clone(),
962            arguments: request.arguments.clone(),
963            account: ServiceAccount::for_start_mode(request.start_mode),
964            restart: request.restart,
965            directories,
966            secret_guard,
967            on_demand: request.on_demand,
968        })
969    }
970
971    /// Builds a plan from values a caller already holds, without touching the
972    /// filesystem.
973    ///
974    /// This is what makes the renderers testable on a host that has none of the
975    /// paths involved: a Windows developer can render the systemd unit for
976    /// `/opt/runner-manager/bin/runner-manager` and assert every line of it.
977    #[must_use]
978    pub fn unchecked(
979        identity: ServiceIdentity,
980        start_mode: StartMode,
981        binary: impl Into<PathBuf>,
982        directories: ServiceDirectories,
983    ) -> Self {
984        Self {
985            identity,
986            start_mode,
987            binary: binary.into(),
988            source_binary: None,
989            arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
990            account: ServiceAccount::for_start_mode(start_mode),
991            restart: RestartPolicy::default(),
992            directories,
993            secret_guard: None,
994            on_demand: false,
995        }
996    }
997
998    /// Registers without arming the manager's automatic start. See
999    /// [`InstallRequest::started_on_demand`].
1000    #[must_use]
1001    pub const fn started_on_demand(mut self) -> Self {
1002        self.on_demand = true;
1003        self
1004    }
1005
1006    /// Whether the manager was asked not to start this by itself.
1007    #[must_use]
1008    pub const fn is_on_demand(&self) -> bool {
1009        self.on_demand
1010    }
1011
1012    /// Names the file the secret store lives in, so systemd can permit atomic
1013    /// replacement of it under `ProtectSystem=strict`.
1014    ///
1015    /// [`InstallPlan::resolve`] fills this from `d2`; a test names it directly
1016    /// so that a Windows or macOS CI leg can assert the Linux unit's writable
1017    /// path for a store that platform does not have.
1018    #[must_use]
1019    pub fn with_secret_guard(mut self, guard: impl Into<PathBuf>) -> Self {
1020        self.secret_guard = Some(guard.into());
1021        self
1022    }
1023
1024    /// The file the machine-scoped secret store lives in, when one resolved.
1025    #[must_use]
1026    pub fn secret_guard(&self) -> Option<&Path> {
1027        self.secret_guard.as_deref()
1028    }
1029
1030    /// Overrides the restart policy on an already-built plan.
1031    #[must_use]
1032    pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1033        self.restart = restart;
1034        self
1035    }
1036
1037    /// Overrides the registered arguments on an already-built plan.
1038    #[must_use]
1039    pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1040    where
1041        I: IntoIterator<Item = S>,
1042        S: Into<OsString>,
1043    {
1044        self.arguments = arguments.into_iter().map(Into::into).collect();
1045        self
1046    }
1047
1048    /// What the operating system calls this registration.
1049    #[must_use]
1050    pub const fn identity(&self) -> &ServiceIdentity {
1051        &self.identity
1052    }
1053
1054    /// Boot or login.
1055    #[must_use]
1056    pub const fn start_mode(&self) -> StartMode {
1057        self.start_mode
1058    }
1059
1060    /// The resolved absolute path of the binary to register.
1061    #[must_use]
1062    pub fn binary(&self) -> &Path {
1063        &self.binary
1064    }
1065
1066    /// Where [`Self::binary`] was copied from, when it is a copy.
1067    ///
1068    /// `None` is the legacy layout: the registration names the file a package
1069    /// manager owns, and cannot be upgraded while it runs. See
1070    /// [`InstallRequest::copied_from`].
1071    #[must_use]
1072    pub fn source_binary(&self) -> Option<&Path> {
1073        self.source_binary.as_deref()
1074    }
1075
1076    /// The arguments it is registered with.
1077    #[must_use]
1078    pub fn arguments(&self) -> &[OsString] {
1079        &self.arguments
1080    }
1081
1082    /// The account it runs under.
1083    #[must_use]
1084    pub const fn account(&self) -> &ServiceAccount {
1085        &self.account
1086    }
1087
1088    /// The restart-on-failure policy.
1089    #[must_use]
1090    pub const fn restart(&self) -> RestartPolicy {
1091        self.restart
1092    }
1093
1094    /// The four directories it was installed against.
1095    #[must_use]
1096    pub const fn directories(&self) -> &ServiceDirectories {
1097        &self.directories
1098    }
1099
1100    /// The command line as one string, quoted the way each platform expects.
1101    #[must_use]
1102    pub fn command_line(&self) -> String {
1103        let mut out = quote_argument(&self.binary.to_string_lossy());
1104        for argument in &self.arguments {
1105            out.push(' ');
1106            out.push_str(&quote_argument(&argument.to_string_lossy()));
1107        }
1108        out
1109    }
1110}
1111
1112/// Undoes a runner-root change, and says what could not be undone.
1113///
1114/// `None` when the rollback was complete — either because there was nothing to
1115/// undo, or because the directory this operation created was removed again and
1116/// the descriptor it replaced was written back. `Some` is the "report any
1117/// non-reversible existing directory state explicitly" half of the requirement,
1118/// and the caller folds it into the failure it was already reporting.
1119fn retained_runner_root(change: &RootAccessChange) -> Option<String> {
1120    let reversal = change.revert();
1121    matches!(reversal, Reversal::Retained { .. }).then(|| reversal.to_string())
1122}
1123
1124/// The error a failed step returns once both of its rollbacks have been
1125/// attempted.
1126///
1127/// `retained` is [`retained_runner_root`]'s answer and `rollback` is what
1128/// undoing the *registration* reported — whatever it reports on success, which
1129/// a failure has no use for. Both arrive already evaluated, because each may be
1130/// attempted exactly once and the order is the caller's to choose.
1131///
1132/// A complete rollback leaves `cause` exactly as it was: the operator's problem
1133/// is what failed, not the tidying up afterwards. An incomplete one is promoted
1134/// to [`ServiceError::Rollback`], which is the variant that exists to say "this
1135/// failed *and* something is left behind" — and it names everything that did.
1136fn rolled_back<T>(
1137    retained: Option<String>,
1138    rollback: Result<T, ServiceError>,
1139    operation: &'static str,
1140    identity: &ServiceIdentity,
1141    cause: ServiceError,
1142) -> ServiceError {
1143    let left_behind = match (rollback.err(), retained) {
1144        (Some(rollback), Some(retained)) => Some(format!("{rollback}; {retained}")),
1145        (Some(rollback), None) => Some(rollback.to_string()),
1146        (None, retained) => retained,
1147    };
1148    match left_behind {
1149        Some(rollback) => ServiceError::Rollback {
1150            operation,
1151            name: identity.name().to_string(),
1152            cause: cause.to_string(),
1153            rollback,
1154        },
1155        None => cause,
1156    }
1157}
1158
1159/// [`rolled_back`] for a failure with nothing registered yet, where the runner
1160/// root is the only thing there is to undo.
1161fn undo_runner_root(
1162    change: &RootAccessChange,
1163    operation: &'static str,
1164    identity: &ServiceIdentity,
1165    cause: ServiceError,
1166) -> ServiceError {
1167    rolled_back(
1168        retained_runner_root(change),
1169        Ok(()),
1170        operation,
1171        identity,
1172        cause,
1173    )
1174}
1175
1176/// Makes a path absolute without resolving symlinks.
1177///
1178/// Symbolic links are deliberately left alone. A Homebrew installation puts a
1179/// stable link at `/opt/homebrew/bin/runner-manager` and moves the file it
1180/// points at on every upgrade; recording the link's target would make an
1181/// ordinary `brew upgrade` look exactly like the npm failure this module exists
1182/// to detect, and recording the link records the thing the operator installed.
1183///
1184/// Linux is the one platform where this is not fully in the caller's hands:
1185/// `std::env::current_exe` reads `/proc/self/exe`, which the kernel has already
1186/// resolved. There the recorded path is the real file rather than the shim —
1187/// which is still correct for the npm case, because a real file under a Node
1188/// prefix disappears with the prefix.
1189fn absolute(path: &Path) -> Result<PathBuf, ServiceError> {
1190    std::path::absolute(path).map_err(|error| ServiceError::BinaryPath {
1191        detail: format!("{} could not be made absolute: {error}", path.display()),
1192    })
1193}
1194
1195/// The absolute path of the executable running this call.
1196fn running_executable() -> Result<PathBuf, ServiceError> {
1197    let raw = std::env::current_exe().map_err(|error| ServiceError::BinaryPath {
1198        detail: error.to_string(),
1199    })?;
1200    absolute(&raw)
1201}
1202
1203/// Quotes one command-line argument when it needs it.
1204///
1205/// The rule is Windows', because Windows is the platform where a command line is
1206/// a single string the callee re-splits, and because `windows-service` applies
1207/// exactly this rule when it builds `lpBinaryPathName`. The Unix backends embed
1208/// arguments in a plist array and in a systemd `ExecStart=`, both of which
1209/// accept the same quoting, so one rule serves all three rather than three
1210/// nearly-identical ones.
1211pub(crate) fn quote_argument(argument: &str) -> String {
1212    if !argument.is_empty() && !argument.contains([' ', '"', '\t', '\n']) {
1213        return argument.to_string();
1214    }
1215    let mut out = String::with_capacity(argument.len() + 2);
1216    out.push('"');
1217    let mut backslashes = 0usize;
1218    for c in argument.chars() {
1219        match c {
1220            '\\' => {
1221                backslashes += 1;
1222                out.push('\\');
1223            }
1224            '"' => {
1225                // Every backslash immediately before a quote must be doubled,
1226                // and the quote itself escaped.
1227                for _ in 0..=backslashes {
1228                    out.push('\\');
1229                }
1230                out.push('"');
1231                backslashes = 0;
1232            }
1233            other => {
1234                backslashes = 0;
1235                out.push(other);
1236            }
1237        }
1238    }
1239    // Trailing backslashes would otherwise escape the closing quote.
1240    for _ in 0..backslashes {
1241        out.push('\\');
1242    }
1243    out.push('"');
1244    out
1245}
1246
1247/// Reads the executable back out of a command line quoted by
1248/// [`quote_argument`].
1249///
1250/// `service status` needs this because the Windows Service Control Manager
1251/// stores the binary and its arguments as one string and hands the whole thing
1252/// back from `QueryServiceConfigW`. Comparing a record against a registration
1253/// means splitting that string the same way Windows itself does.
1254///
1255/// Returns `None` for an empty or whitespace-only command line, which is the
1256/// only input with no first argument to find.
1257#[must_use]
1258pub fn executable_from_command_line(command_line: &str) -> Option<PathBuf> {
1259    let trimmed = command_line.trim_start();
1260    if trimmed.is_empty() {
1261        return None;
1262    }
1263    let mut out = String::new();
1264    let mut chars = trimmed.chars().peekable();
1265    let quoted = chars.peek() == Some(&'"');
1266    if quoted {
1267        chars.next();
1268        let mut backslashes = 0usize;
1269        for c in chars {
1270            match c {
1271                '\\' => {
1272                    backslashes += 1;
1273                }
1274                '"' => {
1275                    // `2n` backslashes then a quote closes the argument; `2n+1`
1276                    // is a literal quote inside it.
1277                    out.extend(std::iter::repeat_n('\\', backslashes / 2));
1278                    if backslashes.is_multiple_of(2) {
1279                        break;
1280                    }
1281                    backslashes = 0;
1282                    out.push('"');
1283                }
1284                other => {
1285                    out.extend(std::iter::repeat_n('\\', backslashes));
1286                    backslashes = 0;
1287                    out.push(other);
1288                }
1289            }
1290        }
1291    } else {
1292        for c in chars {
1293            if c == ' ' || c == '\t' {
1294                break;
1295            }
1296            out.push(c);
1297        }
1298    }
1299    if out.is_empty() {
1300        None
1301    } else {
1302        Some(PathBuf::from(out))
1303    }
1304}
1305
1306// ---------------------------------------------------------------------------
1307// Rendered definitions
1308// ---------------------------------------------------------------------------
1309
1310/// Which of the four things a platform reads.
1311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1312pub enum DefinitionKind {
1313    /// The parameters `CreateServiceW` is called with, rendered as a canonical
1314    /// descriptor so that they can be reviewed and asserted like the other
1315    /// three. The Service Control Manager has no file.
1316    WindowsService,
1317    /// A Task Scheduler XML document, for `--start-at login` on Windows.
1318    WindowsScheduledTask,
1319    /// A launchd property list — a LaunchDaemon at boot, a LaunchAgent at login.
1320    LaunchdPlist,
1321    /// A systemd unit — a system unit at boot, a user unit at login.
1322    SystemdUnit,
1323}
1324
1325impl DefinitionKind {
1326    /// What to call the thing that reads it, in an operator-facing message.
1327    #[must_use]
1328    pub const fn manager(self) -> &'static str {
1329        match self {
1330            Self::WindowsService => "the Windows Service Control Manager",
1331            Self::WindowsScheduledTask => "Windows Task Scheduler",
1332            Self::LaunchdPlist => "launchd",
1333            Self::SystemdUnit => "systemd",
1334        }
1335    }
1336}
1337
1338impl fmt::Display for DefinitionKind {
1339    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1340        f.write_str(self.manager())
1341    }
1342}
1343
1344/// One platform's definition of the registration, as text.
1345///
1346/// Producing this is pure: no `cfg`, no privileges, no filesystem. That is what
1347/// lets every leg of the CI matrix assert every platform's definition, instead
1348/// of each leg asserting only its own and the other two being reviewed by
1349/// reading.
1350#[derive(Debug, Clone, PartialEq, Eq)]
1351pub struct ServiceDefinition {
1352    kind: DefinitionKind,
1353    text: String,
1354    install_path: Option<PathBuf>,
1355}
1356
1357impl ServiceDefinition {
1358    /// Which platform this is for.
1359    #[must_use]
1360    pub const fn kind(&self) -> DefinitionKind {
1361        self.kind
1362    }
1363
1364    /// The definition itself.
1365    #[must_use]
1366    pub fn text(&self) -> &str {
1367        &self.text
1368    }
1369
1370    /// Where the platform expects the file, when the platform reads a file.
1371    ///
1372    /// `None` for [`DefinitionKind::WindowsService`], whose "definition" is a
1373    /// set of arguments to `CreateServiceW`, and for a Task Scheduler document,
1374    /// which is handed to `schtasks` from a temporary file and is not stored
1375    /// where it was written.
1376    #[must_use]
1377    pub fn install_path(&self) -> Option<&Path> {
1378        self.install_path.as_deref()
1379    }
1380
1381    /// The definition this host's own service manager would be given.
1382    ///
1383    /// The value-level twin of [`host_definition_kind`]. Rendering needs no
1384    /// privileges and no service manager, so anything that wants to *see* what
1385    /// an install would register — `f3`'s `service install --dry-run`, a
1386    /// support bundle, [`RecordingControls`] — can have it without registering
1387    /// anything.
1388    ///
1389    /// # Errors
1390    ///
1391    /// [`ServiceError::Control`] when a Windows logon-triggered registration is
1392    /// asked for and the session reports no account to register it for.
1393    pub fn for_host(plan: &InstallPlan) -> Result<Self, ServiceError> {
1394        Ok(match host_definition_kind(plan.start_mode()) {
1395            DefinitionKind::WindowsService => Self::windows_service(plan),
1396            DefinitionKind::WindowsScheduledTask => {
1397                Self::windows_scheduled_task(plan, &TaskPrincipal::current()?)
1398            }
1399            DefinitionKind::LaunchdPlist => Self::launchd(plan, host_home().as_deref()),
1400            DefinitionKind::SystemdUnit => Self::systemd(plan, host_home().as_deref()),
1401        })
1402    }
1403
1404    /// The Windows service parameters, as a canonical descriptor.
1405    #[must_use]
1406    pub fn windows_service(plan: &InstallPlan) -> Self {
1407        Self {
1408            kind: DefinitionKind::WindowsService,
1409            text: windows_service_descriptor(plan),
1410            install_path: None,
1411        }
1412    }
1413
1414    /// A Task Scheduler document for `--start-at login`.
1415    #[must_use]
1416    pub fn windows_scheduled_task(plan: &InstallPlan, principal: &TaskPrincipal) -> Self {
1417        Self {
1418            kind: DefinitionKind::WindowsScheduledTask,
1419            text: windows_scheduled_task_xml(plan, principal),
1420            install_path: None,
1421        }
1422    }
1423
1424    /// A launchd property list.
1425    ///
1426    /// `home` is only consulted for `--start-at login`, where the file belongs
1427    /// in the operator's own `~/Library/LaunchAgents`.
1428    #[must_use]
1429    pub fn launchd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1430        let file = format!("{}.plist", plan.identity().launchd_label());
1431        let install_path = match plan.start_mode() {
1432            StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
1433            StartMode::Login => home.map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file)),
1434        };
1435        Self {
1436            kind: DefinitionKind::LaunchdPlist,
1437            text: launchd_plist(plan),
1438            install_path,
1439        }
1440    }
1441
1442    /// Wraps text this module did not render.
1443    ///
1444    /// Two callers need it and both matter. A test builds a deliberately
1445    /// widened definition and watches [`review_least_privilege`] reject it —
1446    /// without which the review would be a function nobody had ever seen fail.
1447    /// And `service status` can review the definition **actually on disk**,
1448    /// which on Linux and macOS an operator is free to edit after installation.
1449    #[must_use]
1450    pub fn from_text(kind: DefinitionKind, text: impl Into<String>) -> Self {
1451        Self {
1452            kind,
1453            text: text.into(),
1454            install_path: None,
1455        }
1456    }
1457
1458    /// A systemd unit.
1459    ///
1460    /// `home` is only consulted for `--start-at login`, where the unit belongs
1461    /// in the operator's own `~/.config/systemd/user`.
1462    #[must_use]
1463    pub fn systemd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1464        let file = plan.identity().systemd_unit();
1465        let install_path = match plan.start_mode() {
1466            StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
1467            StartMode::Login => home.map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file)),
1468        };
1469        Self {
1470            kind: DefinitionKind::SystemdUnit,
1471            text: systemd_unit(plan),
1472            install_path,
1473        }
1474    }
1475}
1476
1477/// Where a LaunchDaemon lives.
1478pub const LAUNCH_DAEMONS_DIR: &str = "/Library/LaunchDaemons";
1479/// Where a LaunchAgent lives, under the operator's home directory.
1480pub const LAUNCH_AGENTS_SUBDIR: &str = "Library/LaunchAgents";
1481/// Where a systemd system unit lives.
1482pub const SYSTEMD_SYSTEM_DIR: &str = "/etc/systemd/system";
1483/// Where a systemd user unit lives, under the operator's home directory.
1484pub const SYSTEMD_USER_SUBDIR: &str = ".config/systemd/user";
1485
1486/// The product's own page, cited by every definition so an operator who finds
1487/// one on a host can find out what put it there.
1488const DOCUMENTATION: &str = "https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI";
1489
1490/// How many consecutive failures the platform tolerates before it stops
1491/// retrying.
1492///
1493/// The companion to [`RestartPolicy::reset_after`]: the delay bounds how *fast*
1494/// a restart may come, and this bounds how *many* come before the host gives
1495/// up and leaves the failure visible instead of hiding it behind an endless
1496/// retry.
1497pub const START_LIMIT_BURST: u32 = 5;
1498
1499// -- systemd -----------------------------------------------------------------
1500
1501/// Renders the systemd unit for this plan.
1502///
1503/// # Why the sandbox is this tight, and what it costs
1504///
1505/// `05-infrastructure.md` item 2 ends *"and write its configured cache and
1506/// runtime directories — and no more"*. `ProtectSystem=strict` plus an explicit
1507/// `ReadWritePaths` is what "and no more" means on Linux: everything outside the
1508/// recorded application-data directories and the credential store directory
1509/// is read-only to this unit. The latter must be writable because refresh-token
1510/// rotation atomically replaces the credential file.
1511///
1512/// **The runner inherits it.** The agent spawns the GitHub Actions runner as a
1513/// child, so a workflow running on this host also runs inside this sandbox: it
1514/// cannot write outside those directories and its private `/tmp`, and
1515/// `NoNewPrivileges=yes` means it cannot `sudo`. `07-security.md` assumes a
1516/// hostile workflow may run here, so that is the intended direction — but it is
1517/// a real behavioural limit and `docs/service-account.md` states it where an
1518/// operator will find it.
1519#[must_use]
1520pub fn systemd_unit(plan: &InstallPlan) -> String {
1521    let identity = plan.identity();
1522    let restart = plan.restart();
1523    let directories = plan.directories();
1524
1525    let mut out = String::new();
1526    out.push_str("[Unit]\n");
1527    out.push_str(&format!("Description={}\n", identity.display_name()));
1528    out.push_str(&format!("Documentation={DOCUMENTATION}\n"));
1529    out.push_str("After=network-online.target\n");
1530    out.push_str("Wants=network-online.target\n");
1531    // In `[Unit]` rather than `[Service]`: systemd moved these in v229 and only
1532    // accepts them here without a deprecation warning.
1533    out.push_str(&format!(
1534        "StartLimitIntervalSec={}\n",
1535        restart.reset_after().as_secs()
1536    ));
1537    out.push_str(&format!("StartLimitBurst={START_LIMIT_BURST}\n"));
1538
1539    out.push_str("\n[Service]\n");
1540    out.push_str("Type=simple\n");
1541    // A runner is a child of the daemon. In particular, systemd must not
1542    // signal it merely because a cooperative daemon handover replaces the
1543    // manager process; startup recovery supervises any survivor instead.
1544    out.push_str("KillMode=process\n");
1545    out.push_str(&format!("ExecStart={}\n", plan.command_line()));
1546    out.push_str(&format!(
1547        "WorkingDirectory={}\n",
1548        directories.state.display()
1549    ));
1550    out.push_str(&format!("SyslogIdentifier={identity}\n"));
1551    out.push_str("Restart=on-failure\n");
1552    out.push_str(&format!("RestartSec={}\n", restart.delay().as_secs()));
1553
1554    out.push_str("\n# Least privilege. See docs/service-account.md.\n");
1555    for directive in SYSTEMD_HARDENING {
1556        out.push_str(directive);
1557        out.push('\n');
1558    }
1559    let mut writable = directories.all().to_vec();
1560    if let Some(secret_directory) = plan.secret_guard().and_then(Path::parent) {
1561        writable.push(secret_directory);
1562    }
1563    out.push_str(&format!(
1564        "ReadWritePaths={}\n",
1565        writable
1566            .iter()
1567            .map(|path| quote_argument(&path.to_string_lossy()))
1568            .collect::<Vec<_>>()
1569            .join(" ")
1570    ));
1571
1572    out.push_str("\n[Install]\n");
1573    out.push_str(match plan.start_mode() {
1574        StartMode::Boot => "WantedBy=multi-user.target\n",
1575        StartMode::Login => "WantedBy=default.target\n",
1576    });
1577    out
1578}
1579
1580/// The hardening directives every unit carries, in the order they are rendered.
1581///
1582/// A constant rather than a sequence of `push_str` calls so that
1583/// [`review_least_privilege`] can be written against the same list the renderer
1584/// emits: a directive dropped from the unit is a directive the review then
1585/// reports as missing, rather than one that silently stops being checked.
1586pub const SYSTEMD_HARDENING: [&str; 13] = [
1587    "NoNewPrivileges=yes",
1588    "CapabilityBoundingSet=",
1589    "AmbientCapabilities=",
1590    "PrivateTmp=yes",
1591    "PrivateDevices=yes",
1592    "ProtectSystem=strict",
1593    "ProtectKernelTunables=yes",
1594    "ProtectKernelModules=yes",
1595    "ProtectControlGroups=yes",
1596    "RestrictNamespaces=yes",
1597    "RestrictRealtime=yes",
1598    "RestrictSUIDSGID=yes",
1599    "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1600];
1601
1602// -- launchd -----------------------------------------------------------------
1603
1604/// Renders the launchd property list for this plan.
1605///
1606/// `KeepAlive` is a dictionary rather than `<true/>` on purpose: the
1607/// requirement is *restart on failure*, and a bare `KeepAlive` also restarts a
1608/// job that exited cleanly, which would turn a deliberate `service stop` into a
1609/// fight with launchd.
1610#[must_use]
1611pub fn launchd_plist(plan: &InstallPlan) -> String {
1612    let identity = plan.identity();
1613    let directories = plan.directories();
1614    let mut out = String::new();
1615    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
1616    out.push_str(
1617        "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
1618         \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
1619    );
1620    out.push_str("<plist version=\"1.0\">\n<dict>\n");
1621    out.push_str(&plist_string("Label", &identity.launchd_label()));
1622
1623    out.push_str("  <key>ProgramArguments</key>\n  <array>\n");
1624    out.push_str(&format!(
1625        "    <string>{}</string>\n",
1626        xml_escape(&plan.binary().to_string_lossy())
1627    ));
1628    for argument in plan.arguments() {
1629        out.push_str(&format!(
1630            "    <string>{}</string>\n",
1631            xml_escape(&argument.to_string_lossy())
1632        ));
1633    }
1634    out.push_str("  </array>\n");
1635
1636    out.push_str("  <key>RunAtLoad</key>\n  <true/>\n");
1637    out.push_str("  <key>KeepAlive</key>\n  <dict>\n");
1638    out.push_str("    <key>SuccessfulExit</key>\n    <false/>\n");
1639    out.push_str("  </dict>\n");
1640    out.push_str(&format!(
1641        "  <key>ThrottleInterval</key>\n  <integer>{}</integer>\n",
1642        plan.restart().delay().as_secs()
1643    ));
1644    // A background job yields CPU and I/O to whatever the operator is doing.
1645    // Anything above it asks the scheduler for more than a daemon needs.
1646    out.push_str(&plist_string("ProcessType", "Background"));
1647    out.push_str(&plist_string(
1648        "WorkingDirectory",
1649        &directories.state.to_string_lossy(),
1650    ));
1651    out.push_str(&plist_string(
1652        "StandardOutPath",
1653        &directories
1654            .logs
1655            .join("runner-manager.launchd.out.log")
1656            .to_string_lossy(),
1657    ));
1658    out.push_str(&plist_string(
1659        "StandardErrorPath",
1660        &directories
1661            .logs
1662            .join("runner-manager.launchd.err.log")
1663            .to_string_lossy(),
1664    ));
1665
1666    match plan.start_mode() {
1667        StartMode::Boot => {
1668            // A LaunchDaemon runs as root unless told otherwise, and here it
1669            // must: the System Keychain's master key is root-only.
1670            out.push_str(&plist_string(
1671                "UserName",
1672                ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
1673                    .as_str(),
1674            ));
1675            // A daemon has no user session and must not be given one.
1676            out.push_str("  <key>SessionCreate</key>\n  <false/>\n");
1677        }
1678        StartMode::Login => {
1679            // A LaunchAgent already runs as the operator. Naming a `UserName`
1680            // here would be asking launchd for an account switch a login-mode
1681            // registration has no reason to want.
1682        }
1683    }
1684
1685    out.push_str("</dict>\n</plist>\n");
1686    out
1687}
1688
1689/// One `<key>`/`<string>` pair, indented as the rest of the plist is.
1690fn plist_string(key: &str, value: &str) -> String {
1691    format!(
1692        "  <key>{}</key>\n  <string>{}</string>\n",
1693        xml_escape(key),
1694        xml_escape(value)
1695    )
1696}
1697
1698// -- Windows Task Scheduler --------------------------------------------------
1699
1700/// The account a Task Scheduler task runs as.
1701///
1702/// Task Scheduler, unlike the other three managers, needs the principal spelled
1703/// out in the document. Isolating the one impure step keeps
1704/// [`windows_scheduled_task_xml`] a pure function that every CI leg can assert.
1705#[derive(Debug, Clone, PartialEq, Eq)]
1706pub struct TaskPrincipal {
1707    user_id: String,
1708}
1709
1710impl TaskPrincipal {
1711    /// The account running this call, as `DOMAIN\user`.
1712    ///
1713    /// # Errors
1714    ///
1715    /// [`ServiceError::BinaryPath`] is not the right shape here, so this
1716    /// reports [`ServiceError::Control`] naming what the environment failed to
1717    /// say.
1718    pub fn current() -> Result<Self, ServiceError> {
1719        let user = std::env::var("USERNAME")
1720            .ok()
1721            .filter(|value| !value.trim().is_empty());
1722        let Some(user) = user else {
1723            return Err(ServiceError::Control {
1724                operation: "identify the account for",
1725                name: SERVICE_NAME.to_string(),
1726                manager: "Windows Task Scheduler",
1727                detail: "this session reports no %USERNAME%, so there is no principal to \
1728                         register a logon-triggered task for"
1729                    .to_string(),
1730            });
1731        };
1732        let domain = std::env::var("USERDOMAIN")
1733            .ok()
1734            .filter(|value| !value.trim().is_empty());
1735        Ok(Self {
1736            user_id: match domain {
1737                Some(domain) => format!("{domain}\\{user}"),
1738                None => user,
1739            },
1740        })
1741    }
1742
1743    /// A named principal, for tests and for a caller that already knows.
1744    #[must_use]
1745    pub fn named(user_id: impl Into<String>) -> Self {
1746        Self {
1747            user_id: user_id.into(),
1748        }
1749    }
1750
1751    /// `DOMAIN\user`.
1752    #[must_use]
1753    pub fn user_id(&self) -> &str {
1754        &self.user_id
1755    }
1756}
1757
1758/// Renders the Task Scheduler document for a `--start-at login` registration.
1759///
1760/// `RunLevel` is `LeastPrivilege`, which is the whole of Windows' answer to
1761/// item 2 in this domain: the task runs with the operator's ordinary filtered
1762/// token and never with an elevated one, whatever the operator's group
1763/// membership.
1764#[must_use]
1765pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
1766    let identity = plan.identity();
1767    let user = xml_escape(principal.user_id());
1768    let arguments = plan
1769        .arguments()
1770        .iter()
1771        .map(|argument| quote_argument(&argument.to_string_lossy()))
1772        .collect::<Vec<_>>()
1773        .join(" ");
1774    let mut out = String::new();
1775    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
1776    out.push_str(
1777        "<Task version=\"1.4\" \
1778         xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
1779    );
1780    out.push_str("  <RegistrationInfo>\n");
1781    out.push_str(&format!(
1782        "    <Description>{}</Description>\n",
1783        xml_escape(identity.description())
1784    ));
1785    out.push_str(&format!(
1786        "    <URI>\\{}</URI>\n",
1787        xml_escape(identity.name())
1788    ));
1789    out.push_str("  </RegistrationInfo>\n");
1790
1791    out.push_str("  <Triggers>\n    <LogonTrigger>\n");
1792    out.push_str("      <Enabled>true</Enabled>\n");
1793    out.push_str(&format!("      <UserId>{user}</UserId>\n"));
1794    out.push_str("    </LogonTrigger>\n  </Triggers>\n");
1795
1796    out.push_str("  <Principals>\n    <Principal id=\"Author\">\n");
1797    out.push_str(&format!("      <UserId>{user}</UserId>\n"));
1798    out.push_str("      <LogonType>InteractiveToken</LogonType>\n");
1799    out.push_str("      <RunLevel>LeastPrivilege</RunLevel>\n");
1800    out.push_str("    </Principal>\n  </Principals>\n");
1801
1802    out.push_str("  <Settings>\n");
1803    // One agent per host is `d1`'s lock; saying so here means Task Scheduler
1804    // refuses the second start rather than starting a process that then loses
1805    // the race and exits.
1806    out.push_str("    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
1807    out.push_str("    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
1808    out.push_str("    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
1809    out.push_str("    <AllowHardTerminate>true</AllowHardTerminate>\n");
1810    out.push_str("    <StartWhenAvailable>true</StartWhenAvailable>\n");
1811    out.push_str("    <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
1812    out.push_str("    <IdleSettings>\n");
1813    out.push_str("      <StopOnIdleEnd>false</StopOnIdleEnd>\n");
1814    out.push_str("      <RestartOnIdle>false</RestartOnIdle>\n");
1815    out.push_str("    </IdleSettings>\n");
1816    out.push_str("    <AllowStartOnDemand>true</AllowStartOnDemand>\n");
1817    out.push_str("    <Enabled>true</Enabled>\n");
1818    out.push_str("    <Hidden>false</Hidden>\n");
1819    out.push_str("    <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
1820    out.push_str("    <WakeToRun>false</WakeToRun>\n");
1821    // A daemon has no natural end, so any limit here would be a scheduled kill.
1822    out.push_str("    <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
1823    out.push_str("    <Priority>7</Priority>\n");
1824    out.push_str("    <RestartOnFailure>\n");
1825    out.push_str(&format!(
1826        "      <Interval>{}</Interval>\n",
1827        iso8601_minutes(
1828            plan.restart()
1829                .effective_delay(DefinitionKind::WindowsScheduledTask)
1830        )
1831    ));
1832    out.push_str(&format!("      <Count>{START_LIMIT_BURST}</Count>\n"));
1833    out.push_str("    </RestartOnFailure>\n");
1834    out.push_str("  </Settings>\n");
1835
1836    out.push_str("  <Actions Context=\"Author\">\n    <Exec>\n");
1837    out.push_str(&format!(
1838        "      <Command>{}</Command>\n",
1839        xml_escape(&plan.binary().to_string_lossy())
1840    ));
1841    if !arguments.is_empty() {
1842        out.push_str(&format!(
1843            "      <Arguments>{}</Arguments>\n",
1844            xml_escape(&arguments)
1845        ));
1846    }
1847    out.push_str(&format!(
1848        "      <WorkingDirectory>{}</WorkingDirectory>\n",
1849        xml_escape(&plan.directories().state.to_string_lossy())
1850    ));
1851    out.push_str("    </Exec>\n  </Actions>\n");
1852    out.push_str("</Task>\n");
1853    out
1854}
1855
1856/// `PT<n>M`, which is the only shape Task Scheduler accepts for a restart
1857/// interval. See [`RestartPolicy::effective_delay`].
1858fn iso8601_minutes(duration: Duration) -> String {
1859    format!("PT{}M", duration.as_secs() / 60)
1860}
1861
1862/// Escapes the five XML entities. Applied to every value that reaches a plist
1863/// or a task document, because a Windows account name may legitimately contain
1864/// `&` and a path may contain `<`.
1865///
1866/// `pub(crate)` rather than private because [`crate::wsl::task`] renders a
1867/// second kind of Task Scheduler document and must escape it the same way; two
1868/// escapers in one crate is exactly how one of them ends up subtly different.
1869pub(crate) fn xml_escape(value: &str) -> String {
1870    let mut out = String::with_capacity(value.len());
1871    for c in value.chars() {
1872        match c {
1873            '&' => out.push_str("&amp;"),
1874            '<' => out.push_str("&lt;"),
1875            '>' => out.push_str("&gt;"),
1876            '"' => out.push_str("&quot;"),
1877            '\'' => out.push_str("&apos;"),
1878            other => out.push(other),
1879        }
1880    }
1881    out
1882}
1883
1884/// The inverse of [`xml_escape`], for reading a definition back off disk.
1885///
1886/// `&amp;` is replaced last, which is the whole subtlety: replacing it first
1887/// would turn a literal `&amp;amp;` into `&` in two passes instead of `&amp;`
1888/// in one.
1889fn xml_unescape(value: &str) -> String {
1890    value
1891        .replace("&lt;", "<")
1892        .replace("&gt;", ">")
1893        .replace("&quot;", "\"")
1894        .replace("&apos;", "'")
1895        .replace("&amp;", "&")
1896}
1897
1898// -- Windows Service Control Manager -----------------------------------------
1899
1900/// How `CreateServiceW` should be called, in a form that can be reviewed,
1901/// asserted, and printed on any platform.
1902///
1903/// The Windows backend builds its `ServiceInfo` from **this** value rather than
1904/// from the plan, so the descriptor a Linux CI leg asserts and the parameters a
1905/// Windows host registers cannot drift apart.
1906#[derive(Debug, Clone, PartialEq, Eq)]
1907pub struct WindowsServiceSpec {
1908    /// The service name.
1909    pub name: String,
1910    /// What `services.msc` shows.
1911    pub display_name: String,
1912    /// The one-line description.
1913    pub description: String,
1914    /// `true` for `SERVICE_AUTO_START`, `false` for `SERVICE_DEMAND_START`.
1915    pub automatic_start: bool,
1916    /// The account, or `None` for `LocalSystem`, which is what
1917    /// `CreateServiceW` takes a null `lpServiceStartName` to mean.
1918    pub account: Option<String>,
1919    /// The full launch command, quoted.
1920    pub command_line: String,
1921    /// The restart-on-failure policy.
1922    pub restart: RestartPolicy,
1923}
1924
1925/// Derives the Windows service parameters from a plan.
1926#[must_use]
1927pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
1928    WindowsServiceSpec {
1929        name: plan.identity().name().to_string(),
1930        display_name: plan.identity().display_name().to_string(),
1931        description: plan.identity().description().to_string(),
1932        // Only a boot-mode registration is a service at all; a login-mode one
1933        // is a scheduled task. So an automatic start is the only kind here, and
1934        // the field exists because the privileged tests register an on-demand
1935        // fixture rather than one that starts with the test machine.
1936        automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
1937        account: match ServiceAccount::for_definition(
1938            DefinitionKind::WindowsService,
1939            plan.start_mode(),
1940        ) {
1941            // `None` means LocalSystem to `CreateServiceW`, and naming it
1942            // explicitly would be one more string to spell right.
1943            ServiceAccount::LocalSystem => None,
1944            other => Some(other.as_str().to_string()),
1945        },
1946        command_line: plan.command_line(),
1947        restart: plan.restart(),
1948    }
1949}
1950
1951/// Renders the descriptor [`review_least_privilege`] reads and `service status`
1952/// can print.
1953#[must_use]
1954fn windows_service_descriptor(plan: &InstallPlan) -> String {
1955    let spec = windows_service_spec(plan);
1956    let mut out = String::new();
1957    out.push_str("[windows-service]\n");
1958    out.push_str(&format!("Name={}\n", spec.name));
1959    out.push_str(&format!("DisplayName={}\n", spec.display_name));
1960    out.push_str(&format!("Description={}\n", spec.description));
1961    // OWN_PROCESS and never INTERACTIVE_PROCESS: an interactive service would
1962    // put a process this product controls on the operator's desktop, which is
1963    // both deprecated by Windows and more than the requirement asks for.
1964    out.push_str("ServiceType=OWN_PROCESS\n");
1965    out.push_str(&format!(
1966        "StartType={}\n",
1967        if spec.automatic_start {
1968            "AutoStart"
1969        } else {
1970            "OnDemand"
1971        }
1972    ));
1973    out.push_str("ErrorControl=Normal\n");
1974    out.push_str(&format!(
1975        "Account={}\n",
1976        spec.account
1977            .as_deref()
1978            .unwrap_or(ServiceAccount::LocalSystem.as_str())
1979    ));
1980    out.push_str(&format!("CommandLine={}\n", spec.command_line));
1981    out.push_str(&format!(
1982        "FailureActionRestartDelaySecs={}\n",
1983        spec.restart.delay().as_secs()
1984    ));
1985    out.push_str(&format!(
1986        "FailureActionsResetPeriodSecs={}\n",
1987        spec.restart.reset_after().as_secs()
1988    ));
1989    // Without this flag the Service Control Manager applies failure actions
1990    // only to a crash, and a daemon that exits non-zero after failing to reach
1991    // GitHub is not a crash.
1992    out.push_str("FailureActionsOnNonCrashFailures=true\n");
1993    out.push_str(&format!(
1994        "ReadWritePaths={}\n",
1995        plan.directories()
1996            .all()
1997            .iter()
1998            .map(|path| quote_argument(&path.to_string_lossy()))
1999            .collect::<Vec<_>>()
2000            .join(" ")
2001    ));
2002    out
2003}
2004
2005// ---------------------------------------------------------------------------
2006// The least-privilege review
2007// ---------------------------------------------------------------------------
2008
2009/// Whether a finding is about too much authority or too little.
2010#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2011pub enum FindingKind {
2012    /// The definition grants more than `05-infrastructure.md` item 2 asks for.
2013    /// This is what makes a review fail.
2014    Excess,
2015    /// The definition does not grant something the daemon needs, or does not
2016    /// state something the review cannot verify without.
2017    Shortfall,
2018}
2019
2020impl fmt::Display for FindingKind {
2021    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2022        f.write_str(match self {
2023            Self::Excess => "excess",
2024            Self::Shortfall => "shortfall",
2025        })
2026    }
2027}
2028
2029/// One thing the review has to say about a definition.
2030#[derive(Debug, Clone, PartialEq, Eq)]
2031pub struct PrivilegeFinding {
2032    /// Excess or shortfall.
2033    pub kind: FindingKind,
2034    /// The directive, key, or element it is about.
2035    pub subject: String,
2036    /// What is wrong with it, in terms an operator can act on.
2037    pub detail: String,
2038}
2039
2040impl fmt::Display for PrivilegeFinding {
2041    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2042        write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2043    }
2044}
2045
2046/// What a definition actually grants, measured against the requirement.
2047///
2048/// `05-infrastructure.md` item 2 and `07-security.md`'s release gate
2049/// (*"Service account permissions are documented and verified least
2050/// privilege"*) are the same requirement stated twice, and this is the
2051/// verification half. The documentation half is `docs/service-account.md`.
2052///
2053/// The review reads the **rendered text**, not the plan it came from. That is
2054/// deliberate and it is the only version of this check worth having: a review
2055/// that re-derived its expectations from the same values the renderer used
2056/// would agree with the renderer by construction and could never fail. Reading
2057/// the text means a hand-edited unit on a real host is reviewed as it is, and
2058/// means a test can widen a definition and watch this say so.
2059#[derive(Debug, Clone, PartialEq, Eq)]
2060pub struct PrivilegeReview {
2061    kind: DefinitionKind,
2062    account: ServiceAccount,
2063    controls: Vec<String>,
2064    findings: Vec<PrivilegeFinding>,
2065}
2066
2067impl PrivilegeReview {
2068    /// Whether the definition grants nothing beyond the requirement.
2069    #[must_use]
2070    pub fn is_least_privilege(&self) -> bool {
2071        !self
2072            .findings
2073            .iter()
2074            .any(|finding| finding.kind == FindingKind::Excess)
2075    }
2076
2077    /// Everything the review found, excesses and shortfalls together.
2078    #[must_use]
2079    pub fn findings(&self) -> &[PrivilegeFinding] {
2080        &self.findings
2081    }
2082
2083    /// Only the excesses — the findings that make [`Self::is_least_privilege`]
2084    /// false.
2085    #[must_use]
2086    pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2087        self.findings
2088            .iter()
2089            .filter(|finding| finding.kind == FindingKind::Excess)
2090            .collect()
2091    }
2092
2093    /// The controls the definition was confirmed to carry.
2094    ///
2095    /// Present so that a passing review says *what it checked* rather than only
2096    /// that it passed. A check that reports nothing when it succeeds is
2097    /// indistinguishable from a check that did not run.
2098    #[must_use]
2099    pub fn controls(&self) -> &[String] {
2100        &self.controls
2101    }
2102
2103    /// The account the registration runs under, and why it is the minimum.
2104    #[must_use]
2105    pub const fn account(&self) -> &ServiceAccount {
2106        &self.account
2107    }
2108
2109    /// Which platform's definition was reviewed.
2110    #[must_use]
2111    pub const fn kind(&self) -> DefinitionKind {
2112        self.kind
2113    }
2114}
2115
2116impl fmt::Display for PrivilegeReview {
2117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2118        writeln!(
2119            f,
2120            "{} runs as {} ({})",
2121            self.kind,
2122            self.account,
2123            self.account.justification()
2124        )?;
2125        for control in &self.controls {
2126            writeln!(f, "  confirmed  {control}")?;
2127        }
2128        for finding in &self.findings {
2129            writeln!(f, "  {finding}")?;
2130        }
2131        if self.is_least_privilege() {
2132            write!(f, "  verdict    least privilege")
2133        } else {
2134            write!(
2135                f,
2136                "  verdict    NOT least privilege: {} excess(es)",
2137                self.excesses().len()
2138            )
2139        }
2140    }
2141}
2142
2143/// Reads a definition back and reports what it grants.
2144///
2145/// `plan` supplies only the two things the text cannot: the four directories
2146/// the registration is *allowed* to write, and the start mode, which decides
2147/// which account is the minimum.
2148#[must_use]
2149pub fn review_least_privilege(
2150    definition: &ServiceDefinition,
2151    plan: &InstallPlan,
2152) -> PrivilegeReview {
2153    let mut controls = Vec::new();
2154    let mut findings = Vec::new();
2155    match definition.kind() {
2156        DefinitionKind::SystemdUnit => {
2157            review_systemd(definition.text(), plan, &mut controls, &mut findings);
2158        }
2159        DefinitionKind::LaunchdPlist => {
2160            review_launchd(definition.text(), plan, &mut controls, &mut findings);
2161        }
2162        DefinitionKind::WindowsScheduledTask => {
2163            review_scheduled_task(definition.text(), &mut controls, &mut findings);
2164        }
2165        DefinitionKind::WindowsService => {
2166            review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2167        }
2168    }
2169    PrivilegeReview {
2170        kind: definition.kind(),
2171        // The definition's account, not the plan's: a Linux CI leg reviewing the
2172        // Windows descriptor must report `LocalSystem`, which is the account
2173        // that descriptor registers, and not `root`, which is the account this
2174        // leg's own host would use.
2175        account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2176        controls,
2177        findings,
2178    }
2179}
2180
2181/// Every writable path a definition may name, as the strings it names them by.
2182fn permitted_paths(kind: DefinitionKind, plan: &InstallPlan) -> Vec<String> {
2183    let mut permitted = plan
2184        .directories()
2185        .all()
2186        .iter()
2187        .map(|path| path.to_string_lossy().into_owned())
2188        .collect::<Vec<_>>();
2189    if kind == DefinitionKind::SystemdUnit
2190        && let Some(secret_directory) = plan.secret_guard().and_then(Path::parent)
2191    {
2192        permitted.push(secret_directory.to_string_lossy().into_owned());
2193    }
2194    permitted
2195}
2196
2197/// Whether two path spellings name the same place on **this host's**
2198/// filesystem. Case-insensitive on Windows, exact elsewhere.
2199///
2200/// For a rendered definition use [`same_path_for`] instead: which comparison is
2201/// right there follows the platform the definition is *for*, and every leg of
2202/// the CI matrix reviews all four.
2203fn same_path_text(left: &str, right: &str) -> bool {
2204    if cfg!(windows) {
2205        left.eq_ignore_ascii_case(right)
2206    } else {
2207        left == right
2208    }
2209}
2210
2211/// Whether two path spellings name the same place on the platform a given
2212/// definition targets.
2213fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
2214    match kind {
2215        DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
2216            left.eq_ignore_ascii_case(right)
2217        }
2218        DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
2219    }
2220}
2221
2222/// Checks a `ReadWritePaths`-style list against the permitted directories.
2223fn review_writable_paths(
2224    kind: DefinitionKind,
2225    subject: &str,
2226    listed: &[String],
2227    plan: &InstallPlan,
2228    controls: &mut Vec<String>,
2229    findings: &mut Vec<PrivilegeFinding>,
2230) {
2231    let permitted = permitted_paths(kind, plan);
2232    for entry in listed {
2233        if !permitted
2234            .iter()
2235            .any(|allowed| same_path_for(kind, allowed, entry))
2236        {
2237            findings.push(PrivilegeFinding {
2238                kind: FindingKind::Excess,
2239                subject: subject.to_string(),
2240                detail: format!(
2241                    "{entry} is writable but is not one of this registration's required paths"
2242                ),
2243            });
2244        }
2245    }
2246    for allowed in &permitted {
2247        if !listed
2248            .iter()
2249            .any(|entry| same_path_for(kind, allowed, entry))
2250        {
2251            findings.push(PrivilegeFinding {
2252                kind: FindingKind::Shortfall,
2253                subject: subject.to_string(),
2254                detail: format!(
2255                    "{allowed} is one of this registration's directories but is not writable, \
2256                     so the daemon cannot use it"
2257                ),
2258            });
2259        }
2260    }
2261    if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
2262        controls.push(format!(
2263            "{subject} names exactly the required application-data and credential paths"
2264        ));
2265    }
2266}
2267
2268/// The one inbound-surface rule, applied to whichever platform names it.
2269///
2270/// `07-security.md` handling rule 2: *"The product exposes no inbound HTTP,
2271/// socket, or RPC surface anywhere, in any command."* A definition that asks the
2272/// service manager to open a socket or publish a Mach service on the daemon's
2273/// behalf would create exactly that surface without a line of product code
2274/// changing, which is why it is checked here and not only in review.
2275fn review_inbound_surface(
2276    text: &str,
2277    markers: &[(&str, &str)],
2278    controls: &mut Vec<String>,
2279    findings: &mut Vec<PrivilegeFinding>,
2280) {
2281    let mut clean = true;
2282    for (marker, detail) in markers {
2283        if text.contains(marker) {
2284            clean = false;
2285            findings.push(PrivilegeFinding {
2286                kind: FindingKind::Excess,
2287                subject: (*marker).to_string(),
2288                detail: (*detail).to_string(),
2289            });
2290        }
2291    }
2292    if clean {
2293        controls.push(
2294            "no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
2295        );
2296    }
2297}
2298
2299fn review_systemd(
2300    text: &str,
2301    plan: &InstallPlan,
2302    controls: &mut Vec<String>,
2303    findings: &mut Vec<PrivilegeFinding>,
2304) {
2305    let directives = ini_directives(text, "Service");
2306    for expected in SYSTEMD_HARDENING {
2307        let (key, value) = expected
2308            .split_once('=')
2309            .expect("every hardening directive is written as key=value");
2310        match directives.get(key) {
2311            Some(actual) if actual == value => controls.push((*expected).to_string()),
2312            Some(actual) => findings.push(PrivilegeFinding {
2313                kind: FindingKind::Excess,
2314                subject: key.to_string(),
2315                detail: format!(
2316                    "is `{actual}`, not `{value}`, so the unit keeps authority the \
2317                                 requirement does not ask for"
2318                ),
2319            }),
2320            None => findings.push(PrivilegeFinding {
2321                kind: FindingKind::Excess,
2322                subject: key.to_string(),
2323                detail: format!(
2324                    "is absent, so the unit inherits systemd's default rather than `{value}`"
2325                ),
2326            }),
2327        }
2328    }
2329
2330    match directives.get("ReadWritePaths") {
2331        Some(value) => {
2332            let listed = split_quoted(value);
2333            review_writable_paths(
2334                DefinitionKind::SystemdUnit,
2335                "ReadWritePaths",
2336                &listed,
2337                plan,
2338                controls,
2339                findings,
2340            );
2341        }
2342        None => findings.push(PrivilegeFinding {
2343            kind: FindingKind::Shortfall,
2344            subject: "ReadWritePaths".to_string(),
2345            detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
2346                .to_string(),
2347        }),
2348    }
2349
2350    // `User=` on a system unit is an escalation only in the other direction, so
2351    // what is checked is the pair systemd actually treats as privileged.
2352    if directives.contains_key("PrivateUsers")
2353        && directives.get("PrivateUsers") == Some(&"no".to_string())
2354    {
2355        findings.push(PrivilegeFinding {
2356            kind: FindingKind::Excess,
2357            subject: "PrivateUsers".to_string(),
2358            detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
2359                .to_string(),
2360        });
2361    }
2362
2363    review_inbound_surface(
2364        text,
2365        &[
2366            (
2367                "ListenStream=",
2368                "asks systemd to open a listening socket for this service, which \
2369                 07-security.md rule 2 forbids the product to have",
2370            ),
2371            (
2372                "ListenDatagram=",
2373                "asks systemd to open a listening socket for this service, which \
2374                 07-security.md rule 2 forbids the product to have",
2375            ),
2376        ],
2377        controls,
2378        findings,
2379    );
2380}
2381
2382fn review_launchd(
2383    text: &str,
2384    plan: &InstallPlan,
2385    controls: &mut Vec<String>,
2386    findings: &mut Vec<PrivilegeFinding>,
2387) {
2388    match plist_string_value(text, "ProcessType").as_deref() {
2389        Some("Background") => controls.push("ProcessType=Background".to_string()),
2390        Some(other) => findings.push(PrivilegeFinding {
2391            kind: FindingKind::Excess,
2392            subject: "ProcessType".to_string(),
2393            detail: format!(
2394                "is `{other}`, which asks the scheduler for more CPU and I/O than a background \
2395                 daemon needs"
2396            ),
2397        }),
2398        None => findings.push(PrivilegeFinding {
2399            kind: FindingKind::Excess,
2400            subject: "ProcessType".to_string(),
2401            detail: "is absent, so launchd applies its `Standard` default rather than \
2402                     `Background`"
2403                .to_string(),
2404        }),
2405    }
2406
2407    match plan.start_mode() {
2408        StartMode::Boot => {
2409            if plist_bool_value(text, "SessionCreate") == Some(true) {
2410                findings.push(PrivilegeFinding {
2411                    kind: FindingKind::Excess,
2412                    subject: "SessionCreate".to_string(),
2413                    detail: "asks launchd to create a security session for a job that runs \
2414                             outside every login session and has no use for one"
2415                        .to_string(),
2416                });
2417            } else {
2418                controls.push("SessionCreate is not requested".to_string());
2419            }
2420            match plist_string_value(text, "UserName").as_deref() {
2421                Some("root") => {
2422                    controls.push("UserName=root, stated rather than inherited".to_string())
2423                }
2424                Some(other) => findings.push(PrivilegeFinding {
2425                    kind: FindingKind::Shortfall,
2426                    subject: "UserName".to_string(),
2427                    detail: format!(
2428                        "is `{other}`, which cannot unlock the System Keychain: \
2429                         /var/db/SystemKey is root-only, so the daemon would start and then \
2430                         find no credential"
2431                    ),
2432                }),
2433                None => findings.push(PrivilegeFinding {
2434                    kind: FindingKind::Shortfall,
2435                    subject: "UserName".to_string(),
2436                    detail: "is absent, so the account is launchd's implicit default and this \
2437                             review cannot confirm it"
2438                        .to_string(),
2439                }),
2440            }
2441        }
2442        StartMode::Login => {
2443            if let Some(named) = plist_string_value(text, "UserName") {
2444                findings.push(PrivilegeFinding {
2445                    kind: FindingKind::Excess,
2446                    subject: "UserName".to_string(),
2447                    detail: format!(
2448                        "names `{named}` in a LaunchAgent, which already runs as the operator; \
2449                         naming an account here asks launchd for a switch a login-mode \
2450                         registration has no reason to want"
2451                    ),
2452                });
2453            } else {
2454                controls
2455                    .push("no UserName: the agent runs as the operator and no other".to_string());
2456            }
2457        }
2458    }
2459
2460    review_inbound_surface(
2461        text,
2462        &[
2463            (
2464                "<key>Sockets</key>",
2465                "asks launchd to open a socket for this job, which 07-security.md rule 2 \
2466                 forbids the product to have",
2467            ),
2468            (
2469                "<key>MachServices</key>",
2470                "publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
2471                 forbids the product to have",
2472            ),
2473        ],
2474        controls,
2475        findings,
2476    );
2477}
2478
2479fn review_scheduled_task(
2480    text: &str,
2481    controls: &mut Vec<String>,
2482    findings: &mut Vec<PrivilegeFinding>,
2483) {
2484    match xml_value(text, "RunLevel").as_deref() {
2485        Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
2486        Some(other) => findings.push(PrivilegeFinding {
2487            kind: FindingKind::Excess,
2488            subject: "RunLevel".to_string(),
2489            detail: format!(
2490                "is `{other}`, so the task runs with an elevated token whenever the operator is \
2491                 an administrator"
2492            ),
2493        }),
2494        None => findings.push(PrivilegeFinding {
2495            kind: FindingKind::Excess,
2496            subject: "RunLevel".to_string(),
2497            detail: "is absent, so Task Scheduler decides the token rather than the definition"
2498                .to_string(),
2499        }),
2500    }
2501
2502    match xml_value(text, "LogonType").as_deref() {
2503        Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
2504        Some(other) => findings.push(PrivilegeFinding {
2505            kind: FindingKind::Excess,
2506            subject: "LogonType".to_string(),
2507            detail: format!(
2508                "is `{other}`, which asks Windows to store or synthesise a credential for this \
2509                 task; an interactive token needs neither"
2510            ),
2511        }),
2512        None => findings.push(PrivilegeFinding {
2513            kind: FindingKind::Shortfall,
2514            subject: "LogonType".to_string(),
2515            detail: "is absent, so this review cannot confirm that no credential is stored"
2516                .to_string(),
2517        }),
2518    }
2519}
2520
2521fn review_windows_service(
2522    text: &str,
2523    plan: &InstallPlan,
2524    controls: &mut Vec<String>,
2525    findings: &mut Vec<PrivilegeFinding>,
2526) {
2527    let directives = ini_directives(text, "windows-service");
2528
2529    match directives.get("ServiceType").map(String::as_str) {
2530        Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
2531        Some(other) => findings.push(PrivilegeFinding {
2532            kind: FindingKind::Excess,
2533            subject: "ServiceType".to_string(),
2534            detail: format!(
2535                "is `{other}`; an interactive or shared-process service reaches further than a \
2536                 daemon that only talks to GitHub over HTTPS"
2537            ),
2538        }),
2539        None => findings.push(PrivilegeFinding {
2540            kind: FindingKind::Shortfall,
2541            subject: "ServiceType".to_string(),
2542            detail: "is absent, so this review cannot confirm the service is not interactive"
2543                .to_string(),
2544        }),
2545    }
2546
2547    // The account is not a free choice on Windows: `d2`'s DACL decides it. What
2548    // the review can check is that the definition names the one account that
2549    // DACL admits, and no broader one.
2550    match directives.get("Account").map(String::as_str) {
2551        Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
2552            controls.push(format!(
2553                "Account={account}: the only stock account the machine-scoped store's DACL \
2554                 (SY, BA, OW) admits"
2555            ));
2556        }
2557        Some(other) => findings.push(PrivilegeFinding {
2558            kind: FindingKind::Shortfall,
2559            subject: "Account".to_string(),
2560            detail: format!(
2561                "is `{other}`, which the machine-scoped store's DACL does not name, so the \
2562                 daemon would start and then find no credential. Widening that DACL is not this \
2563                 registration's to do: an ACE reaching `{other}` would also reach every other \
2564                 service running under it"
2565            ),
2566        }),
2567        None => findings.push(PrivilegeFinding {
2568            kind: FindingKind::Shortfall,
2569            subject: "Account".to_string(),
2570            detail: "is absent, so this review cannot confirm which account was registered"
2571                .to_string(),
2572        }),
2573    }
2574
2575    match directives.get("ReadWritePaths") {
2576        Some(value) => {
2577            let listed = split_quoted(value);
2578            review_writable_paths(
2579                DefinitionKind::WindowsService,
2580                "ReadWritePaths",
2581                &listed,
2582                plan,
2583                controls,
2584                findings,
2585            );
2586        }
2587        None => findings.push(PrivilegeFinding {
2588            kind: FindingKind::Shortfall,
2589            subject: "ReadWritePaths".to_string(),
2590            detail: "is absent, so the directories the service was installed against are not \
2591                     recorded"
2592                .to_string(),
2593        }),
2594    }
2595}
2596
2597// -- parsing helpers ---------------------------------------------------------
2598
2599/// Reads `key=value` lines out of one `[section]` of an INI-shaped document.
2600///
2601/// systemd's unit format and this module's Windows descriptor are both this
2602/// shape. A repeated key takes its last value, which is systemd's own rule for
2603/// every directive here.
2604fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
2605    let mut out = BTreeMap::new();
2606    let mut inside = false;
2607    for line in text.lines() {
2608        let line = line.trim();
2609        if line.starts_with('[') && line.ends_with(']') {
2610            inside = &line[1..line.len() - 1] == section;
2611            continue;
2612        }
2613        if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2614            continue;
2615        }
2616        if let Some((key, value)) = line.split_once('=') {
2617            out.insert(key.trim().to_string(), value.trim().to_string());
2618        }
2619    }
2620    out
2621}
2622
2623/// Splits a whitespace-separated list that may contain arguments quoted by
2624/// [`quote_argument`].
2625fn split_quoted(value: &str) -> Vec<String> {
2626    let mut out = Vec::new();
2627    let mut rest = value.trim();
2628    while !rest.is_empty() {
2629        if rest.starts_with('"') {
2630            // `executable_from_command_line` already implements Windows' quoting
2631            // rules; reuse it rather than write a second, subtly different one.
2632            if let Some(parsed) = executable_from_command_line(rest) {
2633                out.push(parsed.to_string_lossy().into_owned());
2634            }
2635            // Advance past the closing quote.
2636            let mut depth = 0usize;
2637            let mut end = rest.len();
2638            for (index, c) in rest.char_indices() {
2639                match c {
2640                    '\\' => depth += 1,
2641                    '"' => {
2642                        if depth.is_multiple_of(2) && index > 0 {
2643                            end = index + 1;
2644                            break;
2645                        }
2646                        depth = 0;
2647                    }
2648                    _ => depth = 0,
2649                }
2650            }
2651            rest = rest[end..].trim_start();
2652        } else {
2653            let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
2654            out.push(rest[..end].to_string());
2655            rest = rest[end..].trim_start();
2656        }
2657    }
2658    out
2659}
2660
2661/// The text between the **first** `<tag>` and its `</tag>`, unescaped, for the
2662/// small, known documents this module renders. Not a general XML parser and
2663/// does not pretend to be one.
2664///
2665/// "First" matters for a document that repeats a tag: a Task Scheduler task has
2666/// an `<Enabled>` in its trigger *and* one in its settings, so a caller after
2667/// the second one passes the slice that starts at `<Settings>`.
2668///
2669/// `pub(crate)` rather than private because [`crate::wsl::task`] reads back a
2670/// second kind of Task Scheduler document; one reader for both is the same
2671/// argument [`xml_escape`] makes for one escaper.
2672pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
2673    let open = format!("<{tag}>");
2674    let close = format!("</{tag}>");
2675    let start = text.find(&open)? + open.len();
2676    let end = text[start..].find(&close)? + start;
2677    Some(xml_unescape(text[start..end].trim()))
2678}
2679
2680/// Whether Task Scheduler will launch a login task automatically.
2681///
2682/// Its `/Query /XML` output normalises away values that equal the schema
2683/// default. In particular, an enabled task commonly has no `<Enabled>` node at
2684/// all, so only an explicit `false` means disabled.
2685#[cfg(any(windows, test))]
2686fn windows_login_task_starts_automatically(document: &str) -> bool {
2687    document.contains("<LogonTrigger>")
2688        && xml_value(document, "Enabled").as_deref() != Some("false")
2689}
2690
2691/// The `<string>` that follows `<key>key</key>` in a property list.
2692fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
2693    let marker = format!("<key>{key}</key>");
2694    let start = text.find(&marker)? + marker.len();
2695    Some(text[start..].trim_start())
2696}
2697
2698fn plist_string_value(text: &str, key: &str) -> Option<String> {
2699    let rest = plist_value_after_key(text, key)?;
2700    if !rest.starts_with("<string>") {
2701        return None;
2702    }
2703    xml_value(rest, "string")
2704}
2705
2706fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
2707    let rest = plist_value_after_key(text, key)?;
2708    if rest.starts_with("<true/>") {
2709        Some(true)
2710    } else if rest.starts_with("<false/>") {
2711        Some(false)
2712    } else {
2713        None
2714    }
2715}
2716
2717// ---------------------------------------------------------------------------
2718// The install record
2719// ---------------------------------------------------------------------------
2720
2721/// The version of [`InstallRecord`] on disk.
2722///
2723/// Bumped when a record written by an older version can no longer be read.
2724/// `service status` reports an unreadable record as a problem with a remedy
2725/// rather than failing, because the remedy — uninstall and install again —
2726/// touches no configuration, no secret and no cache.
2727pub const RECORD_SCHEMA_VERSION: u32 = 1;
2728
2729/// What `service install` wrote down, and what `service status` reads back.
2730///
2731/// This is the *record* half of `05-infrastructure.md` item 6. The service
2732/// manager also knows the binary path, and the two are compared: a registration
2733/// whose command line no longer matches the record is a registration something
2734/// else has edited, and saying so is more useful than picking one of them to
2735/// believe.
2736///
2737/// It holds no credential. The nearest thing is the *location* of the secret
2738/// store, which `d2` publishes for `host show` to print, and even that is
2739/// derived rather than stored.
2740#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2741pub struct InstallRecord {
2742    /// [`RECORD_SCHEMA_VERSION`] at the time of writing.
2743    pub schema_version: u32,
2744    /// What the operating system calls the registration.
2745    pub service_name: String,
2746    /// Which service manager holds it.
2747    pub manager: String,
2748    /// Boot or login.
2749    pub start_mode: StartMode,
2750    /// The account it runs under.
2751    pub account: ServiceAccount,
2752    /// **The resolved absolute path of the binary at install time.** Item 6.
2753    pub binary: PathBuf,
2754    /// Where [`Self::binary`] was copied from, when it is a copy.
2755    ///
2756    /// `#[serde(default)]` rather than a schema bump: a record written before
2757    /// this existed is still readable, and reads as `None`. That is not a
2758    /// silent downgrade — `None` means the registration names a package
2759    /// manager's own file, which is the layout that cannot be upgraded while
2760    /// the service runs, and `service status` says so by name.
2761    #[serde(default)]
2762    pub source_binary: Option<PathBuf>,
2763    /// The arguments it was registered with.
2764    pub arguments: Vec<String>,
2765    /// The restart-on-failure delay.
2766    pub restart_delay_secs: u64,
2767    /// How long it must run before the failure count resets.
2768    pub restart_reset_secs: u64,
2769    /// The rotating diagnostic log's stem. Item 4.
2770    pub log_file: PathBuf,
2771    /// Whether the manager was asked not to start this by itself. Always
2772    /// `false` for a registration an operator made; see
2773    /// [`InstallRequest::started_on_demand`].
2774    ///
2775    /// `#[serde(default)]` so that a record missing the field is rejected by
2776    /// the schema check below, with its remedy, rather than by a parse error
2777    /// that names a field an operator has never heard of.
2778    #[serde(default)]
2779    pub starts_on_demand: bool,
2780    /// Where the platform's definition file was written, when there is one.
2781    pub definition_path: Option<PathBuf>,
2782    /// When the registration was made.
2783    pub installed_at: DateTime<Utc>,
2784    /// Which build of the product made it.
2785    pub installed_by_version: String,
2786    // A TOML table has to follow every scalar at its level, so this field is
2787    // last by necessity rather than by taste.
2788    /// The four directories the registration was installed against. Item 2's
2789    /// *"configured cache and runtime directories"*.
2790    pub directories: ServiceDirectories,
2791}
2792
2793impl InstallRecord {
2794    /// Where the record lives, given this host's directories.
2795    #[must_use]
2796    pub fn path(paths: &AppPaths) -> PathBuf {
2797        paths.config_dir().join(RECORD_FILE)
2798    }
2799
2800    /// Builds the record for a plan that has just been applied.
2801    #[must_use]
2802    pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
2803        Self {
2804            schema_version: RECORD_SCHEMA_VERSION,
2805            service_name: plan.identity().name().to_string(),
2806            manager: definition.kind().manager().to_string(),
2807            start_mode: plan.start_mode(),
2808            account: plan.account().clone(),
2809            binary: plan.binary().to_path_buf(),
2810            arguments: plan
2811                .arguments()
2812                .iter()
2813                .map(|argument| argument.to_string_lossy().into_owned())
2814                .collect(),
2815            restart_delay_secs: plan.restart().delay().as_secs(),
2816            restart_reset_secs: plan.restart().reset_after().as_secs(),
2817            starts_on_demand: plan.is_on_demand(),
2818            source_binary: plan.source_binary().map(Path::to_path_buf),
2819            log_file: plan.directories().log_file(),
2820            definition_path: definition.install_path().map(Path::to_path_buf),
2821            installed_at: at,
2822            installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
2823            directories: plan.directories().clone(),
2824        }
2825    }
2826
2827    /// The plan this record describes, as this build would register it.
2828    ///
2829    /// The directories, binary, arguments and restart policy are the recorded
2830    /// ones; everything else -- hardening, the secret store's guard -- is what
2831    /// this build renders. That difference is the point: it is how a
2832    /// registration written by an older build is compared against, and
2833    /// repaired to, the current definition.
2834    #[must_use]
2835    pub fn plan(&self, identity: ServiceIdentity) -> InstallPlan {
2836        let plan = InstallPlan::unchecked(
2837            identity,
2838            self.start_mode,
2839            self.binary.clone(),
2840            self.directories.clone(),
2841        )
2842        .with_arguments(self.arguments.clone())
2843        .with_restart(self.restart());
2844        let plan = if self.starts_on_demand {
2845            plan.started_on_demand()
2846        } else {
2847            plan
2848        };
2849        match crate::secrets::PlatformSecretStore::for_start_mode(self.start_mode) {
2850            Ok(store) => plan.with_secret_guard(store.guard()),
2851            Err(_) => plan,
2852        }
2853    }
2854
2855    /// The restart policy this record describes, or [`RestartPolicy::default`]
2856    /// when the recorded numbers are outside the supported range — which can
2857    /// only happen to a record something else has edited.
2858    #[must_use]
2859    pub fn restart(&self) -> RestartPolicy {
2860        RestartPolicy::new(
2861            Duration::from_secs(self.restart_delay_secs),
2862            Duration::from_secs(self.restart_reset_secs),
2863        )
2864        .unwrap_or_default()
2865    }
2866
2867    /// Reads the record, or reports that there is none.
2868    ///
2869    /// `Ok(None)` means no registration was made by this product on this host.
2870    ///
2871    /// # Errors
2872    ///
2873    /// [`ServiceError::Record`] when the file exists and cannot be read,
2874    /// [`ServiceError::RecordNotPermitted`] when it exists and this account may
2875    /// not read it, and [`ServiceError::RecordUnreadable`] when it is not a
2876    /// record this version understands.
2877    pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
2878        let path = Self::path(paths);
2879        let text = match std::fs::read_to_string(&path) {
2880            Ok(text) => text,
2881            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2882            // Told apart from every other read failure because `status` carries
2883            // on through this one alone. See `ServiceError::RecordNotPermitted`.
2884            Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
2885                return Err(ServiceError::RecordNotPermitted {
2886                    path,
2887                    detail: error.to_string(),
2888                });
2889            }
2890            Err(error) => {
2891                return Err(ServiceError::Record {
2892                    operation: "read",
2893                    path,
2894                    detail: error.to_string(),
2895                });
2896            }
2897        };
2898        let record: Self =
2899            toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
2900                path: path.clone(),
2901                detail: error.to_string(),
2902            })?;
2903        if record.schema_version != RECORD_SCHEMA_VERSION {
2904            return Err(ServiceError::RecordUnreadable {
2905                path,
2906                detail: format!(
2907                    "it declares schema version {} and this build reads version {}",
2908                    record.schema_version, RECORD_SCHEMA_VERSION
2909                ),
2910            });
2911        }
2912        Ok(Some(record))
2913    }
2914
2915    /// Writes the record, replacing whatever was there.
2916    ///
2917    /// # Errors
2918    ///
2919    /// [`ServiceError::Record`].
2920    pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
2921        use std::io::Write as _;
2922
2923        let path = Self::path(paths);
2924        let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
2925            operation: "encode",
2926            path: path.clone(),
2927            detail: error.to_string(),
2928        })?;
2929        if let Some(parent) = path.parent() {
2930            std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
2931                operation: "write",
2932                path: path.clone(),
2933                detail: error.to_string(),
2934            })?;
2935        }
2936        let parent = path.parent().ok_or_else(|| ServiceError::Record {
2937            operation: "write",
2938            path: path.clone(),
2939            detail: "the record path has no parent directory".to_string(),
2940        })?;
2941        let mut temporary =
2942            tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
2943                operation: "write",
2944                path: path.clone(),
2945                detail: error.to_string(),
2946            })?;
2947        temporary
2948            .write_all(text.as_bytes())
2949            .and_then(|()| temporary.as_file().sync_all())
2950            .map_err(|error| ServiceError::Record {
2951                operation: "write",
2952                path: path.clone(),
2953                detail: error.to_string(),
2954            })?;
2955        // ------------------------------------------------------------------
2956        // THE RECORD IS READABLE BY THE ACCOUNT WHOSE DIRECTORY IT IS IN.
2957        // ------------------------------------------------------------------
2958        // `NamedTempFile` creates at `0600`, which is right for a temporary
2959        // file and wrong for this one. A boot-mode `service install` runs under
2960        // `sudo`, so `0600` made the record `root`-owned and unreadable by the
2961        // operator — and `service status`, which asks no privilege of anybody,
2962        // failed on their own host with `Permission denied` and a remedy that
2963        // named the command that had just failed.
2964        //
2965        // `0644` does not widen anything: `RECORD_FILE`'s own documentation is
2966        // that this is *"non-secret TOML"* holding no credential, and
2967        // `AppPaths::create_all` puts it in a `0700` directory, so no other
2968        // local account can reach it whatever its own mode says. It is the same
2969        // arrangement the rotating diagnostics already have.
2970        #[cfg(unix)]
2971        {
2972            use std::os::unix::fs::PermissionsExt as _;
2973
2974            temporary
2975                .as_file()
2976                .set_permissions(std::fs::Permissions::from_mode(0o644))
2977                .map_err(|error| ServiceError::Record {
2978                    operation: "write",
2979                    path: path.clone(),
2980                    detail: error.to_string(),
2981                })?;
2982        }
2983        temporary
2984            .persist(&path)
2985            .map(|_| ())
2986            .map_err(|error| ServiceError::Record {
2987                operation: "write",
2988                path,
2989                detail: error.error.to_string(),
2990            })
2991    }
2992
2993    /// Removes the record. Returns whether there was one.
2994    ///
2995    /// # Errors
2996    ///
2997    /// [`ServiceError::Record`].
2998    pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
2999        let path = Self::path(paths);
3000        match std::fs::remove_file(&path) {
3001            Ok(()) => Ok(true),
3002            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
3003            Err(error) => Err(ServiceError::Record {
3004                operation: "remove",
3005                path,
3006                detail: error.to_string(),
3007            }),
3008        }
3009    }
3010}
3011
3012// ---------------------------------------------------------------------------
3013// The last successful GitHub contact
3014// ---------------------------------------------------------------------------
3015
3016/// The heartbeat file's own schema version.
3017const CONTACT_SCHEMA_VERSION: u32 = 1;
3018
3019#[derive(Debug, Clone, Serialize, Deserialize)]
3020struct ContactRecord {
3021    schema_version: u32,
3022    last_success: DateTime<Utc>,
3023}
3024
3025/// Records that GitHub was reached successfully, for `service status` to
3026/// report.
3027///
3028/// # The contract, for `f3` and for the agent
3029///
3030/// Journey 5 step 4 requires `service status` to report *"the last successful
3031/// GitHub contact"*, and `service status` runs in the operator's terminal while
3032/// the daemon runs in a service. They share no memory, so the fact has to be on
3033/// disk, and this is the file. **The daemon calls this after each successful
3034/// GitHub call; nothing else writes it.**
3035///
3036/// It is a single timestamp and deliberately not a log: an operator asking this
3037/// question wants to know whether the agent is alive and reaching GitHub *now*,
3038/// and a value that is minutes old answers it. Writing it is a whole-file
3039/// replace through a temporary in the same directory, so a status command can
3040/// never read a half-written timestamp.
3041///
3042/// # Errors
3043///
3044/// [`ServiceError::Record`] when `state/` cannot be written.
3045pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3046    let path = contact_path(paths);
3047    let record = ContactRecord {
3048        schema_version: CONTACT_SCHEMA_VERSION,
3049        last_success: at,
3050    };
3051    let failed = |detail: String| ServiceError::Record {
3052        operation: "write",
3053        path: path.clone(),
3054        detail,
3055    };
3056    let text = toml::to_string_pretty(&record).map_err(|error| failed(error.to_string()))?;
3057    let directory = path.parent().unwrap_or_else(|| Path::new("."));
3058    std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3059    let temporary = path.with_extension("toml.new");
3060    std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3061    std::fs::rename(&temporary, &path).map_err(|error| failed(error.to_string()))
3062}
3063
3064/// Reads the last successful GitHub contact, or reports that none was recorded.
3065///
3066/// `Ok(None)` is the honest answer on a host whose agent has never run, and it
3067/// is what `service status` prints rather than a zero timestamp.
3068///
3069/// # Errors
3070///
3071/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3072/// A malformed heartbeat is reported rather than treated as absence: absence
3073/// means *"the agent has never reached GitHub"*, and reporting a parse failure
3074/// as that would be a wrong answer to the question Journey 5 asks.
3075pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3076    let path = contact_path(paths);
3077    let text = match std::fs::read_to_string(&path) {
3078        Ok(text) => text,
3079        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3080        Err(error) => {
3081            return Err(ServiceError::Record {
3082                operation: "read",
3083                path,
3084                detail: error.to_string(),
3085            });
3086        }
3087    };
3088    let record: ContactRecord = toml::from_str(&text).map_err(|error| ServiceError::Record {
3089        operation: "read",
3090        path,
3091        detail: error.to_string(),
3092    })?;
3093    Ok(Some(record.last_success))
3094}
3095
3096/// Where the heartbeat lives.
3097#[must_use]
3098pub fn contact_path(paths: &AppPaths) -> PathBuf {
3099    paths.state_dir().join(CONTACT_FILE)
3100}
3101
3102// ---------------------------------------------------------------------------
3103// The credential GitHub rejected
3104// ---------------------------------------------------------------------------
3105
3106#[derive(Debug, Serialize, Deserialize)]
3107#[serde(deny_unknown_fields)]
3108struct CredentialRejectionRecord {
3109    schema_version: u32,
3110    rejected_since: DateTime<Utc>,
3111}
3112
3113/// Records that GitHub rejected the daemon's credential, keeping the first
3114/// moment of an ongoing rejection.
3115///
3116/// # Why a file of its own
3117///
3118/// A rejected credential used to be visible only in the daemon's log: the
3119/// contact heartbeat simply stopped moving, `service status` still printed
3120/// `healthy`, and the TUI -- which authenticates with its own, perhaps freshly
3121/// renewed, copy -- showed nothing at all while the service started no runner
3122/// for hours. Separate from [`CONTACT_FILE`] so that an older build reading
3123/// that file is not handed a field it refuses.
3124///
3125/// # Errors
3126///
3127/// [`ServiceError::Record`] when `state/` cannot be written.
3128pub fn record_github_credential_rejected(
3129    paths: &AppPaths,
3130    at: DateTime<Utc>,
3131) -> Result<(), ServiceError> {
3132    if github_credential_rejected_since(paths)
3133        .ok()
3134        .flatten()
3135        .is_some()
3136    {
3137        return Ok(());
3138    }
3139    let path = credential_rejection_path(paths);
3140    let record = CredentialRejectionRecord {
3141        schema_version: CONTACT_SCHEMA_VERSION,
3142        rejected_since: at,
3143    };
3144    let failed = |detail: String| ServiceError::Record {
3145        operation: "write",
3146        path: path.clone(),
3147        detail,
3148    };
3149    let text = toml::to_string_pretty(&record).map_err(|error| failed(error.to_string()))?;
3150    let directory = path.parent().unwrap_or_else(|| Path::new("."));
3151    std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3152    let temporary = path.with_extension("toml.new");
3153    std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3154    std::fs::rename(&temporary, &path).map_err(|error| failed(error.to_string()))
3155}
3156
3157/// Clears a recorded rejection once GitHub accepts the credential again.
3158///
3159/// # Errors
3160///
3161/// [`ServiceError::Record`] when the record exists and cannot be removed.
3162pub fn clear_github_credential_rejection(paths: &AppPaths) -> Result<(), ServiceError> {
3163    let path = credential_rejection_path(paths);
3164    match std::fs::remove_file(&path) {
3165        Ok(()) => Ok(()),
3166        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3167        Err(error) => Err(ServiceError::Record {
3168            operation: "remove",
3169            path,
3170            detail: error.to_string(),
3171        }),
3172    }
3173}
3174
3175/// Since when GitHub has been rejecting the daemon's credential, if it is.
3176///
3177/// # Errors
3178///
3179/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3180pub fn github_credential_rejected_since(
3181    paths: &AppPaths,
3182) -> Result<Option<DateTime<Utc>>, ServiceError> {
3183    let path = credential_rejection_path(paths);
3184    let text = match std::fs::read_to_string(&path) {
3185        Ok(text) => text,
3186        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3187        Err(error) => {
3188            return Err(ServiceError::Record {
3189                operation: "read",
3190                path,
3191                detail: error.to_string(),
3192            });
3193        }
3194    };
3195    let record: CredentialRejectionRecord =
3196        toml::from_str(&text).map_err(|error| ServiceError::Record {
3197            operation: "read",
3198            path,
3199            detail: error.to_string(),
3200        })?;
3201    Ok(Some(record.rejected_since))
3202}
3203
3204/// Where the rejection record lives.
3205#[must_use]
3206pub fn credential_rejection_path(paths: &AppPaths) -> PathBuf {
3207    paths.state_dir().join(CREDENTIAL_REJECTION_FILE)
3208}
3209
3210// ---------------------------------------------------------------------------
3211// A definition written by an older build
3212// ---------------------------------------------------------------------------
3213
3214/// An installed service definition that differs from what this build renders
3215/// for the same recorded installation.
3216///
3217/// # Why this exists
3218///
3219/// An upgrade replaces the binary a registration runs and nothing else. A
3220/// systemd unit written by 0.4.7 still carried `LoadCredential=`, which hands
3221/// the daemon a copy of the credential frozen at service start, and lacked
3222/// write access to the credential store. Every release after the fix kept
3223/// running under that unit: the daemon could neither renew the token nor see
3224/// one renewed by anybody else, and stopped starting runners the moment the
3225/// frozen access token expired.
3226#[derive(Debug, Clone, PartialEq, Eq)]
3227pub struct DefinitionDrift {
3228    /// The definition file on disk.
3229    pub path: PathBuf,
3230    /// The build that wrote it, as its install record says.
3231    pub installed_by_version: String,
3232    /// What this build renders in its place.
3233    pub rendered: String,
3234    /// The start mode it was installed for.
3235    pub start_mode: StartMode,
3236}
3237
3238/// Compares the installed systemd unit against this build's rendering.
3239///
3240/// Limited to systemd, whose unit is a plain file this module renders byte
3241/// for byte; `None` for every other manager, for a definition that cannot be
3242/// read, and for one that already matches. The file is read through
3243/// `controls`, so a double never compares against a real host's unit.
3244fn definition_drift(
3245    controls: &dyn ControlFactory,
3246    identity: &ServiceIdentity,
3247    record: &InstallRecord,
3248) -> Option<DefinitionDrift> {
3249    if host_definition_kind(record.start_mode) != DefinitionKind::SystemdUnit {
3250        return None;
3251    }
3252    let path = record.definition_path.clone()?;
3253    let installed = controls.installed_definition(&path)?;
3254    systemd_definition_drift(identity, record, path, &installed)
3255}
3256
3257fn systemd_definition_drift(
3258    identity: &ServiceIdentity,
3259    record: &InstallRecord,
3260    path: PathBuf,
3261    installed: &str,
3262) -> Option<DefinitionDrift> {
3263    let rendered = systemd_unit(&record.plan(identity.clone()));
3264    (installed != rendered).then(|| DefinitionDrift {
3265        path,
3266        installed_by_version: record.installed_by_version.clone(),
3267        rendered,
3268        start_mode: record.start_mode,
3269    })
3270}
3271
3272// ---------------------------------------------------------------------------
3273// The last runner-root refusal
3274// ---------------------------------------------------------------------------
3275
3276const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
3277
3278#[derive(Debug, Clone, Serialize, Deserialize)]
3279struct RootRefusalFile {
3280    schema_version: u32,
3281    /// Keyed by policy, because the fact is per-policy and not per-host.
3282    #[serde(default)]
3283    refusals: BTreeMap<String, RootRefusalEntry>,
3284}
3285
3286#[derive(Debug, Clone, Serialize, Deserialize)]
3287struct RootRefusalEntry {
3288    at: DateTime<Utc>,
3289    kind: String,
3290    root: String,
3291    detail: String,
3292}
3293
3294/// One policy's last runner-root refusal, as `service status` reports it.
3295#[derive(Debug, Clone, PartialEq, Eq)]
3296pub struct RunnerRootRefusal {
3297    /// The policy that placed no runner.
3298    pub policy: String,
3299    /// When it was last refused.
3300    pub at: DateTime<Utc>,
3301    /// The closed-vocabulary cause, as `RunnerRootError::kind` names it.
3302    pub kind: String,
3303    /// The root it was refused.
3304    pub root: String,
3305    /// The refusal in full, including the remediation. Not redacted: this file
3306    /// is read by a command in the operator's terminal, not by the log sink.
3307    pub detail: String,
3308}
3309
3310/// Records that one policy could not use its runner root.
3311///
3312/// # The contract, and why a file
3313///
3314/// A launch the runner root refuses fails before any attempt row exists, so the
3315/// journal cannot carry it and no screen can show it. The daemon's own log
3316/// cannot either: it redacts every field it does not allow-list and scrubs
3317/// anything path-shaped out of the ones it does, which is most of a
3318/// `RunnerRootError`. So the sentence an operator needs — the directory and the
3319/// remediation, verbatim — has nowhere to go, and the failure reads
3320/// `runner_start_failed reason=other` once per poll for as long as it lasts.
3321/// That is not hypothetical: it cost three hours.
3322///
3323/// **Keyed by policy, and that is load-bearing.** A host runs several policies
3324/// and they do not share a fate: `allocate_persistent_slot` tolerates an
3325/// unresolvable host default, so a repository with its own persistent root
3326/// places runners happily while an ephemeral policy on a withheld volume places
3327/// none. A single host-wide slot would have the working policy clear the broken
3328/// one's record on the same reconcile pass, and `service status` would report a
3329/// healthy host that had started zero runners for an entire target.
3330///
3331/// **The daemon writes this when a root refuses a launch and clears the policy's
3332/// entry when one succeeds; nothing else writes it.** It keeps the last refusal
3333/// per policy rather than a history, for the reason [`record_github_contact`] is
3334/// a single timestamp: the operator is asking whether runners can be placed
3335/// *now*.
3336///
3337/// Written whole through a temporary in the same directory, so a status command
3338/// cannot read half a record.
3339///
3340/// # Errors
3341///
3342/// [`ServiceError::Record`] when `state/` cannot be read or written.
3343pub fn record_runner_root_refusal(
3344    paths: &AppPaths,
3345    policy: &str,
3346    at: DateTime<Utc>,
3347    kind: &str,
3348    root: &str,
3349    detail: &str,
3350) -> Result<(), ServiceError> {
3351    let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
3352        schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
3353        refusals: BTreeMap::new(),
3354    });
3355    file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
3356    file.refusals.insert(
3357        policy.to_owned(),
3358        RootRefusalEntry {
3359            at,
3360            kind: kind.to_owned(),
3361            root: root.to_owned(),
3362            detail: detail.to_owned(),
3363        },
3364    );
3365    write_refusal_file(paths, &file)
3366}
3367
3368/// Clears one policy's entry, because that policy placed a runner.
3369///
3370/// Removes the file once the last entry goes, so a host that is working leaves
3371/// nothing behind for a later `service status` to report. A policy with no entry
3372/// is not an error: this is called on every successful placement, and almost
3373/// every one of those follows another success.
3374///
3375/// # Errors
3376///
3377/// [`ServiceError::Record`] when `state/` cannot be read or written.
3378pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
3379    let Some(mut file) = read_refusal_file(paths)? else {
3380        return Ok(());
3381    };
3382    if file.refusals.remove(policy).is_none() {
3383        return Ok(());
3384    }
3385    if file.refusals.is_empty() {
3386        let path = root_refusal_path(paths);
3387        return match std::fs::remove_file(&path) {
3388            Ok(()) => Ok(()),
3389            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3390            Err(error) => Err(ServiceError::Record {
3391                operation: "remove",
3392                path,
3393                detail: error.to_string(),
3394            }),
3395        };
3396    }
3397    write_refusal_file(paths, &file)
3398}
3399
3400/// Every policy whose runner root refused it and has not since succeeded.
3401///
3402/// Ordered by policy, so two runs of `service status` on an unchanged host
3403/// print the same thing in the same order.
3404///
3405/// # Errors
3406///
3407/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3408/// Reported rather than treated as absence, for the reason
3409/// [`last_github_contact`] gives: absence means "every policy is placing
3410/// runners", and answering a parse failure with that would be a wrong answer.
3411pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
3412    Ok(read_refusal_file(paths)?
3413        .map(|file| {
3414            file.refusals
3415                .into_iter()
3416                .map(|(policy, entry)| RunnerRootRefusal {
3417                    policy,
3418                    at: entry.at,
3419                    kind: entry.kind,
3420                    root: entry.root,
3421                    detail: entry.detail,
3422                })
3423                .collect()
3424        })
3425        .unwrap_or_default())
3426}
3427
3428fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
3429    let path = root_refusal_path(paths);
3430    let text = match std::fs::read_to_string(&path) {
3431        Ok(text) => text,
3432        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3433        Err(error) => {
3434            return Err(ServiceError::Record {
3435                operation: "read",
3436                path,
3437                detail: error.to_string(),
3438            });
3439        }
3440    };
3441    toml::from_str(&text)
3442        .map(Some)
3443        .map_err(|error| ServiceError::Record {
3444            operation: "read",
3445            path,
3446            detail: error.to_string(),
3447        })
3448}
3449
3450fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
3451    let path = root_refusal_path(paths);
3452    let failed = |detail: String| ServiceError::Record {
3453        operation: "write",
3454        path: path.clone(),
3455        detail,
3456    };
3457    let text = toml::to_string_pretty(file).map_err(|error| failed(error.to_string()))?;
3458    let directory = path.parent().unwrap_or_else(|| Path::new("."));
3459    std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3460    let temporary = path.with_extension("toml.new");
3461    std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3462    std::fs::rename(&temporary, &path).map_err(|error| failed(error.to_string()))
3463}
3464
3465/// Where the refusals live.
3466#[must_use]
3467pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
3468    paths.state_dir().join(ROOT_REFUSAL_FILE)
3469}
3470
3471// ---------------------------------------------------------------------------
3472// The recorded binary path
3473// ---------------------------------------------------------------------------
3474
3475/// What became of the absolute path `install` recorded.
3476///
3477/// `05-infrastructure.md` item 6 in one type. Three of the four variants are
3478/// errors, and `service status` reports them as errors rather than as health —
3479/// which is the whole point, because the npm case produces a service that looks
3480/// installed, is registered, and cannot start.
3481#[derive(Debug, Clone, PartialEq, Eq)]
3482pub enum BinaryPath {
3483    /// The recorded path is a file, and the service manager still starts it.
3484    Current {
3485        /// The path.
3486        path: PathBuf,
3487    },
3488    /// **Nothing is at the recorded path.**
3489    ///
3490    /// This is the npm upgrade: `npm i -g @ivan-murzak/runner-manager` puts the binary under
3491    /// the active Node installation's global prefix, and switching Node versions
3492    /// with `nvm`, `fnm`, or `volta` moves that prefix. The service is still
3493    /// registered, still set to start at boot, and starts nothing.
3494    Missing {
3495        /// The path the record names.
3496        recorded: PathBuf,
3497    },
3498    /// Something is at the recorded path, but it is not a file the service
3499    /// manager could start.
3500    NotExecutable {
3501        /// The path the record names.
3502        recorded: PathBuf,
3503        /// What is there instead.
3504        detail: String,
3505    },
3506    /// The record and the service manager name different binaries.
3507    Diverged {
3508        /// What the record says.
3509        recorded: PathBuf,
3510        /// What the service manager is registered to start.
3511        registered: PathBuf,
3512    },
3513}
3514
3515impl BinaryPath {
3516    /// Whether this is a state `service status` must report as an error.
3517    #[must_use]
3518    pub const fn is_error(&self) -> bool {
3519        !matches!(self, Self::Current { .. })
3520    }
3521
3522    /// The path the record names, whatever state it is in.
3523    #[must_use]
3524    pub fn recorded(&self) -> &Path {
3525        match self {
3526            Self::Current { path } => path,
3527            Self::Missing { recorded }
3528            | Self::NotExecutable { recorded, .. }
3529            | Self::Diverged { recorded, .. } => recorded,
3530        }
3531    }
3532}
3533
3534impl fmt::Display for BinaryPath {
3535    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3536        match self {
3537            Self::Current { path } => write!(f, "{}", path.display()),
3538            Self::Missing { recorded } => write!(
3539                f,
3540                "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
3541                 package manager that moved the binary is the usual cause; an `npm i -g` \
3542                 installation moves with the active Node version. Run `service install` again \
3543                 from the binary that is now installed.",
3544                recorded.display()
3545            ),
3546            Self::NotExecutable { recorded, detail } => write!(
3547                f,
3548                "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
3549                 from the installed binary.",
3550                recorded.display()
3551            ),
3552            Self::Diverged {
3553                recorded,
3554                registered,
3555            } => write!(
3556                f,
3557                "{} -- STALE: the service manager is registered to start {} instead. Something \
3558                 has edited the registration since it was installed. Run `service uninstall` and \
3559                 `service install`; neither touches configuration, secrets, or the cache.",
3560                recorded.display(),
3561                registered.display()
3562            ),
3563        }
3564    }
3565}
3566
3567/// Decides what became of a recorded path.
3568///
3569/// `registered` is what the service manager says it will start, when the
3570/// platform can be asked. Order matters and is deliberate: **absence is checked
3571/// first**, because a missing binary is the failure item 6 exists for and an
3572/// operator hearing "the registration disagrees with the record" about a file
3573/// that is not there would be sent to the wrong problem.
3574#[must_use]
3575pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
3576    match std::fs::metadata(recorded) {
3577        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3578            return BinaryPath::Missing {
3579                recorded: recorded.to_path_buf(),
3580            };
3581        }
3582        Err(error) => {
3583            return BinaryPath::NotExecutable {
3584                recorded: recorded.to_path_buf(),
3585                detail: format!("it cannot be inspected ({error})"),
3586            };
3587        }
3588        Ok(metadata) if !metadata.is_file() => {
3589            return BinaryPath::NotExecutable {
3590                recorded: recorded.to_path_buf(),
3591                detail: "what is there is not a file".to_string(),
3592            };
3593        }
3594        Ok(_) => {}
3595    }
3596    if let Some(registered) = registered
3597        && !same_path_text(&recorded.to_string_lossy(), &registered.to_string_lossy())
3598    {
3599        return BinaryPath::Diverged {
3600            recorded: recorded.to_path_buf(),
3601            registered: registered.to_path_buf(),
3602        };
3603    }
3604    BinaryPath::Current {
3605        path: recorded.to_path_buf(),
3606    }
3607}
3608
3609// ---------------------------------------------------------------------------
3610// The control seam
3611// ---------------------------------------------------------------------------
3612
3613/// What a service manager says about a registration it holds.
3614#[derive(Debug, Clone, PartialEq, Eq)]
3615pub struct Registration {
3616    /// Which manager holds it, which is also what decides the start mode:
3617    /// a Windows service is a boot registration and a Task Scheduler task is a
3618    /// login one; a LaunchDaemon and a system unit are boot, a LaunchAgent and
3619    /// a user unit are login.
3620    pub manager: DefinitionKind,
3621    /// The start mode the *domain* it was found in implies.
3622    pub start_mode: StartMode,
3623    /// The full command line, as the manager stores it.
3624    pub command_line: String,
3625    /// The account it runs under, when the manager reports one.
3626    pub account: Option<String>,
3627    /// Whether it is running now.
3628    pub running: bool,
3629    /// Whether it will start by itself.
3630    ///
3631    /// Distinct from [`Registration::start_mode`]: a Windows service can be
3632    /// registered in the boot domain and still be set to `demand` start, which
3633    /// is a service that exists, looks installed, and does not come back after
3634    /// a reboot.
3635    pub starts_automatically: bool,
3636    /// The restart-on-failure delay the manager reports, when it reports one.
3637    ///
3638    /// The *delay* rather than the whole [`RestartPolicy`], because that is the
3639    /// half every one of the four managers can be asked for. The failure-count
3640    /// reset window has no representation at all in Task Scheduler or launchd,
3641    /// so a `RestartPolicy` read back from them would carry one number the
3642    /// manager reported and one this module invented — and a comparison against
3643    /// an invented number is a comparison that cannot fail.
3644    pub restart_delay: Option<Duration>,
3645}
3646
3647impl Registration {
3648    /// The executable the manager will start, parsed out of the command line.
3649    #[must_use]
3650    pub fn binary(&self) -> Option<PathBuf> {
3651        executable_from_command_line(&self.command_line)
3652    }
3653}
3654
3655/// One platform's service manager, for one start-mode domain.
3656///
3657/// Every method takes the identity rather than storing it, so one control can
3658/// answer about the product's registration and about a test fixture's without
3659/// either being able to reach the other by accident.
3660pub trait ServiceControl: fmt::Debug {
3661    /// Which manager this is.
3662    fn manager(&self) -> DefinitionKind;
3663
3664    /// Registers the plan, returning the definition that was applied.
3665    ///
3666    /// # Errors
3667    ///
3668    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`].
3669    fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
3670
3671    /// Deregisters. Returns whether there was a registration to remove.
3672    ///
3673    /// **Removes the registration and nothing else.** No implementation of this
3674    /// method may delete a directory, a database, a secret, or a cache;
3675    /// `05-infrastructure.md` item 5 is a property of every backend, not a
3676    /// check somewhere above them.
3677    ///
3678    /// # Errors
3679    ///
3680    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`].
3681    fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3682
3683    /// What the manager knows about this registration, if anything.
3684    ///
3685    /// # Errors
3686    ///
3687    /// [`ServiceError::Control`].
3688    fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
3689
3690    /// Starts it now, without waiting for the next boot or logon.
3691    ///
3692    /// # Errors
3693    ///
3694    /// [`ServiceError::Control`], [`ServiceError::NeedsElevation`], or
3695    /// [`ServiceError::NotInstalled`].
3696    fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
3697
3698    /// Stops it. Returns whether it was running.
3699    ///
3700    /// # Errors
3701    ///
3702    /// As [`ServiceControl::start`].
3703    fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3704}
3705
3706/// Chooses the control for a start mode.
3707///
3708/// The indirection is what makes [`ServiceOperations`] testable: production
3709/// hands it [`HostControls`], every test in this file hands it
3710/// [`RecordingControls`], and neither knows the difference.
3711pub trait ControlFactory: fmt::Debug + Send + Sync {
3712    /// The control for one start-mode domain.
3713    ///
3714    /// # Errors
3715    ///
3716    /// [`ServiceError::Control`] when this host has no manager for that domain.
3717    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
3718
3719    /// The text of an installed definition file, for drift detection. `None`
3720    /// when there is none to read. The default reads nothing, so a double
3721    /// never reports the real host's unit as its own.
3722    fn installed_definition(&self, _path: &Path) -> Option<String> {
3723        None
3724    }
3725}
3726
3727/// The real service managers of the host this binary was built for.
3728#[derive(Debug, Clone, Copy, Default)]
3729pub struct HostControls;
3730
3731// ---------------------------------------------------------------------------
3732// The operations
3733// ---------------------------------------------------------------------------
3734
3735/// What `install` did.
3736#[derive(Debug, Clone)]
3737pub struct Installed {
3738    /// The plan that was applied, including the resolved absolute binary path.
3739    pub plan: InstallPlan,
3740    /// The definition the platform was given.
3741    pub definition: ServiceDefinition,
3742    /// The record that was written.
3743    pub record: InstallRecord,
3744    /// What the definition grants, measured against the requirement.
3745    pub review: PrivilegeReview,
3746    /// What was done to the runner root the registration will run jobs under.
3747    ///
3748    /// Named trustees rather than SIDs, so printing it adds no identity to the
3749    /// output. See [`crate::runner_root_access`].
3750    pub runner_root: RootAccessSummary,
3751    /// Whether this replaced a registration that was already there, rather than
3752    /// making a new one.
3753    ///
3754    /// Reported rather than glossed over: "Service installed" on a host that
3755    /// had one already reads as though nothing had been taken away and put
3756    /// back, and what was taken away and put back is the thing an operator
3757    /// watching a running agent wants to know about.
3758    pub replaced_existing: bool,
3759}
3760
3761/// What `uninstall` did — and, as importantly, what it did not.
3762#[derive(Debug, Clone, PartialEq, Eq)]
3763pub struct Uninstalled {
3764    /// Whether a registration was removed.
3765    pub removed_registration: bool,
3766    /// Whether the install record was removed.
3767    pub removed_record: bool,
3768    /// Whether a definition file was removed, and which.
3769    pub removed_definition: Option<PathBuf>,
3770    /// The directories that were **left exactly as they were**.
3771    ///
3772    /// `05-infrastructure.md` item 5 stated as a value rather than as a
3773    /// promise: `service uninstall` prints this list, so an operator can see
3774    /// that the configuration, the SQLite database, the stored token and the
3775    /// runner package cache are all still there.
3776    pub preserved: Vec<PathBuf>,
3777}
3778
3779impl fmt::Display for Uninstalled {
3780    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3781        if self.removed_registration {
3782            writeln!(f, "The service registration was removed.")?;
3783        } else {
3784            writeln!(f, "There was no service registration to remove.")?;
3785        }
3786        writeln!(f, "Nothing else was deleted. These are untouched:")?;
3787        for path in &self.preserved {
3788            writeln!(f, "  {}", path.display())?;
3789        }
3790        write!(
3791            f,
3792            "The stored GitHub token is untouched too; `auth logout` is what purges it."
3793        )
3794    }
3795}
3796
3797/// What `set_start_mode` did.
3798#[derive(Debug, Clone, PartialEq, Eq)]
3799pub struct StartModeChange {
3800    /// What it was.
3801    pub from: StartMode,
3802    /// What it is now.
3803    pub to: StartMode,
3804    /// `false` when the registration was already in the requested mode.
3805    pub changed: bool,
3806    /// The secret store the new mode obliges, so a caller can tell an operator
3807    /// whether the token has to move too.
3808    pub store_scope: crate::secrets::SecretScope,
3809    /// What the mode change did to the runner root's access control.
3810    ///
3811    /// A mode change moves the account the daemon runs as, and the runner root
3812    /// admits that account by name — so the two move together or the new
3813    /// registration cannot write its own workspaces.
3814    pub runner_root: RootAccessSummary,
3815}
3816
3817impl fmt::Display for StartModeChange {
3818    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3819        if !self.changed {
3820            return write!(f, "The service already starts at {}.", self.to);
3821        }
3822        write!(
3823            f,
3824            "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
3825             if the token was stored under the other scope, run `auth login` again. {}",
3826            self.to, self.from, self.store_scope, self.runner_root
3827        )
3828    }
3829}
3830
3831/// Install, uninstall, inspect, and switch start mode.
3832///
3833/// This is the whole of the library contract `f3` builds `service install`,
3834/// `service uninstall` and `service status` on. Nothing here prints; every
3835/// operation returns a value with a [`fmt::Display`] a command can write, so the
3836/// same facts are available to the TUI and to a JSON document without being
3837/// re-derived from text.
3838#[derive(Debug, Clone)]
3839pub struct ServiceOperations {
3840    paths: AppPaths,
3841    identity: ServiceIdentity,
3842    controls: std::sync::Arc<dyn ControlFactory>,
3843    runner_root: Option<LocalAbsolutePath>,
3844}
3845
3846impl ServiceOperations {
3847    /// Operates on this host's real service managers.
3848    #[must_use]
3849    pub fn on_this_host(paths: AppPaths) -> Self {
3850        Self::with_controls(
3851            paths,
3852            ServiceIdentity::product(),
3853            std::sync::Arc::new(HostControls),
3854        )
3855    }
3856
3857    /// Operates on the controls a caller supplies.
3858    ///
3859    /// Both other arguments are explicit for the same reason: the privileged
3860    /// installer tests register a real service under a
3861    /// [`ServiceIdentity::fixture`] name, against a disposable
3862    /// [`AppPaths::rooted_at`] tree, and must not be able to touch an operator's
3863    /// installation even by mistake.
3864    #[must_use]
3865    pub fn with_controls(
3866        paths: AppPaths,
3867        identity: ServiceIdentity,
3868        controls: std::sync::Arc<dyn ControlFactory>,
3869    ) -> Self {
3870        Self {
3871            paths,
3872            identity,
3873            controls,
3874            runner_root: None,
3875        }
3876    }
3877
3878    /// Points runner-root preparation at a directory the caller owns, for the
3879    /// **current account**.
3880    ///
3881    /// **Only a [`ServiceIdentity::fixture`] registration is allowed to move
3882    /// it, and the guard is here rather than at the call site.** A product
3883    /// registration ignores the override entirely and resolves
3884    /// [`crate::runner_root::default_runner_root`] itself, which is what keeps
3885    /// "custom roots are never re-ACLed" true no matter what a caller passes.
3886    ///
3887    /// It exists because the directory the product uses is
3888    /// `%SystemDrive%\rman`, and a smoke test that created and re-permissioned
3889    /// *that* would be editing the machine it runs on from outside its own
3890    /// fixture. So a fixture aims this at a temporary directory it created and
3891    /// will delete — and "and will delete" is why the override is always
3892    /// reconciled for the calling account rather than for the account the start
3893    /// mode obliges. A boot-mode root admits `SY` and `BA` only, which an
3894    /// ordinary filtered token is neither, so a test that supplied one could
3895    /// not then inspect or remove its own temporary directory.
3896    #[must_use]
3897    pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
3898        self.runner_root = Some(root);
3899        self
3900    }
3901
3902    /// The directories this operates against.
3903    #[must_use]
3904    pub const fn paths(&self) -> &AppPaths {
3905        &self.paths
3906    }
3907
3908    /// The registration this operates on.
3909    #[must_use]
3910    pub const fn identity(&self) -> &ServiceIdentity {
3911        &self.identity
3912    }
3913
3914    /// Registers `daemon run` with the operating system.
3915    ///
3916    /// The order is the contract, and each step is a requirement:
3917    ///
3918    /// 1. the four directories are created, because item 2 is about writing them;
3919    /// 2. **the single-instance lock is taken** — item 1. It is held for the
3920    ///    whole of the install rather than probed and released, so a daemon that
3921    ///    starts halfway through cannot end up racing a registration;
3922    /// 3. neither start-mode domain already holds a registration — item 6's
3923    ///    record would otherwise describe one of two;
3924    /// 4. the binary path is resolved and confirmed to be a file — item 6;
3925    /// 5. the platform registers it;
3926    /// 6. the record is written.
3927    ///
3928    /// # Errors
3929    ///
3930    /// [`ServiceError::LockHeld`] when an agent is already running,
3931    /// [`ServiceError::AlreadyInstalled`], [`ServiceError::BinaryMissing`], and
3932    /// whatever the platform reports.
3933    pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
3934        self.paths
3935            .create_all()
3936            .map_err(|source| ServiceError::Paths {
3937                source: Box::new(source),
3938            })?;
3939
3940        // Item 1. The guard lives until the end of this function; dropping it is
3941        // the release, so an early return releases it too.
3942        let _guard = self.refuse_while_an_agent_runs()?;
3943
3944        // --------------------------------------------------------------------
3945        // AN INSTALL OVER THE SAME START MODE REPLACES; IT DOES NOT REFUSE.
3946        // --------------------------------------------------------------------
3947        // This used to refuse any existing registration, and that was wrong in
3948        // a way that took a real host down.
3949        //
3950        // `service install` is *also* how an operator moves to a new version by
3951        // hand — `install_owned_copy` says so, and the `.old` rename it does
3952        // exists precisely so that reinstalling over a **running** service
3953        // works. So the caller replaces the copy the service runs and *then*
3954        // asks to register it. Refusing at that point does not undo the swap:
3955        // the daemon is left running a binary nobody registered, the command
3956        // reports failure, and on macOS the new binary is a program the stored
3957        // credential's keychain grant does not name. Watched on 0.1.17: the
3958        // copy under `state/bin` was replaced at 00:16, `service install` said
3959        // "already registered", and the daemon crash-looped on `-25293` from
3960        // then on.
3961        //
3962        // Registering the same service twice is not what an operator is asking
3963        // for here and is not what they get: the platform is asked to drop the
3964        // registration and take it again, which is a deregister followed by a
3965        // register because that is the only thing launchd, systemd and the SCM
3966        // all support — `launchctl bootstrap` refuses a label already loaded.
3967        //
3968        // The *other* mode is still refused. Moving between boot and login
3969        // moves the registration between two service managers and changes the
3970        // account and the secret store with it; `set_start_mode` does that,
3971        // carefully, and an install is not the place for it.
3972        let (replacing, previous_was_running) = match self.find_registration()? {
3973            Some((existing, registration)) if existing == request.start_mode() => {
3974                (true, registration.running)
3975            }
3976            Some((existing, _)) => {
3977                return Err(ServiceError::AlreadyInstalled {
3978                    name: self.identity.name().to_string(),
3979                    existing,
3980                    requested: request.start_mode(),
3981                });
3982            }
3983            None => (false, false),
3984        };
3985
3986        let plan = InstallPlan::resolve(
3987            self.identity.clone(),
3988            request,
3989            ServiceDirectories::of(&self.paths),
3990        )?;
3991
3992        // Resolved before the runner root, and used after it: every step from
3993        // here on has to be able to undo the directory, and a `?` between the
3994        // preparation and the first fallible use would leave one behind with
3995        // nothing to say so.
3996        let control = self.controls.control(plan.start_mode())?;
3997
3998        // Item 8, and the one step that can refuse an otherwise valid install.
3999        // Before the platform is asked to register anything, because a
4000        // registration whose workspaces would land in a directory ordinary local
4001        // users can write is one that should never have existed — and because
4002        // undoing a directory is cheaper than undoing a service.
4003        let root = self.prepare_runner_root(plan.start_mode())?;
4004
4005        // Read before the registration is dropped, so that a replace which then
4006        // fails to take can put back what was there. A record this account
4007        // cannot read leaves nothing to reinstate, which is reported rather
4008        // than pretended: see `reinstate`.
4009        let previous = if replacing {
4010            InstallRecord::read(&self.paths).ok().flatten()
4011        } else {
4012            None
4013        };
4014        if replacing && let Err(cause) = control.uninstall(&self.identity) {
4015            return Err(undo_runner_root(&root, "install", &self.identity, cause));
4016        }
4017
4018        let definition = match control.install(&plan) {
4019            Ok(definition) => definition,
4020            Err(cause) => {
4021                // Nothing was deregistered on a first install, so there is
4022                // nothing to put back and `reinstate` says so with `Ok(())`.
4023                let restored =
4024                    self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running);
4025                return Err(rolled_back(
4026                    retained_runner_root(&root),
4027                    restored,
4028                    "install",
4029                    &self.identity,
4030                    cause,
4031                ));
4032            }
4033        };
4034        let review = review_least_privilege(&definition, &plan);
4035        let record = InstallRecord::of(&plan, &definition, Utc::now());
4036        if let Err(cause) = record.write(&self.paths) {
4037            let rollback = control
4038                .uninstall(&self.identity)
4039                .map(|_| ())
4040                .and_then(|()| {
4041                    self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4042                });
4043            return Err(rolled_back(
4044                retained_runner_root(&root),
4045                rollback,
4046                "install",
4047                &self.identity,
4048                cause,
4049            ));
4050        }
4051
4052        // Registration is not the postcondition of an install: a service the
4053        // current session still has to start by hand is only half-installed.
4054        // This matters most for a logon-triggered Windows task because its
4055        // trigger has already passed by the time an operator reinstalls it.
4056        // Start through the same control that registered the definition and
4057        // roll the whole replacement back if the manager refuses it.
4058        if let Err(cause) = control.start(&self.identity) {
4059            let remove_new = control.uninstall(&self.identity).map(|_| ());
4060            let restore_registration = remove_new.and_then(|()| {
4061                self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4062            });
4063            let restore_record = restore_registration.and_then(|()| match &previous {
4064                Some(previous) => previous.write(&self.paths),
4065                None => InstallRecord::remove(&self.paths).map(|_| ()),
4066            });
4067            return Err(rolled_back(
4068                retained_runner_root(&root),
4069                restore_record,
4070                "install and start",
4071                &self.identity,
4072                cause,
4073            ));
4074        }
4075        Ok(Installed {
4076            plan,
4077            definition,
4078            record,
4079            review,
4080            runner_root: root.summary().clone(),
4081            replaced_existing: replacing,
4082        })
4083    }
4084
4085    /// Puts back a registration this call deregistered in order to replace it.
4086    ///
4087    /// `Ok(())` when there was nothing to put back, which is the ordinary case:
4088    /// a first install deregisters nothing. When there *was* a record, the plan
4089    /// is rebuilt from it rather than from the request, because the request is
4090    /// the one that just failed.
4091    ///
4092    /// Best effort by construction — the caller passes the result to
4093    /// [`rolled_back`], which reports a failed rollback alongside the original
4094    /// cause rather than replacing it.
4095    fn reinstate(
4096        &self,
4097        control: &dyn ServiceControl,
4098        previous: Option<&InstallRecord>,
4099        was_running: bool,
4100    ) -> Result<(), ServiceError> {
4101        let Some(record) = previous else {
4102            return Ok(());
4103        };
4104        control.install(&record.plan(self.identity.clone()))?;
4105        if was_running {
4106            control.start(&self.identity)?;
4107        }
4108        Ok(())
4109    }
4110
4111    /// Deregisters, and deletes nothing else.
4112    ///
4113    /// # Errors
4114    ///
4115    /// Whatever the platform reports. A missing registration is **not** an
4116    /// error: `service uninstall` on a host that has none should say so and
4117    /// exit cleanly, because an operator running it twice has not made a
4118    /// mistake.
4119    pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
4120        let record = InstallRecord::read(&self.paths).ok().flatten();
4121        let removed_definition = record
4122            .as_ref()
4123            .and_then(|record| record.definition_path.clone());
4124
4125        let mut removed_registration = false;
4126        // Both domains, not only the recorded one. A record can be lost while a
4127        // registration survives, and leaving a registration behind because a
4128        // TOML file was deleted is exactly the state `uninstall` exists to end.
4129        for mode in [StartMode::Boot, StartMode::Login] {
4130            let control = self.controls.control(mode)?;
4131            if control.uninstall(&self.identity)? {
4132                removed_registration = true;
4133            }
4134        }
4135        let removed_record = InstallRecord::remove(&self.paths)?;
4136        Ok(Uninstalled {
4137            removed_registration,
4138            removed_record,
4139            removed_definition: removed_definition.filter(|_| removed_registration),
4140            preserved: self
4141                .paths
4142                .all()
4143                .iter()
4144                .map(|(_, path)| (*path).to_path_buf())
4145                .collect(),
4146        })
4147    }
4148
4149    /// Switches between `boot` and `login` **without re-resolving anything**.
4150    ///
4151    /// `05-infrastructure.md` item 7. Everything the new registration carries —
4152    /// the absolute binary path, the arguments, the restart policy, the four
4153    /// directories — comes from the existing record, so the operator is not
4154    /// asked to reinstall the product and a binary that has since moved is not
4155    /// silently swapped for whichever one happens to be running this command.
4156    ///
4157    /// On Windows the registration necessarily moves between two different
4158    /// Windows facilities, because Windows has no service that starts at logon;
4159    /// see this module's documentation. That is still not a reinstall: no file
4160    /// is downloaded, replaced, or re-resolved.
4161    ///
4162    /// The caller must persist the new mode onto this host's `Host` record so
4163    /// that `host show` reports it — [`StartModeChange::to`] is the value, and
4164    /// `f1` already emits `host.service_start_mode` from that field.
4165    ///
4166    /// # Errors
4167    ///
4168    /// [`ServiceError::NotInstalled`] when there is no record to switch, and
4169    /// whatever the platform reports.
4170    pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
4171        let Some(record) = InstallRecord::read(&self.paths)? else {
4172            return Err(ServiceError::NotInstalled {
4173                name: self.identity.name().to_string(),
4174                operation: "switch the start mode of",
4175            });
4176        };
4177        let from = record.start_mode;
4178        if from == to {
4179            // Nothing moves, so nothing about the root's access control has to.
4180            // Reconciling it here would turn a no-op command into one that can
4181            // fail on a permission it does not need.
4182            return Ok(StartModeChange {
4183                from,
4184                to,
4185                changed: false,
4186                store_scope: crate::secrets::SecretScope::for_start_mode(to),
4187                runner_root: RootAccessSummary::NotApplicable,
4188            });
4189        }
4190
4191        #[cfg(windows)]
4192        let arguments = {
4193            let mut arguments = record.arguments.clone();
4194            arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
4195            if to == StartMode::Boot {
4196                arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
4197            }
4198            arguments
4199        };
4200        #[cfg(not(windows))]
4201        let arguments = record.arguments.clone();
4202        let plan = InstallPlan::unchecked(
4203            self.identity.clone(),
4204            to,
4205            record.binary.clone(),
4206            record.directories.clone(),
4207        )
4208        .with_arguments(arguments)
4209        .with_restart(record.restart());
4210        let plan = if record.starts_on_demand {
4211            plan.started_on_demand()
4212        } else {
4213            plan
4214        };
4215        let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
4216            Ok(store) => plan.with_secret_guard(store.guard()),
4217            Err(_) => plan,
4218        };
4219
4220        // Install the target domain before touching the live one. This makes a
4221        // failed target install a no-op from the operator's point of view and,
4222        // unlike uninstall-first ordering, never trades a working service for
4223        // an error message. Resolved before the runner root and used after it,
4224        // so that no `?` sits between preparing the directory and the first
4225        // step that knows how to undo it.
4226        let target = self.controls.control(to)?;
4227        // The domain being left, resolved here rather than where it is used for
4228        // the same reason: the only step that removes it runs after the runner
4229        // root has been prepared, and a `?` there would abandon the target
4230        // registration, the record and the directory without a word.
4231        let previous = self.controls.control(from)?;
4232
4233        // The account changes with the mode, and so must the account the runner
4234        // root admits: `04-security-recovery.md` requires the selected identity
4235        // to be *reconciled* when service mode changes, which means adding the
4236        // operator's on the way to login and dropping it again on the way back.
4237        // Before the target install, for the same reason `install` does it
4238        // first — a root that cannot be made safe must not produce a working
4239        // registration.
4240        let root = self.prepare_runner_root(to)?;
4241
4242        let definition = match target.install(&plan) {
4243            Ok(definition) => definition,
4244            Err(cause) => {
4245                return Err(undo_runner_root(
4246                    &root,
4247                    "switch start mode",
4248                    &self.identity,
4249                    cause,
4250                ));
4251            }
4252        };
4253        let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
4254        if let Err(cause) = next_record.write(&self.paths) {
4255            return Err(rolled_back(
4256                retained_runner_root(&root),
4257                target.uninstall(&self.identity),
4258                "switch start mode",
4259                &self.identity,
4260                cause,
4261            ));
4262        }
4263
4264        // Only after the target registration and its durable record exist is
4265        // it safe to remove the old domain. If that last step fails, remove the
4266        // target and put the old record back so status and reality agree.
4267        if let Err(cause) = previous.uninstall(&self.identity) {
4268            let target_rollback = target.uninstall(&self.identity);
4269            let record_rollback = record.write(&self.paths);
4270            return Err(rolled_back(
4271                retained_runner_root(&root),
4272                target_rollback.and(record_rollback),
4273                "switch start mode",
4274                &self.identity,
4275                cause,
4276            ));
4277        }
4278        Ok(StartModeChange {
4279            from,
4280            to,
4281            changed: true,
4282            store_scope: crate::secrets::SecretScope::for_start_mode(to),
4283            runner_root: root.summary().clone(),
4284        })
4285    }
4286
4287    /// Starts the registration now.
4288    ///
4289    /// # Errors
4290    ///
4291    /// [`ServiceError::NotInstalled`], or whatever the platform reports.
4292    pub fn start(&self) -> Result<(), ServiceError> {
4293        let Some((mode, _)) = self.find_registration()? else {
4294            return Err(ServiceError::NotInstalled {
4295                name: self.identity.name().to_string(),
4296                operation: "start",
4297            });
4298        };
4299        self.controls.control(mode)?.start(&self.identity)
4300    }
4301
4302    /// Stops the registration. Returns whether it was running.
4303    ///
4304    /// # Errors
4305    ///
4306    /// As [`ServiceOperations::start`].
4307    pub fn stop(&self) -> Result<bool, ServiceError> {
4308        let Some((mode, _)) = self.find_registration()? else {
4309            return Err(ServiceError::NotInstalled {
4310                name: self.identity.name().to_string(),
4311                operation: "stop",
4312            });
4313        };
4314        self.controls.control(mode)?.stop(&self.identity)
4315    }
4316
4317    /// Everything Journey 5 step 4 asks `service status` to report.
4318    ///
4319    /// # Errors
4320    ///
4321    /// [`ServiceError::Record`] when local state cannot be read, and whatever
4322    /// the platform reports. A *stale binary path* is deliberately **not** an
4323    /// error here: it is a reported state, because a status command that
4324    /// refused to print anything else would hide the very facts an operator
4325    /// needs in order to fix it.
4326    ///
4327    /// A record **this account may not read** is reported for the same reason
4328    /// and is the second exception. On a boot-mode host the record was written
4329    /// by `sudo service install`, and a version before this one wrote it `0600`
4330    /// — so the operator's own `service status` ended with `Permission denied`
4331    /// and printed nothing at all, on a host whose registration the service
4332    /// manager would have described perfectly well. What launchd, systemd or
4333    /// the SCM says is a separate fact from the record, and it is still worth
4334    /// having.
4335    pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
4336        let (record, record_refused) = match InstallRecord::read(&self.paths) {
4337            Ok(record) => (record, None),
4338            Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
4339            Err(error) => return Err(error),
4340        };
4341        let found = self.find_registration()?;
4342        let last_github_contact = last_github_contact(&self.paths)?;
4343        let drift = record
4344            .as_ref()
4345            .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, record));
4346        let credential_rejected_since =
4347            github_credential_rejected_since(&self.paths).ok().flatten();
4348        Ok(ServiceStatus::compose(
4349            self.identity.clone(),
4350            record,
4351            record_refused.as_ref(),
4352            found.map(|(_, registration)| registration),
4353            last_github_contact,
4354            &self.paths,
4355        )
4356        .with_definition_drift(drift)
4357        .with_credential_rejection(credential_rejected_since))
4358    }
4359
4360    /// The installed definition, when it is not what this build renders for
4361    /// the recorded installation. See [`definition_drift`].
4362    ///
4363    /// # Errors
4364    ///
4365    /// Whatever reading the install record reports.
4366    pub fn definition_drift(&self) -> Result<Option<DefinitionDrift>, ServiceError> {
4367        Ok(InstallRecord::read(&self.paths)?
4368            .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, &record)))
4369    }
4370
4371    /// Creates or reconciles the runner root this start mode's account needs.
4372    ///
4373    /// The account is not an argument: it is [`ServiceAccount::for_start_mode`],
4374    /// the same function the registration's own principal comes from, so the
4375    /// directory admits exactly the identity the definition registers and a mode
4376    /// change reconciles both together or neither.
4377    ///
4378    /// On macOS and Linux this is a no-op that returns
4379    /// [`RootAccessSummary::NotApplicable`]; see [`crate::runner_root_access`].
4380    fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
4381        #[cfg(not(windows))]
4382        {
4383            // `runner_root` is consumed here as well as in the Windows arm: a
4384            // field only one platform reads is a dead field on the other.
4385            let _ = (mode, &self.runner_root);
4386            Ok(RootAccessChange::not_applicable())
4387        }
4388        #[cfg(windows)]
4389        {
4390            let wrap = |source| ServiceError::RunnerRoot {
4391                source: Box::new(source),
4392            };
4393            // The test seam, and the two things allowed through it. `cfg!(test)`
4394            // is false in every shipped binary, so what a released build honours
4395            // is the fixture name alone — and a fixture name cannot be the
4396            // product's, which is what keeps a released `service install`
4397            // pointed at the platform default whatever a caller passes.
4398            //
4399            // An overridden root is always reconciled **for this account**,
4400            // which is the foreground admission — a real mode of the product
4401            // rather than a concession invented here. It has to be: a boot-mode
4402            // root admits `SY` and `BA` only, and a test process holding an
4403            // ordinary filtered token is neither, so it could not inspect the
4404            // temporary directory it just supplied nor delete it afterwards.
4405            // What that costs is that the *boot* descriptor is not proved
4406            // through this path; it is proved purely, by this module's
4407            // `the_runner_root_a_boot_registration_needs_admits_only_the_service`
4408            // and by `runner_root_access`'s own tests, and for real by the
4409            // privileged installer test, which runs elevated.
4410            if let Some(root) = self
4411                .runner_root
4412                .as_ref()
4413                .filter(|_| self.identity.is_fixture() || cfg!(test))
4414            {
4415                let admission = RootAdmission::of_this_account().map_err(wrap)?;
4416                return crate::runner_root_access::reconcile(&self.paths, root, &admission)
4417                    .map_err(wrap);
4418            }
4419
4420            let admission = match ServiceAccount::for_start_mode(mode) {
4421                // A boot registration runs as LocalSystem, which the constant
4422                // `SY` ace already names.
4423                ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
4424                // A login registration runs as this account, under a filtered
4425                // token in which Administrators is deny-only — so without an
4426                // ace of its own it would be admitted by nothing.
4427                ServiceAccount::InvokingUser | ServiceAccount::Root => {
4428                    RootAdmission::of_this_account().map_err(wrap)?
4429                }
4430            };
4431            crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
4432        }
4433    }
4434
4435    /// Takes the single-instance lock, or refuses with `d1`'s own message.
4436    fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
4437        HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
4438            |source| match source {
4439                held @ LockError::Held { .. } => ServiceError::LockHeld {
4440                    source: Box::new(held),
4441                },
4442                other => ServiceError::LockUnreadable {
4443                    source: Box::new(other),
4444                },
4445            },
4446        )
4447    }
4448
4449    /// The registration, in whichever domain holds it.
4450    fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
4451        for mode in [StartMode::Boot, StartMode::Login] {
4452            let control = self.controls.control(mode)?;
4453            if let Some(registration) = control.query(&self.identity)? {
4454                return Ok(Some((registration.start_mode, registration)));
4455            }
4456        }
4457        Ok(None)
4458    }
4459}
4460
4461// ---------------------------------------------------------------------------
4462// Status
4463// ---------------------------------------------------------------------------
4464
4465/// One thing `service status` has to report as wrong.
4466#[derive(Debug, Clone, PartialEq, Eq)]
4467pub struct StatusProblem {
4468    /// What it is about.
4469    pub subject: &'static str,
4470    /// What is wrong, and what to do.
4471    pub detail: String,
4472}
4473
4474impl fmt::Display for StatusProblem {
4475    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4476        write!(f, "{}: {}", self.subject, self.detail)
4477    }
4478}
4479
4480/// Everything `service status` reports.
4481///
4482/// Journey 5 step 4 names four of these — the start mode, the resolved binary
4483/// path, the diagnostic log path, and the last successful GitHub contact — and
4484/// the release gate adds the fifth: *"`service status` reports a stale binary
4485/// path as an error rather than appearing healthy"*. [`Self::is_healthy`] is
4486/// what an exit code should be derived from, and it is false whenever
4487/// [`Self::problems`] is non-empty.
4488#[derive(Debug, Clone)]
4489pub struct ServiceStatus {
4490    identity: ServiceIdentity,
4491    record: Option<InstallRecord>,
4492    registration: Option<Registration>,
4493    binary: Option<BinaryPath>,
4494    log_file: PathBuf,
4495    store: Option<crate::secrets::ActiveStore>,
4496    last_github_contact: Option<DateTime<Utc>>,
4497    runner_root: Option<(PathBuf, RootAccessReport)>,
4498    credential_rejected_since: Option<DateTime<Utc>>,
4499    problems: Vec<StatusProblem>,
4500    notes: Vec<String>,
4501}
4502
4503impl ServiceStatus {
4504    /// `record_refused` carries a [`ServiceError::RecordNotPermitted`] when
4505    /// there **is** a record and this account may not read it, which is not the
4506    /// same state as having none and must not be reported as one. See
4507    /// [`Operations::status`].
4508    fn compose(
4509        identity: ServiceIdentity,
4510        record: Option<InstallRecord>,
4511        record_refused: Option<&ServiceError>,
4512        registration: Option<Registration>,
4513        last_github_contact: Option<DateTime<Utc>>,
4514        paths: &AppPaths,
4515    ) -> Self {
4516        let mut problems = Vec::new();
4517        let mut notes = Vec::new();
4518
4519        if let Some(refusal) = record_refused {
4520            // A problem rather than a note: `is_healthy` stays false, because
4521            // an operator who cannot read their own install record does have
4522            // something to fix. It is just not a reason to withhold everything
4523            // the service manager knows.
4524            problems.push(StatusProblem {
4525                subject: "install record",
4526                detail: refusal.to_string(),
4527            });
4528        }
4529
4530        let log_file = record.as_ref().map_or_else(
4531            || ServiceDirectories::of(paths).log_file(),
4532            |record| record.log_file.clone(),
4533        );
4534
4535        let binary = record.as_ref().map(|record| {
4536            inspect_binary(
4537                &record.binary,
4538                registration
4539                    .as_ref()
4540                    .and_then(Registration::binary)
4541                    .as_deref(),
4542            )
4543        });
4544        if let Some(state) = &binary
4545            && state.is_error()
4546        {
4547            problems.push(StatusProblem {
4548                subject: "binary",
4549                detail: state.to_string(),
4550            });
4551        }
4552
4553        match (&record, &registration) {
4554            (Some(_), None) => problems.push(StatusProblem {
4555                subject: "registration",
4556                detail: "this host has a service record but no service manager knows the \
4557                         registration. Run `service install` again; it deletes nothing."
4558                    .to_string(),
4559            }),
4560            // Not reported when the record was refused rather than absent: the
4561            // problem above already names the real state, and "there is no
4562            // install record" would be a second, wrong diagnosis of the same
4563            // file, with a `service uninstall` in its remedy.
4564            (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
4565                subject: "record",
4566                detail: format!(
4567                    "{} holds a registration for this service but there is no install record, so \
4568                     the path it was installed from and the directories it was installed against \
4569                     are unknown. Run `service uninstall` and `service install`.",
4570                    found.manager
4571                ),
4572            }),
4573            (None, Some(_)) => {}
4574            (Some(record), Some(found)) => {
4575                if record.start_mode != found.start_mode {
4576                    problems.push(StatusProblem {
4577                        subject: "start mode",
4578                        detail: format!(
4579                            "the record says {} and {} holds a {} registration. Switch the start \
4580                             mode again to make them agree.",
4581                            record.start_mode, found.manager, found.start_mode
4582                        ),
4583                    });
4584                }
4585                if record.start_mode == StartMode::Boot && !found.starts_automatically {
4586                    problems.push(StatusProblem {
4587                        subject: "start mode",
4588                        detail: format!(
4589                            "{} holds the registration but will not start it by itself, so this \
4590                             host does not resume work after a reboot.",
4591                            found.manager
4592                        ),
4593                    });
4594                }
4595                if let Some(actual) = found.restart_delay {
4596                    let expected = record.restart().effective_delay(found.manager);
4597                    if actual != expected {
4598                        problems.push(StatusProblem {
4599                            subject: "restart policy",
4600                            detail: format!(
4601                                "the record says the service restarts after {}s and {} reports \
4602                                 {}s. Something has edited the registration since it was \
4603                                 installed.",
4604                                expected.as_secs(),
4605                                found.manager,
4606                                actual.as_secs()
4607                            ),
4608                        });
4609                    } else if expected != record.restart().delay() {
4610                        // Not a fault. Task Scheduler expresses this in whole
4611                        // minutes, so the delay in force is longer than the one
4612                        // asked for -- never shorter, which is the direction
4613                        // the requirement cares about.
4614                        notes.push(format!(
4615                            "{} expresses the restart delay in whole minutes, so the {}s asked \
4616                             for is enforced as {}s. The service therefore never restarts faster \
4617                             than the configured bound.",
4618                            found.manager,
4619                            record.restart().delay().as_secs(),
4620                            expected.as_secs()
4621                        ));
4622                    }
4623                }
4624                if found.starts_automatically && !found.running {
4625                    problems.push(StatusProblem {
4626                        subject: "runtime",
4627                        detail: format!(
4628                            "{} holds an automatic registration, but the daemon is stopped. Run \
4629                             `runner-manager service install --start-at {}`; if the service \
4630                             manager denies replacement, retry from an elevated terminal. If it \
4631                             stops again, inspect {}.",
4632                            found.manager,
4633                            found.start_mode,
4634                            log_file.display()
4635                        ),
4636                    });
4637                }
4638            }
4639            (None, None) => {}
4640        }
4641
4642        // The two facts here really are independent, which is what `f1`'s
4643        // `status.rs` says this check needs and could not have there: the scope
4644        // is derived from the mode the **record** carries, and it is compared
4645        // against the mode the **service manager** actually holds.
4646        let store = record.as_ref().and_then(|record| {
4647            let registered_mode = registration
4648                .as_ref()
4649                .map_or(record.start_mode, |found| found.start_mode);
4650            crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
4651                .ok()
4652                .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
4653        });
4654        if let Some(store) = &store
4655            && !store.agrees_with_start_mode()
4656        {
4657            problems.push(StatusProblem {
4658                subject: "secret store",
4659                detail: format!(
4660                    "{store}. Run `auth login` again so the token is stored where the registered \
4661                     start mode can read it."
4662                ),
4663            });
4664        }
4665
4666        if record.as_ref().map(|record| record.start_mode) == Some(StartMode::Login) {
4667            notes.push(
4668                "This registration starts at login, so the agent does not run until the operator \
4669                 signs in; this host does not resume work after an unattended reboot."
4670                    .to_string(),
4671            );
4672        }
4673        if last_github_contact.is_none() {
4674            notes.push(
4675                "GitHub has not been reached successfully since this host's state directory was \
4676                 created."
4677                    .to_string(),
4678            );
4679        }
4680
4681        // The privileged inspection output. Read-only, tolerant of an account
4682        // that may not read the descriptor at all, and never a `problem`: a
4683        // root this account cannot inspect is a true statement about this
4684        // account's rights, not a fault in the registration. What refuses an
4685        // install is the preflight in `runner_root_access`, which runs as the
4686        // installing account and has the authority to know.
4687        let runner_root = crate::runner_root::default_runner_root(paths)
4688            .ok()
4689            .map(|root| {
4690                let path = root.as_path().to_path_buf();
4691                let report = crate::runner_root_access::report(&path);
4692                (path, report)
4693            });
4694        // Reported, and deliberately not a `problem`. The Definition of Done
4695        // asks for a broad root to be "reported and fail the security
4696        // preflight", and the security preflight is `install`'s -- which runs
4697        // as the installing account and refuses outright. `service status` runs
4698        // as whoever typed it, is expected to be readable on a host with
4699        // nothing installed at all, and drives an exit code; turning a
4700        // directory that predates this feature into a non-zero exit for a
4701        // machine that has never installed the service would report a fault
4702        // that is not this registration's.
4703        if let Some((
4704            path,
4705            RootAccessReport::Present {
4706                broad_write: true, ..
4707            },
4708        )) = &runner_root
4709        {
4710            notes.push(format!(
4711                "the platform default runner root {} can be written by ordinary local users, so \
4712                 it is not a safe place to run jobs. `service install` refuses it rather than \
4713                 tightening it, because the contents of a directory anybody could write cannot \
4714                 be trusted: remove or empty it, or choose another root with `runner-manager \
4715                 host set-runtime-root --path <PATH>`.",
4716                path.display()
4717            ));
4718        }
4719
4720        // The surface the agent has nowhere else to reach. A root that refuses
4721        // a launch does so before any attempt row exists, and the daemon's log
4722        // redacts the paths out of the sentence, so without this the operator
4723        // sees `runner_start_failed reason=other` once per poll and nothing
4724        // that names the directory or the fix.
4725        //
4726        // A **note** and not a problem, by the same rule the broad-write root
4727        // above obeys: `service status` runs as whoever typed it, is expected
4728        // to be readable on a host with nothing installed at all, and drives an
4729        // exit code. A problem here would fail that exit code on three hosts
4730        // that are not broken -- one whose root was fixed but which has had no
4731        // queued job since, one whose service was uninstalled without clearing
4732        // `state/`, and one that only ever ran the agent in the foreground --
4733        // and the printed remedy (`service uninstall && service install`) does
4734        // not touch `state/`, so following it would not clear the error either.
4735        // A note is read by the same operator at the same moment and traps
4736        // nobody in a loop.
4737        //
4738        // A read failure is reported and not swallowed, for the reason
4739        // `runner_root_refusals` documents: absence means "every policy is
4740        // placing runners", and answering a parse failure with that would be
4741        // the wrong answer to the question this row exists for.
4742        match runner_root_refusals(paths) {
4743            Ok(refusals) => {
4744                for refusal in refusals {
4745                    notes.push(format!(
4746                        "policy {} started no runner: its runner root {} refused the launch \
4747                         ({}), last at {}. {} This clears when that policy next places a \
4748                         runner.",
4749                        refusal.policy,
4750                        refusal.root,
4751                        refusal.kind,
4752                        refusal.at.to_rfc3339(),
4753                        refusal.detail,
4754                    ));
4755                }
4756            }
4757            Err(error) => notes.push(format!(
4758                "whether the agent could use its runner roots could not be read: {error}"
4759            )),
4760        }
4761
4762        Self {
4763            identity,
4764            record,
4765            registration,
4766            binary,
4767            log_file,
4768            store,
4769            last_github_contact,
4770            runner_root,
4771            credential_rejected_since: None,
4772            problems,
4773            notes,
4774        }
4775    }
4776
4777    /// Reports a definition this build would render differently.
4778    #[must_use]
4779    pub fn with_definition_drift(mut self, drift: Option<DefinitionDrift>) -> Self {
4780        if let Some(drift) = drift {
4781            let repair = match drift.start_mode {
4782                StartMode::Boot => {
4783                    "`sudo systemctl stop runner-manager && sudo runner-manager service install \
4784                     --start-at boot` (for a managed WSL host, run `runner-manager wsl install \
4785                     --distribution <name>` from Windows)"
4786                }
4787                StartMode::Login => {
4788                    "`systemctl --user stop runner-manager && runner-manager service install \
4789                     --start-at login`"
4790                }
4791            };
4792            self.problems.push(StatusProblem {
4793                subject: "definition",
4794                detail: format!(
4795                    "{} was written by runner-manager {} and differs from what {} renders, so \
4796                     the daemon runs under outdated settings (for example a credential frozen \
4797                     at service start). Re-register it with {repair}; configuration and \
4798                     credentials are kept.",
4799                    drift.path.display(),
4800                    drift.installed_by_version,
4801                    env!("CARGO_PKG_VERSION"),
4802                ),
4803            });
4804        }
4805        self
4806    }
4807
4808    /// Reports that GitHub is rejecting the daemon's credential.
4809    #[must_use]
4810    pub fn with_credential_rejection(mut self, since: Option<DateTime<Utc>>) -> Self {
4811        if let Some(since) = since {
4812            self.credential_rejected_since = Some(since);
4813            self.problems.push(StatusProblem {
4814                subject: "github credential",
4815                detail: format!(
4816                    "GitHub has rejected the service's credential since {}, so the service \
4817                     cannot see queued jobs or start runners. Run `runner-manager auth login` \
4818                     (for a managed WSL host, `runner-manager --host wsl:<name> auth login` \
4819                     from Windows).",
4820                    since.to_rfc3339()
4821                ),
4822            });
4823        }
4824        self
4825    }
4826
4827    /// Since when GitHub has rejected the service's credential, if it has.
4828    #[must_use]
4829    pub const fn credential_rejected_since(&self) -> Option<DateTime<Utc>> {
4830        self.credential_rejected_since
4831    }
4832
4833    /// What this host's default runner root grants, and to whom.
4834    ///
4835    /// `None` when the platform default could not even be resolved. The
4836    /// descriptor inside has been through
4837    /// [`crate::runner_root_access::redact`], so it names the well-known
4838    /// trustees and says "an account" for everything else — no more identity
4839    /// than the `account` line above it already prints.
4840    #[must_use]
4841    pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
4842        self.runner_root
4843            .as_ref()
4844            .map(|(path, report)| (path.as_path(), report))
4845    }
4846
4847    /// Whether a registration exists at all.
4848    #[must_use]
4849    pub const fn is_installed(&self) -> bool {
4850        self.registration.is_some() || self.record.is_some()
4851    }
4852
4853    /// Whether the daemon is running now.
4854    #[must_use]
4855    pub fn is_running(&self) -> bool {
4856        self.registration
4857            .as_ref()
4858            .is_some_and(|registration| registration.running)
4859    }
4860
4861    /// **False whenever anything is wrong**, including a stale binary path.
4862    #[must_use]
4863    pub fn is_healthy(&self) -> bool {
4864        self.problems.is_empty()
4865    }
4866
4867    /// Everything that is wrong.
4868    #[must_use]
4869    pub fn problems(&self) -> &[StatusProblem] {
4870        &self.problems
4871    }
4872
4873    /// True statements that are not faults — a login-mode registration not
4874    /// running unattended, or an agent that has not yet reached GitHub.
4875    #[must_use]
4876    pub fn notes(&self) -> &[String] {
4877        &self.notes
4878    }
4879
4880    /// The recorded start mode. Journey 5 step 4.
4881    #[must_use]
4882    pub fn start_mode(&self) -> Option<StartMode> {
4883        self.record.as_ref().map(|record| record.start_mode)
4884    }
4885
4886    /// The resolved absolute binary path and what became of it. Journey 5
4887    /// step 4 and `05-infrastructure.md` item 6.
4888    #[must_use]
4889    pub const fn binary(&self) -> Option<&BinaryPath> {
4890        self.binary.as_ref()
4891    }
4892
4893    /// The diagnostic log path. `05-infrastructure.md` item 4.
4894    #[must_use]
4895    pub fn log_file(&self) -> &Path {
4896        &self.log_file
4897    }
4898
4899    /// The last successful GitHub contact. Journey 5 step 4.
4900    #[must_use]
4901    pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
4902        self.last_github_contact
4903    }
4904
4905    /// Which store the daemon reads, and whether that agrees with the
4906    /// registration.
4907    #[must_use]
4908    pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
4909        self.store.as_ref()
4910    }
4911
4912    /// The record `install` wrote.
4913    #[must_use]
4914    pub const fn record(&self) -> Option<&InstallRecord> {
4915        self.record.as_ref()
4916    }
4917
4918    /// What the service manager says.
4919    #[must_use]
4920    pub const fn registration(&self) -> Option<&Registration> {
4921        self.registration.as_ref()
4922    }
4923}
4924
4925impl fmt::Display for ServiceStatus {
4926    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4927        writeln!(f, "Service: {}", self.identity)?;
4928        match (&self.record, &self.registration) {
4929            (None, None) => {
4930                writeln!(
4931                    f,
4932                    "  installed                 no. `service install` registers `{} {}`.",
4933                    SERVICE_NAME,
4934                    DAEMON_ARGUMENTS.join(" ")
4935                )?;
4936            }
4937            _ => {
4938                let manager = self
4939                    .registration
4940                    .as_ref()
4941                    .map(|registration| registration.manager.manager());
4942                writeln!(
4943                    f,
4944                    "  installed                 {}",
4945                    manager.unwrap_or("yes, but no service manager knows it")
4946                )?;
4947                writeln!(
4948                    f,
4949                    "  state                     {}",
4950                    if self.is_running() {
4951                        "running"
4952                    } else {
4953                        "not running"
4954                    }
4955                )?;
4956            }
4957        }
4958        if let Some(record) = &self.record {
4959            writeln!(f, "  start mode                {}", record.start_mode)?;
4960            writeln!(f, "  account                   {}", record.account)?;
4961            writeln!(f, "  restart on failure        {}", record.restart())?;
4962            writeln!(
4963                f,
4964                "  arguments                 {}",
4965                record.arguments.join(" ")
4966            )?;
4967        }
4968        if let Some(binary) = &self.binary {
4969            writeln!(f, "  binary                    {binary}")?;
4970        }
4971        writeln!(f, "  diagnostic log            {}", self.log_file.display())?;
4972        if let Some((path, report)) = &self.runner_root {
4973            // Named as the *default* rather than as "the runner root", because
4974            // it is only the effective one until an operator runs
4975            // `host set-runtime-root`. This crate cannot see that setting — it
4976            // lives in the application's store — so an unqualified label here
4977            // would contradict the `runner root … (host-configured)` row that
4978            // `status` and `host show` print from the value that is in force.
4979            writeln!(f, "  default runner root       {}", path.display())?;
4980            if *report != RootAccessReport::NotApplicable {
4981                writeln!(f, "  default root access       {report}")?;
4982            }
4983        }
4984        if let Some(store) = &self.store {
4985            writeln!(f, "  secret store              {store}")?;
4986        }
4987        writeln!(
4988            f,
4989            "  last GitHub contact       {}",
4990            match self.last_github_contact {
4991                Some(at) => at.to_rfc3339(),
4992                None => "never".to_string(),
4993            }
4994        )?;
4995        for note in &self.notes {
4996            writeln!(f, "  note                      {note}")?;
4997        }
4998        for problem in &self.problems {
4999            writeln!(f, "  ERROR                     {problem}")?;
5000        }
5001        write!(
5002            f,
5003            "  verdict                   {}",
5004            if self.is_healthy() {
5005                "healthy"
5006            } else {
5007                "NOT healthy"
5008            }
5009        )
5010    }
5011}
5012
5013// ---------------------------------------------------------------------------
5014// The in-memory double
5015// ---------------------------------------------------------------------------
5016
5017/// A [`ControlFactory`] that registers nothing and remembers everything.
5018///
5019/// Public rather than `#[cfg(test)]` on purpose. `f3` builds three commands on
5020/// [`ServiceOperations`] and has to be able to test them without a service
5021/// manager, and a double that lived behind `#[cfg(test)]` here would be
5022/// invisible from another crate — which would leave `f3` either untested or
5023/// writing a second double that drifts from this one.
5024///
5025/// It is not a simulator. It stores what it was asked to register and reports it
5026/// back, which is exactly enough to exercise the logic that sits *above* a
5027/// service manager: the lock refusal, the record, the stale-path detection, the
5028/// start-mode switch, and the promise that uninstall deletes nothing else.
5029#[derive(Debug, Clone, Default)]
5030pub struct RecordingControls {
5031    state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
5032}
5033
5034#[derive(Debug, Default)]
5035struct RecordingState {
5036    registrations: BTreeMap<(StartMode, String), Registration>,
5037    definitions: BTreeMap<String, ServiceDefinition>,
5038    calls: Vec<String>,
5039    #[cfg(test)]
5040    install_failures: BTreeMap<StartMode, String>,
5041    #[cfg(test)]
5042    after_install: BTreeMap<StartMode, TestInstallSideEffect>,
5043}
5044
5045#[cfg(test)]
5046#[derive(Debug, Clone)]
5047enum TestInstallSideEffect {
5048    HideDirectory { directory: PathBuf, hidden: PathBuf },
5049}
5050
5051impl RecordingControls {
5052    /// A factory holding no registrations.
5053    #[must_use]
5054    pub fn new() -> Self {
5055        Self::default()
5056    }
5057
5058    /// Every call made through this factory, in order, as
5059    /// `"<operation> <name> (<mode>)"`.
5060    #[must_use]
5061    pub fn calls(&self) -> Vec<String> {
5062        self.state.lock().expect("not poisoned").calls.clone()
5063    }
5064
5065    /// Every registration currently held, with the domain it is in.
5066    #[must_use]
5067    pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
5068        self.state
5069            .lock()
5070            .expect("not poisoned")
5071            .registrations
5072            .iter()
5073            .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
5074            .collect()
5075    }
5076
5077    /// The definition applied for a registration, if it is held.
5078    #[must_use]
5079    pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
5080        self.state
5081            .lock()
5082            .expect("not poisoned")
5083            .definitions
5084            .get(name)
5085            .cloned()
5086    }
5087
5088    /// Edits a held registration, as something outside this product would.
5089    ///
5090    /// This is how the divergence and start-type problems are made reachable
5091    /// from a test: `sc config`, `launchctl` and `systemctl` can all change a
5092    /// registration after installation, and a status command that could not be
5093    /// shown detecting that would be a status command nobody had tested against
5094    /// the case it exists for.
5095    ///
5096    /// Does nothing when no registration of that name is held.
5097    pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
5098        let mut state = self.state.lock().expect("not poisoned");
5099        if let Some((_, registration)) = state
5100            .registrations
5101            .iter_mut()
5102            .find(|((_, held), _)| held == name)
5103        {
5104            edit(registration);
5105        }
5106    }
5107
5108    #[cfg(test)]
5109    fn fail_next_install(&self, mode: StartMode, detail: &str) {
5110        self.state
5111            .lock()
5112            .expect("not poisoned")
5113            .install_failures
5114            .insert(mode, detail.to_string());
5115    }
5116
5117    #[cfg(test)]
5118    fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
5119        self.state
5120            .lock()
5121            .expect("not poisoned")
5122            .after_install
5123            .insert(
5124                mode,
5125                TestInstallSideEffect::HideDirectory { directory, hidden },
5126            );
5127    }
5128}
5129
5130impl ControlFactory for RecordingControls {
5131    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5132        Ok(Box::new(RecordingControl {
5133            mode,
5134            state: std::sync::Arc::clone(&self.state),
5135        }))
5136    }
5137}
5138
5139#[derive(Debug)]
5140struct RecordingControl {
5141    mode: StartMode,
5142    state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
5143}
5144
5145impl RecordingControl {
5146    fn note(&self, operation: &str, name: &str) {
5147        self.state
5148            .lock()
5149            .expect("not poisoned")
5150            .calls
5151            .push(format!("{operation} {name} ({})", self.mode));
5152    }
5153}
5154
5155impl ServiceControl for RecordingControl {
5156    fn manager(&self) -> DefinitionKind {
5157        // The double reports the kind this host's real backend would, so a test
5158        // that asserts on the manager name asserts something true of the
5159        // platform it is running on.
5160        host_definition_kind(self.mode)
5161    }
5162
5163    fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5164        self.note("install", plan.identity().name());
5165        #[cfg(test)]
5166        if let Some(detail) = self
5167            .state
5168            .lock()
5169            .expect("not poisoned")
5170            .install_failures
5171            .remove(&self.mode)
5172        {
5173            return Err(ServiceError::Control {
5174                operation: "install",
5175                name: plan.identity().name().to_string(),
5176                manager: "recording control",
5177                detail,
5178            });
5179        }
5180        // The real definition, not a stub. Rendering is pure, so the double can
5181        // afford it -- and a stub would make `Installed::review` report that the
5182        // definition confirms nothing, which is exactly the shape of false
5183        // assurance a test double must never hand back.
5184        let definition = ServiceDefinition::for_host(plan)?;
5185        let mut state = self.state.lock().expect("not poisoned");
5186        state.registrations.insert(
5187            (self.mode, plan.identity().name().to_string()),
5188            Registration {
5189                manager: host_definition_kind(self.mode),
5190                start_mode: self.mode,
5191                command_line: plan.command_line(),
5192                account: Some(plan.account().as_str().to_string()),
5193                running: false,
5194                starts_automatically: true,
5195                restart_delay: Some(plan.restart().delay()),
5196            },
5197        );
5198        state
5199            .definitions
5200            .insert(plan.identity().name().to_string(), definition.clone());
5201        #[cfg(test)]
5202        let side_effect = state.after_install.remove(&self.mode);
5203        drop(state);
5204        #[cfg(test)]
5205        if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
5206            std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
5207            std::fs::write(&directory, b"blocks recreation")
5208                .expect("test fault can block record directory recreation");
5209        }
5210        Ok(definition)
5211    }
5212
5213    fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5214        self.note("uninstall", identity.name());
5215        let mut state = self.state.lock().expect("not poisoned");
5216        state.definitions.remove(identity.name());
5217        Ok(state
5218            .registrations
5219            .remove(&(self.mode, identity.name().to_string()))
5220            .is_some())
5221    }
5222
5223    fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
5224        self.note("query", identity.name());
5225        Ok(self
5226            .state
5227            .lock()
5228            .expect("not poisoned")
5229            .registrations
5230            .get(&(self.mode, identity.name().to_string()))
5231            .cloned())
5232    }
5233
5234    fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
5235        self.note("start", identity.name());
5236        let mut state = self.state.lock().expect("not poisoned");
5237        match state
5238            .registrations
5239            .get_mut(&(self.mode, identity.name().to_string()))
5240        {
5241            Some(registration) => {
5242                registration.running = true;
5243                Ok(())
5244            }
5245            None => Err(ServiceError::NotInstalled {
5246                name: identity.name().to_string(),
5247                operation: "start",
5248            }),
5249        }
5250    }
5251
5252    fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5253        self.note("stop", identity.name());
5254        let mut state = self.state.lock().expect("not poisoned");
5255        match state
5256            .registrations
5257            .get_mut(&(self.mode, identity.name().to_string()))
5258        {
5259            Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
5260            None => Err(ServiceError::NotInstalled {
5261                name: identity.name().to_string(),
5262                operation: "stop",
5263            }),
5264        }
5265    }
5266}
5267
5268impl ControlFactory for HostControls {
5269    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5270        sys::control(mode)
5271    }
5272
5273    fn installed_definition(&self, path: &Path) -> Option<String> {
5274        std::fs::read_to_string(path).ok()
5275    }
5276}
5277
5278/// The operator's home directory, when the platform reports one.
5279///
5280/// Only login-mode registrations need it — a LaunchAgent and a systemd user
5281/// unit both live under it — and it is resolved lazily so that a boot-mode
5282/// operation on an account with no profile is not refused for wanting something
5283/// it never uses.
5284fn host_home() -> Option<PathBuf> {
5285    directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
5286}
5287
5288/// The Unix backends' name for the same thing.
5289#[cfg(unix)]
5290fn home_directory() -> Option<PathBuf> {
5291    host_home()
5292}
5293
5294/// Runs a command and returns its exit status with both streams as text.
5295///
5296/// The three Unix backends and the Windows Task Scheduler backend all drive a
5297/// stock command-line tool, and all four want the same three things back. The
5298/// tools are chosen for machine-readable, locale-independent output wherever one
5299/// exists — `systemctl is-active`, `launchctl print`, `schtasks /XML` — because
5300/// parsing a localised human-facing table is how a status command starts lying
5301/// on somebody else's machine.
5302fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
5303    let output = std::process::Command::new(program)
5304        .args(arguments)
5305        .output()?;
5306    Ok((
5307        output.status.success(),
5308        String::from_utf8_lossy(&output.stdout).into_owned(),
5309        String::from_utf8_lossy(&output.stderr).into_owned(),
5310    ))
5311}
5312
5313/// Which manager holds a given start-mode domain on the platform this binary
5314/// was built for.
5315#[must_use]
5316pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
5317    if cfg!(windows) {
5318        match mode {
5319            StartMode::Boot => DefinitionKind::WindowsService,
5320            StartMode::Login => DefinitionKind::WindowsScheduledTask,
5321        }
5322    } else if cfg!(target_os = "macos") {
5323        DefinitionKind::LaunchdPlist
5324    } else {
5325        DefinitionKind::SystemdUnit
5326    }
5327}
5328
5329/// Enables a launchd label after it has been bootstrapped.
5330///
5331/// `launchctl bootstrap` deliberately preserves a label's disabled bit. An
5332/// `enable` failure therefore means the new registration cannot satisfy its
5333/// start policy. Compensate by booting it out and removing the plist so a
5334/// failed install cannot look installed to `service status`.
5335#[cfg(any(target_os = "macos", test))]
5336fn enable_launchd_registration(
5337    mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
5338    domain: &str,
5339    service_target: &str,
5340    plist: &Path,
5341    name: &str,
5342    elevation_remedy: &'static str,
5343) -> Result<(), ServiceError> {
5344    let (enabled, cause) = launchctl(&[
5345        std::ffi::OsStr::new("enable"),
5346        std::ffi::OsStr::new(service_target),
5347    ]);
5348    if enabled {
5349        return Ok(());
5350    }
5351
5352    let (booted_out, bootout_detail) = launchctl(&[
5353        std::ffi::OsStr::new("bootout"),
5354        std::ffi::OsStr::new(service_target),
5355    ]);
5356    let removed = std::fs::remove_file(plist);
5357    if !booted_out || removed.is_err() {
5358        return Err(ServiceError::Rollback {
5359            operation: "enable launchd registration",
5360            name: name.to_string(),
5361            cause,
5362            rollback: format!(
5363                "launchctl bootout {domain}: {}; remove {}: {}",
5364                if booted_out {
5365                    "succeeded".to_string()
5366                } else {
5367                    bootout_detail
5368                },
5369                plist.display(),
5370                removed
5371                    .err()
5372                    .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
5373            ),
5374        });
5375    }
5376
5377    if cause.to_ascii_lowercase().contains("permission denied") {
5378        Err(ServiceError::NeedsElevation {
5379            operation: "enable",
5380            name: name.to_string(),
5381            detail: cause,
5382            remedy: elevation_remedy,
5383        })
5384    } else {
5385        Err(ServiceError::Control {
5386            operation: "enable",
5387            name: name.to_string(),
5388            manager: "launchd",
5389            detail: cause,
5390        })
5391    }
5392}
5393
5394// ---------------------------------------------------------------------------
5395// Windows
5396// ---------------------------------------------------------------------------
5397
5398/// A stop request delivered by the Windows Service Control Manager.
5399///
5400/// The application owns the drain policy; this platform boundary only turns
5401/// `SERVICE_CONTROL_STOP`/`SHUTDOWN` into an awaitable notification.
5402#[derive(Debug, Clone)]
5403pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
5404
5405impl ServiceShutdown {
5406    /// Waits until SCM asks the service to stop or shut down.
5407    pub async fn wait(mut self) {
5408        if !*self.0.borrow() {
5409            let _ = self.0.changed().await;
5410        }
5411    }
5412}
5413
5414/// Runs the production process as a real Windows service host.
5415///
5416/// `StartServiceCtrlDispatcher` must run in the service process's main thread,
5417/// so the callback is handed through a process-global slot to the entrypoint
5418/// invoked by SCM. The slot is single-use by design: one process hosts exactly
5419/// one own-process service.
5420#[cfg(windows)]
5421pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
5422where
5423    F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
5424{
5425    windows_host::run(Box::new(run))
5426}
5427
5428#[cfg(windows)]
5429mod windows_host {
5430    use std::ffi::OsString;
5431    use std::sync::{Arc, Mutex, OnceLock, mpsc};
5432    use std::time::Duration;
5433
5434    use windows_service::service::{
5435        ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
5436        ServiceType,
5437    };
5438    use windows_service::service_control_handler::{
5439        self, ServiceControlHandlerResult, ServiceStatusHandle,
5440    };
5441
5442    use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
5443
5444    type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
5445
5446    struct Invocation {
5447        run: Runner,
5448        result: mpsc::SyncSender<Result<u8, String>>,
5449    }
5450
5451    static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
5452
5453    windows_service::define_windows_service!(ffi_service_main, service_main);
5454
5455    pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
5456        let (result_tx, result_rx) = mpsc::sync_channel(1);
5457        let slot = INVOCATION.get_or_init(|| Mutex::new(None));
5458        let mut invocation = slot
5459            .lock()
5460            .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
5461        if invocation.is_some() {
5462            return Err(host_error(
5463                "prepare",
5464                "the service-host slot was already used",
5465            ));
5466        }
5467        *invocation = Some(Invocation {
5468            run,
5469            result: result_tx,
5470        });
5471        drop(invocation);
5472
5473        windows_service::service_dispatcher::start("", ffi_service_main)
5474            .map_err(|error| host_error("connect", &error.to_string()))?;
5475        result_rx
5476            .recv()
5477            .map_err(|error| host_error("finish", &error.to_string()))?
5478            .map_err(|detail| host_error("run", &detail))
5479    }
5480
5481    fn service_main(_arguments: Vec<OsString>) {
5482        let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
5483            return;
5484        };
5485        let result = run_service(invocation.run);
5486        let _ = invocation.result.send(result);
5487    }
5488
5489    fn run_service(run: Runner) -> Result<u8, String> {
5490        let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
5491        let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
5492        let handler_status = Arc::clone(&status);
5493        let handler = move |control| match control {
5494            ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
5495            ServiceControl::Stop | ServiceControl::Shutdown => {
5496                if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
5497                    let _ = handle.set_service_status(service_status(
5498                        ServiceState::StopPending,
5499                        ServiceControlAccept::empty(),
5500                        1,
5501                        Duration::from_secs(300),
5502                        0,
5503                    ));
5504                }
5505                let _ = stop_tx.send(true);
5506                ServiceControlHandlerResult::NoError
5507            }
5508            _ => ServiceControlHandlerResult::NotImplemented,
5509        };
5510        let handle = service_control_handler::register("", handler)
5511            .map_err(|error| format!("cannot register the service control handler: {error}"))?;
5512        *status
5513            .lock()
5514            .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
5515        handle
5516            .set_service_status(service_status(
5517                ServiceState::Running,
5518                ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
5519                0,
5520                Duration::default(),
5521                0,
5522            ))
5523            .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
5524
5525        let exit = run(ServiceShutdown(stop_rx));
5526        handle
5527            .set_service_status(service_status(
5528                ServiceState::Stopped,
5529                ServiceControlAccept::empty(),
5530                0,
5531                Duration::default(),
5532                u32::from(exit),
5533            ))
5534            .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
5535        Ok(exit)
5536    }
5537
5538    fn service_status(
5539        state: ServiceState,
5540        accepted: ServiceControlAccept,
5541        checkpoint: u32,
5542        wait_hint: Duration,
5543        exit: u32,
5544    ) -> ServiceStatus {
5545        ServiceStatus {
5546            service_type: ServiceType::OWN_PROCESS,
5547            current_state: state,
5548            controls_accepted: accepted,
5549            exit_code: if exit == 0 {
5550                ServiceExitCode::Win32(0)
5551            } else {
5552                ServiceExitCode::ServiceSpecific(exit)
5553            },
5554            checkpoint,
5555            wait_hint,
5556            process_id: None,
5557        }
5558    }
5559
5560    fn host_error(operation: &'static str, detail: &str) -> ServiceError {
5561        ServiceError::Control {
5562            operation,
5563            name: SERVICE_NAME.to_string(),
5564            manager: "the Windows Service Control Manager",
5565            detail: detail.to_string(),
5566        }
5567    }
5568}
5569
5570#[cfg(windows)]
5571mod sys {
5572    //! Two managers, because Windows has two answers.
5573    //!
5574    //! `--start-at boot` is a service in the Service Control Manager, which is
5575    //! the only Windows facility that starts something before anybody logs in.
5576    //! `--start-at login` is a Task Scheduler task with a logon trigger, which
5577    //! is the only Windows facility that starts something when somebody does.
5578    //! There is no single mechanism that does both: service trigger-start has no
5579    //! logon trigger, and a scheduled task cannot run before a session exists.
5580
5581    use std::ffi::{OsStr, OsString};
5582    use std::time::{Duration, Instant};
5583
5584    use runner_manager_domain::model::StartMode;
5585    use windows_service::service::{
5586        ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
5587        ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
5588        ServiceState, ServiceType,
5589    };
5590    use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
5591
5592    use super::{
5593        DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
5594        ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
5595    };
5596
5597    /// `ERROR_SERVICE_DOES_NOT_EXIST`. Not an error here: it is the answer to
5598    /// "is this registered".
5599    const SERVICE_DOES_NOT_EXIST: i32 = 1060;
5600    /// `ERROR_SERVICE_MARKED_FOR_DELETE`. `DeleteService` is asynchronous: the
5601    /// registration remains in this state until every open service handle is
5602    /// closed, and callers must not mistake that short window for a leak.
5603    const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
5604    /// `ERROR_ACCESS_DENIED`.
5605    const ACCESS_DENIED: i32 = 5;
5606    const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
5607    const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
5608
5609    const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
5610         choose \"Run as administrator\".";
5611
5612    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5613        Ok(match mode {
5614            StartMode::Boot => Box::new(ScmControl),
5615            StartMode::Login => Box::new(TaskControl),
5616        })
5617    }
5618
5619    // -- the Service Control Manager -----------------------------------------
5620
5621    #[derive(Debug)]
5622    struct ScmControl;
5623
5624    /// Turns a `windows-service` failure into this module's error, keeping the
5625    /// "needs elevation" case separate: an operator told "access is denied" and
5626    /// an operator told "run this elevated" are not equally well served.
5627    fn scm_error(
5628        operation: &'static str,
5629        name: &str,
5630        error: &windows_service::Error,
5631    ) -> ServiceError {
5632        let raw = match error {
5633            windows_service::Error::Winapi(io) => io.raw_os_error(),
5634            _ => None,
5635        };
5636        let detail = match error {
5637            windows_service::Error::Winapi(io) => io.to_string(),
5638            other => other.to_string(),
5639        };
5640        if raw == Some(ACCESS_DENIED) {
5641            return ServiceError::NeedsElevation {
5642                operation,
5643                name: name.to_string(),
5644                detail,
5645                remedy: ELEVATION_REMEDY,
5646            };
5647        }
5648        ServiceError::Control {
5649            operation,
5650            name: name.to_string(),
5651            manager: "the Windows Service Control Manager",
5652            detail,
5653        }
5654    }
5655
5656    fn open_manager(
5657        access: ServiceManagerAccess,
5658        operation: &'static str,
5659        name: &str,
5660    ) -> Result<ServiceManager, ServiceError> {
5661        ServiceManager::local_computer(None::<&OsStr>, access)
5662            .map_err(|error| scm_error(operation, name, &error))
5663    }
5664
5665    impl ServiceControl for ScmControl {
5666        fn manager(&self) -> DefinitionKind {
5667            DefinitionKind::WindowsService
5668        }
5669
5670        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5671            let spec = windows_service_spec(plan);
5672            let manager = open_manager(
5673                ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
5674                "install",
5675                &spec.name,
5676            )?;
5677            let info = ServiceInfo {
5678                name: OsString::from(&spec.name),
5679                display_name: OsString::from(&spec.display_name),
5680                // Never INTERACTIVE_PROCESS: see `windows_service_descriptor`.
5681                service_type: ServiceType::OWN_PROCESS,
5682                start_type: if spec.automatic_start {
5683                    ServiceStartType::AutoStart
5684                } else {
5685                    ServiceStartType::OnDemand
5686                },
5687                error_control: ServiceErrorControl::Normal,
5688                executable_path: plan.binary().to_path_buf(),
5689                launch_arguments: plan.arguments().to_vec(),
5690                dependencies: Vec::new(),
5691                // `None` is LocalSystem, which is the account `d2`'s DACL admits.
5692                account_name: spec.account.as_ref().map(OsString::from),
5693                // No password is ever taken, asked for, or stored. An account
5694                // that needed one would be an account this installer refuses.
5695                account_password: None,
5696            };
5697            let service = manager
5698                .create_service(
5699                    &info,
5700                    ServiceAccess::CHANGE_CONFIG
5701                        | ServiceAccess::QUERY_CONFIG
5702                        | ServiceAccess::QUERY_STATUS
5703                        | ServiceAccess::START
5704                        | ServiceAccess::STOP
5705                        | ServiceAccess::DELETE,
5706                )
5707                .map_err(|error| scm_error("install", &spec.name, &error))?;
5708            service
5709                .set_description(&spec.description)
5710                .map_err(|error| scm_error("describe", &spec.name, &error))?;
5711            service
5712                .update_failure_actions(ServiceFailureActions {
5713                    reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
5714                    reboot_msg: None,
5715                    command: None,
5716                    // Three identical restart actions rather than one: the
5717                    // Service Control Manager applies the first action to the
5718                    // first failure, the second to the second, and the last to
5719                    // every failure after that. One action would leave the
5720                    // second and later failures unhandled, which is a service
5721                    // that comes back once and then stays down.
5722                    actions: Some(vec![
5723                        ServiceAction {
5724                            action_type: ServiceActionType::Restart,
5725                            delay: spec.restart.delay(),
5726                        };
5727                        3
5728                    ]),
5729                })
5730                .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
5731            // Without this the failure actions apply only to a crash, and a
5732            // daemon that exits non-zero is not a crash.
5733            service
5734                .set_failure_actions_on_non_crash_failures(true)
5735                .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
5736            Ok(ServiceDefinition::windows_service(plan))
5737        }
5738
5739        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5740            let manager =
5741                open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
5742            let service = match manager.open_service(
5743                identity.name(),
5744                ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
5745            ) {
5746                Ok(service) => Some(service),
5747                Err(error) if is_missing(&error) => return Ok(false),
5748                // A previous caller has already asked SCM to delete this
5749                // registration. That is the desired state, not an uninstall
5750                // failure. Wait for SCM to release the name so a following
5751                // `service install` can use it immediately.
5752                Err(error) if is_marked_for_delete(&error) => None,
5753                Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
5754            };
5755            if let Some(service) = service {
5756                // A running service can be deleted, but it lingers until it stops.
5757                // Stopping first is what makes `uninstall` followed by `install`
5758                // work in one sitting, which is what the start-mode switch needs.
5759                if let Ok(status) = service.query_status()
5760                    && status.current_state != ServiceState::Stopped
5761                {
5762                    let _ = service.stop();
5763                }
5764                match service.delete() {
5765                    Ok(()) => {}
5766                    // DeleteService is idempotent from the operator's point of
5767                    // view. Another process can mark the service between our
5768                    // OpenService and DeleteService calls.
5769                    Err(error) if is_marked_for_delete(&error) => {}
5770                    Err(error) => {
5771                        return Err(scm_error("uninstall", identity.name(), &error));
5772                    }
5773                }
5774                drop(service);
5775            }
5776
5777            // `DeleteService` marks a registration for deletion and returns;
5778            // SCM removes it only after the last service handle closes. Drop
5779            // ours before polling, then wait for the observable postcondition
5780            // promised by `uninstall`: an immediate status check must not see
5781            // a registration that is merely on its way out. This also keeps a
5782            // stop/uninstall/install sequence deterministic on busy hosts.
5783            let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
5784                match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
5785                    Ok(service) => {
5786                        drop(service);
5787                        Ok(false)
5788                    }
5789                    Err(error) if is_missing(&error) => Ok(true),
5790                    Err(error) if is_marked_for_delete(&error) => Ok(false),
5791                    Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
5792                }
5793            })?;
5794            if !absent {
5795                return Err(ServiceError::Control {
5796                    operation: "verify uninstall of",
5797                    name: identity.name().to_string(),
5798                    manager: "the Windows Service Control Manager",
5799                    detail: format!(
5800                        "the registration was still visible {} seconds after DeleteService; \
5801                         retry `service uninstall` from an elevated prompt",
5802                        DELETE_TIMEOUT.as_secs()
5803                    ),
5804                });
5805            }
5806            Ok(true)
5807        }
5808
5809        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
5810            let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
5811            let service = match manager.open_service(
5812                identity.name(),
5813                ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
5814            ) {
5815                Ok(service) => service,
5816                Err(error) if is_missing(&error) => return Ok(None),
5817                Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
5818            };
5819            let config = service
5820                .query_config()
5821                .map_err(|error| scm_error("inspect", identity.name(), &error))?;
5822            let status = service
5823                .query_status()
5824                .map_err(|error| scm_error("inspect", identity.name(), &error))?;
5825            let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
5826                actions
5827                    .actions
5828                    .and_then(|actions| actions.into_iter().next())
5829                    .filter(|action| action.action_type == ServiceActionType::Restart)
5830                    .map(|action| action.delay)
5831            });
5832            Ok(Some(Registration {
5833                manager: DefinitionKind::WindowsService,
5834                start_mode: StartMode::Boot,
5835                // `lpBinaryPathName` holds the executable *and* its arguments
5836                // as one string, which is why this module carries its own
5837                // parser rather than trusting `executable_path`'s name.
5838                command_line: config.executable_path.to_string_lossy().into_owned(),
5839                account: config
5840                    .account_name
5841                    .map(|account| account.to_string_lossy().into_owned()),
5842                running: status.current_state == ServiceState::Running,
5843                starts_automatically: config.start_type == ServiceStartType::AutoStart,
5844                restart_delay,
5845            }))
5846        }
5847
5848        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
5849            let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
5850            let service = manager
5851                .open_service(identity.name(), ServiceAccess::START)
5852                .map_err(|error| scm_error("start", identity.name(), &error))?;
5853            service
5854                .start::<&OsStr>(&[])
5855                .map_err(|error| scm_error("start", identity.name(), &error))
5856        }
5857
5858        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5859            let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
5860            let service = manager
5861                .open_service(
5862                    identity.name(),
5863                    ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
5864                )
5865                .map_err(|error| scm_error("stop", identity.name(), &error))?;
5866            let status = service
5867                .query_status()
5868                .map_err(|error| scm_error("stop", identity.name(), &error))?;
5869            if status.current_state == ServiceState::Stopped {
5870                return Ok(false);
5871            }
5872            service
5873                .stop()
5874                .map_err(|error| scm_error("stop", identity.name(), &error))?;
5875            Ok(true)
5876        }
5877    }
5878
5879    fn is_missing(error: &windows_service::Error) -> bool {
5880        matches!(error, windows_service::Error::Winapi(io)
5881            if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
5882    }
5883
5884    pub(super) fn is_marked_for_delete(error: &windows_service::Error) -> bool {
5885        matches!(error, windows_service::Error::Winapi(io)
5886            if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
5887    }
5888
5889    pub(super) fn wait_until_scm_absent(
5890        timeout: Duration,
5891        poll_interval: Duration,
5892        mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
5893    ) -> Result<bool, ServiceError> {
5894        let deadline = Instant::now() + timeout;
5895        loop {
5896            if probe_absent()? {
5897                return Ok(true);
5898            }
5899            if Instant::now() >= deadline {
5900                return Ok(false);
5901            }
5902            std::thread::sleep(poll_interval);
5903        }
5904    }
5905
5906    // -- Task Scheduler ------------------------------------------------------
5907
5908    #[derive(Debug)]
5909    struct TaskControl;
5910
5911    fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
5912        if detail.to_ascii_lowercase().contains("access is denied") {
5913            return ServiceError::NeedsElevation {
5914                operation,
5915                name: name.to_string(),
5916                detail,
5917                remedy: ELEVATION_REMEDY,
5918            };
5919        }
5920        ServiceError::Control {
5921            operation,
5922            name: name.to_string(),
5923            manager: "Windows Task Scheduler",
5924            detail,
5925        }
5926    }
5927
5928    fn schtasks(
5929        operation: &'static str,
5930        name: &str,
5931        arguments: &[&OsStr],
5932    ) -> Result<(bool, String), ServiceError> {
5933        match run("schtasks.exe", arguments) {
5934            Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
5935            Err(error) => Err(task_error(operation, name, error.to_string())),
5936        }
5937    }
5938
5939    /// Task Scheduler reads `/XML` files as UTF-16, so the document is written
5940    /// as UTF-16 little-endian with a byte-order mark rather than as UTF-8.
5941    fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
5942        let mut bytes = vec![0xFF, 0xFE];
5943        for unit in text.encode_utf16() {
5944            bytes.extend_from_slice(&unit.to_le_bytes());
5945        }
5946        std::fs::write(path, bytes)
5947    }
5948
5949    impl ServiceControl for TaskControl {
5950        fn manager(&self) -> DefinitionKind {
5951            DefinitionKind::WindowsScheduledTask
5952        }
5953
5954        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5955            let name = plan.identity().name().to_string();
5956            let principal = TaskPrincipal::current()?;
5957            let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
5958            let directory = tempfile::tempdir()
5959                .map_err(|error| task_error("install", &name, error.to_string()))?;
5960            let document = directory.path().join("task.xml");
5961            write_utf16(&document, definition.text())
5962                .map_err(|error| task_error("install", &name, error.to_string()))?;
5963            let (ok, message) = schtasks(
5964                "install",
5965                &name,
5966                &[
5967                    OsStr::new("/Create"),
5968                    OsStr::new("/TN"),
5969                    OsStr::new(&name),
5970                    OsStr::new("/XML"),
5971                    document.as_os_str(),
5972                ],
5973            )?;
5974            if !ok {
5975                return Err(task_error("install", &name, message));
5976            }
5977            Ok(definition)
5978        }
5979
5980        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5981            if self.query(identity)?.is_none() {
5982                return Ok(false);
5983            }
5984            let name = identity.name().to_string();
5985            let (ok, message) = schtasks(
5986                "uninstall",
5987                &name,
5988                &[
5989                    OsStr::new("/Delete"),
5990                    OsStr::new("/TN"),
5991                    OsStr::new(&name),
5992                    OsStr::new("/F"),
5993                ],
5994            )?;
5995            if !ok {
5996                return Err(task_error("uninstall", &name, message));
5997            }
5998            Ok(true)
5999        }
6000
6001        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6002            let name = identity.name().to_string();
6003            let (ok, document) = schtasks(
6004                "inspect",
6005                &name,
6006                &[
6007                    OsStr::new("/Query"),
6008                    OsStr::new("/TN"),
6009                    OsStr::new(&name),
6010                    OsStr::new("/XML"),
6011                    OsStr::new("ONE"),
6012                ],
6013            )?;
6014            if !ok {
6015                // `schtasks` reports a missing task and a broken Task Scheduler
6016                // the same way, with a non-zero exit; there is no distinct code.
6017                // Treating it as absence is the safe reading: the caller either
6018                // installs, which will then fail loudly, or reports "not
6019                // installed", which is what an operator with no task sees.
6020                return Ok(None);
6021            }
6022            let command = xml_value(&document, "Command").unwrap_or_default();
6023            let arguments = xml_value(&document, "Arguments").unwrap_or_default();
6024            let command_line = if arguments.is_empty() {
6025                super::quote_argument(&command)
6026            } else {
6027                format!("{} {arguments}", super::quote_argument(&command))
6028            };
6029            Ok(Some(Registration {
6030                manager: DefinitionKind::WindowsScheduledTask,
6031                start_mode: StartMode::Login,
6032                command_line,
6033                account: xml_value(&document, "UserId"),
6034                running: task_is_running(&name),
6035                starts_automatically: super::windows_login_task_starts_automatically(&document),
6036                restart_delay: xml_value(&document, "Interval")
6037                    .as_deref()
6038                    .and_then(parse_iso8601),
6039            }))
6040        }
6041
6042        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6043            let name = identity.name().to_string();
6044            let (ok, message) = schtasks(
6045                "start",
6046                &name,
6047                &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
6048            )?;
6049            if ok {
6050                Ok(())
6051            } else {
6052                Err(task_error("start", &name, message))
6053            }
6054        }
6055
6056        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6057            let running = self
6058                .query(identity)?
6059                .is_some_and(|registration| registration.running);
6060            if !running {
6061                return Ok(false);
6062            }
6063            let name = identity.name().to_string();
6064            let (ok, message) = schtasks(
6065                "stop",
6066                &name,
6067                &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
6068            )?;
6069            if ok {
6070                Ok(true)
6071            } else {
6072                Err(task_error("stop", &name, message))
6073            }
6074        }
6075    }
6076
6077    /// Whether Task Scheduler reports the task as running.
6078    ///
6079    /// **This is the one value in this module read from localised output.**
6080    /// `schtasks /Query /FO CSV` prints a `Status` column in the machine's
6081    /// display language, and Task Scheduler exposes no locale-independent
6082    /// equivalent short of COM. On a non-English Windows this therefore reports
6083    /// `false` for a task that is in fact running.
6084    ///
6085    /// That is stated rather than hidden because of what does *not* depend on
6086    /// it: Journey 5's liveness question is answered by the last successful
6087    /// GitHub contact, which is a timestamp this product writes itself and no
6088    /// locale can move. Nothing in the Definition of Done rests on this
6089    /// boolean.
6090    fn task_is_running(name: &str) -> bool {
6091        let Ok((true, stdout, _)) = run(
6092            "schtasks.exe",
6093            &[
6094                OsStr::new("/Query"),
6095                OsStr::new("/TN"),
6096                OsStr::new(name),
6097                OsStr::new("/FO"),
6098                OsStr::new("CSV"),
6099                OsStr::new("/NH"),
6100            ],
6101        ) else {
6102            return false;
6103        };
6104        stdout
6105            .lines()
6106            .filter_map(|line| line.rsplit(',').next())
6107            .any(|status| {
6108                status
6109                    .trim()
6110                    .trim_matches('"')
6111                    .eq_ignore_ascii_case("running")
6112            })
6113    }
6114
6115    /// Parses the `PT<n>M` shape this module writes, and the `PT<n>S` shape
6116    /// Task Scheduler would accept if it took seconds. Anything richer -- days,
6117    /// hours, a combination -- is `None` rather than a guess.
6118    fn parse_iso8601(value: &str) -> Option<Duration> {
6119        let rest = value.strip_prefix("PT")?;
6120        if let Some(minutes) = rest.strip_suffix('M') {
6121            return minutes
6122                .parse::<u64>()
6123                .ok()
6124                .map(|minutes| Duration::from_secs(minutes * 60));
6125        }
6126        rest.strip_suffix('S')?
6127            .parse::<u64>()
6128            .ok()
6129            .map(Duration::from_secs)
6130    }
6131}
6132
6133// ---------------------------------------------------------------------------
6134// Unix: shared plumbing
6135// ---------------------------------------------------------------------------
6136
6137/// Writes a definition file, creating its directory, and says when the refusal
6138/// was a permissions one.
6139///
6140/// Both Unix backends write a file into a directory only `root` can write —
6141/// `/Library/LaunchDaemons` and `/etc/systemd/system` — and an operator who ran
6142/// `service install` without `sudo` deserves to be told that rather than handed
6143/// an `EACCES`.
6144#[cfg(unix)]
6145fn write_definition(
6146    operation: &'static str,
6147    name: &str,
6148    path: &Path,
6149    text: &str,
6150    remedy: &'static str,
6151) -> Result<(), ServiceError> {
6152    if let Some(parent) = path.parent()
6153        && let Err(error) = std::fs::create_dir_all(parent)
6154        && error.kind() != std::io::ErrorKind::AlreadyExists
6155    {
6156        return Err(definition_error(operation, name, error, remedy, parent));
6157    }
6158    std::fs::write(path, text)
6159        .map_err(|error| definition_error(operation, name, error, remedy, path))
6160}
6161
6162#[cfg(unix)]
6163fn definition_error(
6164    operation: &'static str,
6165    name: &str,
6166    error: std::io::Error,
6167    remedy: &'static str,
6168    path: &Path,
6169) -> ServiceError {
6170    let detail = format!("{}: {error}", path.display());
6171    if error.kind() == std::io::ErrorKind::PermissionDenied {
6172        ServiceError::NeedsElevation {
6173            operation,
6174            name: name.to_string(),
6175            detail,
6176            remedy,
6177        }
6178    } else {
6179        ServiceError::Control {
6180            operation,
6181            name: name.to_string(),
6182            manager: "the local service manager",
6183            detail,
6184        }
6185    }
6186}
6187
6188/// `sudo` is the remedy on both Unixes, and the message says which command.
6189#[cfg(unix)]
6190const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
6191     `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
6192     agent not running until you sign in.";
6193
6194// ---------------------------------------------------------------------------
6195// macOS
6196// ---------------------------------------------------------------------------
6197
6198#[cfg(target_os = "macos")]
6199mod sys {
6200    //! One manager, two domains. `--start-at boot` is a LaunchDaemon in
6201    //! `/Library/LaunchDaemons`, loaded into launchd's `system` domain;
6202    //! `--start-at login` is a LaunchAgent in the operator's own
6203    //! `~/Library/LaunchAgents`, loaded into `gui/<uid>`.
6204    //!
6205    //! `launchctl print` is the only inspection command here whose output is
6206    //! parsed, and it is English-only on every macOS release — launchd has no
6207    //! localisation for it. The registration's *existence* is decided by the
6208    //! plist file rather than by that output, so a launchd that changed its
6209    //! wording would cost this module the running/not-running line and nothing
6210    //! else.
6211
6212    use std::ffi::OsStr;
6213    use std::path::PathBuf;
6214    use std::time::Duration;
6215
6216    use runner_manager_domain::model::StartMode;
6217
6218    use super::{
6219        DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
6220        SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
6221        enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
6222        write_definition, xml_value,
6223    };
6224
6225    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6226        Ok(Box::new(LaunchdControl { mode }))
6227    }
6228
6229    #[derive(Debug)]
6230    struct LaunchdControl {
6231        mode: StartMode,
6232    }
6233
6234    impl LaunchdControl {
6235        /// launchd's domain target for this start mode.
6236        fn domain(&self) -> String {
6237            match self.mode {
6238                StartMode::Boot => "system".to_string(),
6239                // SAFETY: `getuid` reads the calling process's real user id and
6240                // cannot fail; it takes no arguments and touches no memory.
6241                StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
6242            }
6243        }
6244
6245        fn service_target(&self, identity: &ServiceIdentity) -> String {
6246            format!("{}/{}", self.domain(), identity.launchd_label())
6247        }
6248
6249        /// Where this domain's plist lives, when the domain has a home to put
6250        /// it in.
6251        fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
6252            let file = format!("{}.plist", identity.launchd_label());
6253            match self.mode {
6254                StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
6255                StartMode::Login => {
6256                    home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
6257                }
6258            }
6259        }
6260
6261        fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
6262            ServiceError::Control {
6263                operation,
6264                name: name.to_string(),
6265                manager: "launchd",
6266                detail,
6267            }
6268        }
6269
6270        fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
6271            match run("launchctl", arguments) {
6272                Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
6273                Err(error) => (false, error.to_string()),
6274            }
6275        }
6276    }
6277
6278    impl ServiceControl for LaunchdControl {
6279        fn manager(&self) -> DefinitionKind {
6280            DefinitionKind::LaunchdPlist
6281        }
6282
6283        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6284            let name = plan.identity().name().to_string();
6285            let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
6286            let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
6287                return Err(self.failed(
6288                    "install",
6289                    &name,
6290                    "this account has no home directory, so there is nowhere to put a \
6291                     LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
6292                     /Library/LaunchDaemons."
6293                        .to_string(),
6294                ));
6295            };
6296            write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
6297            let target = self.domain();
6298            let (ok, message) = self.launchctl(&[
6299                OsStr::new("bootstrap"),
6300                OsStr::new(&target),
6301                path.as_os_str(),
6302            ]);
6303            if !ok {
6304                // Leave nothing behind: a plist launchd refused is a file that
6305                // would make the next `service status` claim a registration
6306                // that does not exist.
6307                let _ = std::fs::remove_file(&path);
6308                if message.to_ascii_lowercase().contains("permission denied") {
6309                    return Err(ServiceError::NeedsElevation {
6310                        operation: "install",
6311                        name,
6312                        detail: message,
6313                        remedy: SUDO_REMEDY,
6314                    });
6315                }
6316                return Err(self.failed("install", &name, message));
6317            }
6318            // A previously disabled label stays disabled through a bootstrap,
6319            // which is a service that is installed and will never start.
6320            let service_target = self.service_target(plan.identity());
6321            enable_launchd_registration(
6322                |arguments| self.launchctl(arguments),
6323                &target,
6324                &service_target,
6325                &path,
6326                &name,
6327                SUDO_REMEDY,
6328            )?;
6329            Ok(definition)
6330        }
6331
6332        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6333            let Some(path) = self.plist_path(identity) else {
6334                return Ok(false);
6335            };
6336            if !path.exists() {
6337                return Ok(false);
6338            }
6339            let target = self.service_target(identity);
6340            let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
6341            if !ok
6342                && !message.to_ascii_lowercase().contains("no such process")
6343                && !message.contains("113")
6344            {
6345                if message.to_ascii_lowercase().contains("permission denied") {
6346                    return Err(ServiceError::NeedsElevation {
6347                        operation: "uninstall",
6348                        name: identity.name().to_string(),
6349                        detail: message,
6350                        remedy: SUDO_REMEDY,
6351                    });
6352                }
6353                return Err(self.failed("uninstall", identity.name(), message));
6354            }
6355            // Only the definition. `05-infrastructure.md` item 5: nothing else
6356            // on this host is touched here or anywhere below it.
6357            std::fs::remove_file(&path).map_err(|error| {
6358                super::definition_error(
6359                    "uninstall",
6360                    identity.name(),
6361                    error,
6362                    SUDO_REMEDY,
6363                    path.as_path(),
6364                )
6365            })?;
6366            Ok(true)
6367        }
6368
6369        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6370            let Some(path) = self.plist_path(identity) else {
6371                return Ok(None);
6372            };
6373            let Ok(document) = std::fs::read_to_string(&path) else {
6374                return Ok(None);
6375            };
6376            let target = self.service_target(identity);
6377            let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
6378            Ok(Some(Registration {
6379                manager: DefinitionKind::LaunchdPlist,
6380                start_mode: self.mode,
6381                command_line: program_arguments(&document),
6382                account: plist_string_value(&document, "UserName")
6383                    .or_else(|| Some("the invoking user".to_string())),
6384                running: loaded && printed.contains("state = running"),
6385                starts_automatically: document.contains("<key>RunAtLoad</key>")
6386                    && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
6387                restart_delay: xml_value(
6388                    super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
6389                    "integer",
6390                )
6391                .and_then(|value| value.parse::<u64>().ok())
6392                .map(Duration::from_secs),
6393            }))
6394        }
6395
6396        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6397            let target = self.service_target(identity);
6398            let (ok, message) = self.launchctl(&[
6399                OsStr::new("kickstart"),
6400                OsStr::new("-k"),
6401                OsStr::new(&target),
6402            ]);
6403            if ok {
6404                Ok(())
6405            } else {
6406                Err(self.failed("start", identity.name(), message))
6407            }
6408        }
6409
6410        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6411            let running = self
6412                .query(identity)?
6413                .is_some_and(|registration| registration.running);
6414            if !running {
6415                return Ok(false);
6416            }
6417            let target = self.service_target(identity);
6418            let (ok, message) = self.launchctl(&[
6419                OsStr::new("kill"),
6420                OsStr::new("SIGTERM"),
6421                OsStr::new(&target),
6422            ]);
6423            if ok {
6424                Ok(true)
6425            } else {
6426                Err(self.failed("stop", identity.name(), message))
6427            }
6428        }
6429    }
6430
6431    /// Rebuilds the command line from a plist's `ProgramArguments` array.
6432    fn program_arguments(document: &str) -> String {
6433        let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
6434            return String::new();
6435        };
6436        let Some(end) = rest.find("</array>") else {
6437            return String::new();
6438        };
6439        let mut out = Vec::new();
6440        let mut cursor = &rest[..end];
6441        while let Some(open) = cursor.find("<string>") {
6442            let after = &cursor[open + "<string>".len()..];
6443            let Some(close) = after.find("</string>") else {
6444                break;
6445            };
6446            out.push(quote_argument(&super::xml_unescape(&after[..close])));
6447            cursor = &after[close..];
6448        }
6449        out.join(" ")
6450    }
6451}
6452
6453// ---------------------------------------------------------------------------
6454// Linux and the other Unixes
6455// ---------------------------------------------------------------------------
6456
6457#[cfg(all(unix, not(target_os = "macos")))]
6458mod sys {
6459    //! One manager, two domains. `--start-at boot` is a system unit in
6460    //! `/etc/systemd/system`, wanted by `multi-user.target`; `--start-at login`
6461    //! is a user unit in `~/.config/systemd/user`, wanted by `default.target`.
6462    //!
6463    //! Every inspection here goes through `systemctl is-active` and
6464    //! `systemctl is-enabled`, whose output is a fixed machine-readable word
6465    //! rather than a sentence, so nothing in this module depends on the
6466    //! machine's display language.
6467
6468    use std::ffi::OsStr;
6469    use std::path::PathBuf;
6470    use std::time::Duration;
6471
6472    use runner_manager_domain::model::StartMode;
6473
6474    use super::{
6475        DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
6476        SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
6477        home_directory, ini_directives, run, write_definition,
6478    };
6479
6480    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6481        Ok(Box::new(SystemdControl { mode }))
6482    }
6483
6484    #[derive(Debug)]
6485    struct SystemdControl {
6486        mode: StartMode,
6487    }
6488
6489    impl SystemdControl {
6490        fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
6491            let file = identity.systemd_unit();
6492            match self.mode {
6493                StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
6494                StartMode::Login => {
6495                    home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
6496                }
6497            }
6498        }
6499
6500        /// `systemctl`, with `--user` for the login domain.
6501        fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
6502            let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
6503            if self.mode == StartMode::Login {
6504                all.push(OsStr::new("--user"));
6505            }
6506            all.extend(arguments.iter().map(OsStr::new));
6507            match run("systemctl", &all) {
6508                Ok((ok, stdout, stderr)) => (
6509                    ok,
6510                    if stdout.trim().is_empty() {
6511                        stderr
6512                    } else {
6513                        stdout
6514                    },
6515                ),
6516                Err(error) => (false, error.to_string()),
6517            }
6518        }
6519
6520        fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
6521            ServiceError::Control {
6522                operation,
6523                name: name.to_string(),
6524                manager: "systemd",
6525                detail,
6526            }
6527        }
6528    }
6529
6530    impl ServiceControl for SystemdControl {
6531        fn manager(&self) -> DefinitionKind {
6532            DefinitionKind::SystemdUnit
6533        }
6534
6535        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6536            let name = plan.identity().name().to_string();
6537            let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
6538            let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
6539                return Err(self.failed(
6540                    "install",
6541                    &name,
6542                    "this account has no home directory, so there is nowhere to put a systemd \
6543                     user unit. Use --start-at boot, which installs a system unit under \
6544                     /etc/systemd/system."
6545                        .to_string(),
6546                ));
6547            };
6548            write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
6549            let unit = plan.identity().systemd_unit();
6550            let (reloaded, message) = self.systemctl(&["daemon-reload"]);
6551            if !reloaded {
6552                let _ = std::fs::remove_file(&path);
6553                return Err(self.failed("install", &name, message));
6554            }
6555            let (enabled, message) = self.systemctl(&["enable", &unit]);
6556            if !enabled {
6557                let _ = std::fs::remove_file(&path);
6558                let _ = self.systemctl(&["daemon-reload"]);
6559                return Err(self.failed("install", &name, message));
6560            }
6561            Ok(definition)
6562        }
6563
6564        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6565            let Some(path) = self.unit_path(identity) else {
6566                return Ok(false);
6567            };
6568            if !path.exists() {
6569                return Ok(false);
6570            }
6571            let unit = identity.systemd_unit();
6572            // `--now` stops it as well as disabling it. A failure here is not
6573            // fatal: the unit file is about to go, and refusing to remove it
6574            // because `systemctl` disliked something would leave the host with
6575            // a unit nothing manages.
6576            let _ = self.systemctl(&["disable", "--now", &unit]);
6577            std::fs::remove_file(&path).map_err(|error| {
6578                super::definition_error(
6579                    "uninstall",
6580                    identity.name(),
6581                    error,
6582                    SUDO_REMEDY,
6583                    path.as_path(),
6584                )
6585            })?;
6586            let _ = self.systemctl(&["daemon-reload"]);
6587            Ok(true)
6588        }
6589
6590        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6591            let Some(path) = self.unit_path(identity) else {
6592                return Ok(None);
6593            };
6594            let Ok(document) = std::fs::read_to_string(&path) else {
6595                return Ok(None);
6596            };
6597            let unit = identity.systemd_unit();
6598            let directives = ini_directives(&document, "Service");
6599            let (_, active) = self.systemctl(&["is-active", &unit]);
6600            let (_, enabled) = self.systemctl(&["is-enabled", &unit]);
6601            Ok(Some(Registration {
6602                manager: DefinitionKind::SystemdUnit,
6603                start_mode: self.mode,
6604                command_line: directives.get("ExecStart").cloned().unwrap_or_default(),
6605                account: directives.get("User").cloned().or_else(|| {
6606                    Some(match self.mode {
6607                        StartMode::Boot => "root".to_string(),
6608                        StartMode::Login => "the invoking user".to_string(),
6609                    })
6610                }),
6611                running: active.trim() == "active",
6612                starts_automatically: enabled.trim() == "enabled",
6613                restart_delay: directives
6614                    .get("RestartSec")
6615                    .and_then(|value| value.trim().trim_end_matches('s').parse::<u64>().ok())
6616                    .map(Duration::from_secs),
6617            }))
6618        }
6619
6620        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6621            let unit = identity.systemd_unit();
6622            let (ok, message) = self.systemctl(&["start", &unit]);
6623            if ok {
6624                Ok(())
6625            } else {
6626                Err(self.failed("start", identity.name(), message))
6627            }
6628        }
6629
6630        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6631            let running = self
6632                .query(identity)?
6633                .is_some_and(|registration| registration.running);
6634            if !running {
6635                return Ok(false);
6636            }
6637            let unit = identity.systemd_unit();
6638            let (ok, message) = self.systemctl(&["stop", &unit]);
6639            if ok {
6640                Ok(true)
6641            } else {
6642                Err(self.failed("stop", identity.name(), message))
6643            }
6644        }
6645    }
6646}
6647
6648#[cfg(test)]
6649mod tests {
6650    use super::*;
6651
6652    use std::collections::BTreeMap;
6653
6654    #[test]
6655    fn task_scheduler_omitting_default_enabled_still_means_automatic() {
6656        assert!(windows_login_task_starts_automatically(
6657            "<Task><Triggers><LogonTrigger></LogonTrigger></Triggers></Task>"
6658        ));
6659        assert!(windows_login_task_starts_automatically(
6660            "<Task><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Task>"
6661        ));
6662        assert!(!windows_login_task_starts_automatically(
6663            "<Task><LogonTrigger><Enabled>false</Enabled></LogonTrigger></Task>"
6664        ));
6665        assert!(!windows_login_task_starts_automatically(
6666            "<Task><BootTrigger></BootTrigger></Task>"
6667        ));
6668    }
6669
6670    // -----------------------------------------------------------------------
6671    // Fixtures
6672    // -----------------------------------------------------------------------
6673
6674    /// A plan against paths that exist on no platform, so that every renderer
6675    /// can be asserted on every leg of the CI matrix.
6676    fn linux_plan(mode: StartMode) -> InstallPlan {
6677        InstallPlan::unchecked(
6678            ServiceIdentity::product(),
6679            mode,
6680            "/opt/runner-manager/bin/runner-manager",
6681            ServiceDirectories {
6682                config: PathBuf::from("/var/lib/runner-manager/config"),
6683                state: PathBuf::from("/var/lib/runner-manager/state"),
6684                runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
6685                logs: PathBuf::from("/var/log/runner-manager"),
6686            },
6687        )
6688        .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
6689    }
6690
6691    fn windows_plan(mode: StartMode) -> InstallPlan {
6692        InstallPlan::unchecked(
6693            ServiceIdentity::product(),
6694            mode,
6695            "C:\\Program Files\\runner-manager\\runner-manager.exe",
6696            ServiceDirectories {
6697                config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
6698                state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
6699                runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
6700                logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
6701            },
6702        )
6703    }
6704
6705    /// Replaces one fragment of a rendered definition, and **fails the test if
6706    /// the fragment was not there**.
6707    ///
6708    /// Without the assertion a renderer change would quietly turn every
6709    /// "widened definition is rejected" test below into a test that reviews the
6710    /// unmodified definition and passes for the wrong reason. This is the
6711    /// single guard that keeps that class of vacuity out of this file.
6712    fn edited(text: &str, from: &str, to: &str) -> String {
6713        assert!(
6714            text.contains(from),
6715            "the rendered definition does not contain `{from}`, so this test would assert \
6716             nothing about a widened one"
6717        );
6718        text.replace(from, to)
6719    }
6720
6721    /// Every file under `root`, keyed by its path relative to `root`.
6722    fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
6723        fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
6724            let Ok(entries) = std::fs::read_dir(directory) else {
6725                return;
6726            };
6727            for entry in entries.flatten() {
6728                let path = entry.path();
6729                if path.is_dir() {
6730                    walk(&path, out);
6731                } else if let Ok(bytes) = std::fs::read(&path) {
6732                    out.insert(path, bytes);
6733                }
6734            }
6735        }
6736        let mut out = BTreeMap::new();
6737        for root in roots {
6738            walk(root, &mut out);
6739        }
6740        out
6741    }
6742
6743    struct Host {
6744        _root: tempfile::TempDir,
6745        paths: AppPaths,
6746        binary: PathBuf,
6747        /// A runner root inside this host's own temporary tree.
6748        ///
6749        /// Without it every `install` in this module would create and
6750        /// re-permission the **real** `%SystemDrive%\rman` on the machine
6751        /// running the tests, and would fail outright on any machine where that
6752        /// directory already exists with the access control `C:\` gives it.
6753        /// A sibling of the four application-data directories rather than a
6754        /// child of one, so `b1`'s overlap check has nothing to object to.
6755        runner_root: LocalAbsolutePath,
6756        controls: RecordingControls,
6757    }
6758
6759    impl Host {
6760        fn new() -> Self {
6761            let root = tempfile::tempdir().expect("a temporary directory");
6762            let paths = AppPaths::rooted_at(root.path());
6763            paths.create_all().expect("the four directories");
6764            let binary = root.path().join(if cfg!(windows) {
6765                "runner-manager.exe"
6766            } else {
6767                "runner-manager"
6768            });
6769            std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
6770            let runner_root = LocalAbsolutePath::new(
6771                root.path()
6772                    .join("runner-root")
6773                    .to_str()
6774                    .expect("a unicode temporary path"),
6775            )
6776            .expect("a local absolute path");
6777            Self {
6778                _root: root,
6779                paths,
6780                binary,
6781                runner_root,
6782                controls: RecordingControls::new(),
6783            }
6784        }
6785
6786        fn operations(&self) -> ServiceOperations {
6787            ServiceOperations::with_controls(
6788                self.paths.clone(),
6789                ServiceIdentity::product(),
6790                std::sync::Arc::new(self.controls.clone()),
6791            )
6792            .with_runner_root(self.runner_root.clone())
6793        }
6794
6795        fn request(&self, mode: StartMode) -> InstallRequest {
6796            InstallRequest::new(mode).for_binary(&self.binary)
6797        }
6798    }
6799
6800    #[cfg(windows)]
6801    #[test]
6802    fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
6803        let probes = std::cell::Cell::new(0);
6804        let absent =
6805            super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
6806                let next = probes.get() + 1;
6807                probes.set(next);
6808                Ok(next == 3)
6809            })
6810            .expect("the simulated SCM probe succeeds");
6811
6812        assert!(absent);
6813        assert_eq!(
6814            probes.get(),
6815            3,
6816            "uninstall must recheck after transient presence instead of treating it as a leak"
6817        );
6818    }
6819
6820    #[cfg(windows)]
6821    #[test]
6822    fn windows_recognises_an_already_pending_service_deletion() {
6823        let error = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(1072));
6824
6825        assert!(super::sys::is_marked_for_delete(&error));
6826    }
6827
6828    #[test]
6829    fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
6830        let root = tempfile::tempdir().expect("a temporary directory");
6831        let plist = root.path().join("fixture.plist");
6832        std::fs::write(&plist, b"fixture").expect("a plist fixture");
6833        let calls = std::cell::RefCell::new(Vec::new());
6834
6835        let error = enable_launchd_registration(
6836            |arguments| {
6837                let call = arguments
6838                    .iter()
6839                    .map(|argument| argument.to_string_lossy().into_owned())
6840                    .collect::<Vec<_>>();
6841                let operation = call[0].clone();
6842                calls.borrow_mut().push(call);
6843                if operation == "enable" {
6844                    (false, "label remains disabled".to_string())
6845                } else {
6846                    (true, String::new())
6847                }
6848            },
6849            "system",
6850            "system/com.openai.runner-manager-selftest",
6851            &plist,
6852            "runner-manager-selftest",
6853            "rerun with administrative rights",
6854        )
6855        .expect_err("enable failure must fail the install");
6856
6857        assert!(
6858            matches!(
6859                error,
6860                ServiceError::Control {
6861                    operation: "enable",
6862                    ..
6863                }
6864            ),
6865            "{error}"
6866        );
6867        assert_eq!(calls.borrow().len(), 2);
6868        assert_eq!(calls.borrow()[0][0], "enable");
6869        assert_eq!(calls.borrow()[1][0], "bootout");
6870        assert!(!plist.exists(), "rollback must remove the plist");
6871    }
6872
6873    // -----------------------------------------------------------------------
6874    // Quoting, and reading a command line back
6875    // -----------------------------------------------------------------------
6876
6877    #[test]
6878    fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
6879        let plan = windows_plan(StartMode::Boot);
6880        let command_line = plan.command_line();
6881        assert!(
6882            command_line.starts_with('"'),
6883            "a path with a space must be quoted, got {command_line}"
6884        );
6885        assert_eq!(
6886            executable_from_command_line(&command_line).as_deref(),
6887            Some(plan.binary())
6888        );
6889    }
6890
6891    #[test]
6892    fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
6893        let plan = linux_plan(StartMode::Boot);
6894        let command_line = plan.command_line();
6895        assert!(!command_line.starts_with('"'), "got {command_line}");
6896        assert_eq!(
6897            executable_from_command_line(&command_line).as_deref(),
6898            Some(plan.binary())
6899        );
6900    }
6901
6902    #[test]
6903    fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
6904        // Not a path anybody has, but it is the input that separates a real
6905        // implementation of Windows' quoting rules from a `split_whitespace`.
6906        let awkward = r#"C:\odd "name"\rm.exe"#;
6907        let quoted = quote_argument(awkward);
6908        assert_eq!(
6909            executable_from_command_line(&format!("{quoted} daemon run"))
6910                .as_deref()
6911                .map(Path::to_string_lossy)
6912                .as_deref(),
6913            Some(awkward)
6914        );
6915    }
6916
6917    #[test]
6918    fn an_empty_command_line_has_no_executable() {
6919        assert_eq!(executable_from_command_line("   "), None);
6920        assert_eq!(executable_from_command_line(""), None);
6921    }
6922
6923    #[test]
6924    fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
6925        let awkward = r#"DOMAIN\R&D <team> "ops""#;
6926        assert_eq!(
6927            xml_escape(awkward),
6928            "DOMAIN\\R&amp;D &lt;team&gt; &quot;ops&quot;"
6929        );
6930        assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
6931    }
6932
6933    // -----------------------------------------------------------------------
6934    // The restart policy is bounded at both ends
6935    // -----------------------------------------------------------------------
6936
6937    #[test]
6938    fn a_restart_delay_under_the_floor_is_refused() {
6939        let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
6940            .expect_err("half a second is under the one-second floor");
6941        assert!(
6942            matches!(error, ServiceError::RestartDelay { .. }),
6943            "{error}"
6944        );
6945    }
6946
6947    #[test]
6948    fn a_restart_delay_over_the_ceiling_is_refused() {
6949        let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
6950            .expect_err("an hour is over the five-minute ceiling");
6951        assert!(
6952            matches!(error, ServiceError::RestartDelay { .. }),
6953            "{error}"
6954        );
6955    }
6956
6957    #[test]
6958    fn a_reset_window_no_longer_than_the_delay_is_refused() {
6959        let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
6960            .expect_err("a window equal to the delay can never elapse between restarts");
6961        assert!(
6962            matches!(error, ServiceError::RestartResetWindow { .. }),
6963            "{error}"
6964        );
6965    }
6966
6967    #[test]
6968    fn a_delay_inside_the_bound_is_accepted() {
6969        let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
6970            .expect("twenty seconds is inside the bound");
6971        assert_eq!(policy.delay(), Duration::from_secs(20));
6972        assert_eq!(policy.reset_after(), Duration::from_secs(300));
6973    }
6974
6975    // -----------------------------------------------------------------------
6976    // Identity
6977    // -----------------------------------------------------------------------
6978
6979    #[test]
6980    fn a_fixture_identity_can_never_be_the_product_identity() {
6981        let fixture = ServiceIdentity::fixture("abc123");
6982        assert!(fixture.is_fixture());
6983        assert!(!ServiceIdentity::product().is_fixture());
6984        assert_ne!(fixture.name(), ServiceIdentity::product().name());
6985        assert_ne!(
6986            fixture.launchd_label(),
6987            ServiceIdentity::product().launchd_label()
6988        );
6989        assert_ne!(
6990            fixture.systemd_unit(),
6991            ServiceIdentity::product().systemd_unit()
6992        );
6993    }
6994
6995    #[test]
6996    fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
6997        let fixture = ServiceIdentity::fixture("A b/c:\\d");
6998        assert!(
6999            fixture
7000                .name()
7001                .chars()
7002                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
7003            "got {}",
7004            fixture.name()
7005        );
7006    }
7007
7008    // -----------------------------------------------------------------------
7009    // The systemd unit
7010    // -----------------------------------------------------------------------
7011
7012    #[test]
7013    fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
7014        let unit = systemd_unit(&linux_plan(StartMode::Boot));
7015        assert!(unit.contains("KillMode=process\n"), "{unit}");
7016        assert!(unit.contains("Restart=on-failure\n"), "{unit}");
7017        assert!(unit.contains("RestartSec=15\n"), "{unit}");
7018        assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
7019        assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
7020        assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
7021    }
7022
7023    #[test]
7024    fn the_boot_unit_reads_the_live_store_instead_of_a_frozen_systemd_copy() {
7025        let unit = systemd_unit(&linux_plan(StartMode::Boot));
7026        assert!(
7027            !unit.contains("LoadCredential="),
7028            "a startup snapshot would shadow every rotated credential until restart:\n{unit}"
7029        );
7030    }
7031
7032    #[test]
7033    fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
7034        let unit = systemd_unit(&linux_plan(StartMode::Login));
7035        assert!(
7036            !unit.contains("LoadCredential="),
7037            "a user unit must not name a root-owned credential file, got:\n{unit}"
7038        );
7039        assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
7040    }
7041
7042    #[test]
7043    fn the_unit_can_atomically_replace_the_credential_and_write_only_required_directories() {
7044        let plan = linux_plan(StartMode::Boot);
7045        let unit = systemd_unit(&plan);
7046        let directives = ini_directives(&unit, "Service");
7047        let listed = split_quoted(
7048            directives
7049                .get("ReadWritePaths")
7050                .expect("the unit names its writable paths"),
7051        );
7052        assert_eq!(listed.len(), 5, "{listed:?}");
7053        for path in plan.directories().all() {
7054            assert!(
7055                listed.iter().any(|entry| entry == &path.to_string_lossy()),
7056                "{} is missing from {listed:?}",
7057                path.display()
7058            );
7059        }
7060        assert!(
7061            listed
7062                .iter()
7063                .any(|entry| entry == "/var/lib/runner-manager/secrets"),
7064            "atomic credential replacement needs its parent directory in {listed:?}"
7065        );
7066    }
7067
7068    #[test]
7069    fn the_unit_records_the_absolute_binary_path() {
7070        let plan = linux_plan(StartMode::Boot);
7071        let unit = systemd_unit(&plan);
7072        assert!(
7073            unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
7074            "{unit}"
7075        );
7076    }
7077
7078    // -----------------------------------------------------------------------
7079    // The launchd property list
7080    // -----------------------------------------------------------------------
7081
7082    #[test]
7083    fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
7084        let plist = launchd_plist(&linux_plan(StartMode::Boot));
7085        // A bare `KeepAlive` would also restart a job the operator stopped
7086        // deliberately, which turns `service stop` into a fight with launchd.
7087        assert!(
7088            plist.contains(
7089                "<key>KeepAlive</key>\n  <dict>\n    <key>SuccessfulExit</key>\n    <false/>\n"
7090            ),
7091            "{plist}"
7092        );
7093        assert!(
7094            plist.contains("<key>ThrottleInterval</key>\n  <integer>15</integer>"),
7095            "{plist}"
7096        );
7097    }
7098
7099    #[test]
7100    fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
7101        let daemon = launchd_plist(&linux_plan(StartMode::Boot));
7102        assert_eq!(
7103            plist_string_value(&daemon, "UserName").as_deref(),
7104            Some("root")
7105        );
7106        assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
7107
7108        let agent = launchd_plist(&linux_plan(StartMode::Login));
7109        assert_eq!(
7110            plist_string_value(&agent, "UserName"),
7111            None,
7112            "a LaunchAgent already runs as the operator:\n{agent}"
7113        );
7114    }
7115
7116    #[test]
7117    fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
7118        let plist = launchd_plist(&linux_plan(StartMode::Boot));
7119        assert!(
7120            plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
7121            "{plist}"
7122        );
7123        assert!(plist.contains("<string>daemon</string>"), "{plist}");
7124        assert!(plist.contains("<string>run</string>"), "{plist}");
7125    }
7126
7127    #[test]
7128    fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
7129        assert_eq!(
7130            ServiceIdentity::product().launchd_label(),
7131            "io.github.IvanMurzak.runner-manager"
7132        );
7133    }
7134
7135    // -----------------------------------------------------------------------
7136    // The Task Scheduler document
7137    // -----------------------------------------------------------------------
7138
7139    #[test]
7140    fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
7141        let xml = windows_scheduled_task_xml(
7142            &windows_plan(StartMode::Login),
7143            &TaskPrincipal::named("HOST\\operator"),
7144        );
7145        assert!(xml.contains("<LogonTrigger>"), "{xml}");
7146        assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
7147        assert!(
7148            xml.contains("<LogonType>InteractiveToken</LogonType>"),
7149            "{xml}"
7150        );
7151        assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
7152        assert!(
7153            xml.contains("<Interval>PT1M</Interval>"),
7154            "Task Scheduler takes whole minutes only, and rejects the registration outright \
7155             for anything finer:\n{xml}"
7156        );
7157    }
7158
7159    #[test]
7160    fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
7161        // Measured against the real Task Scheduler, which answers `PT15S` with
7162        // "The task XML contains a value which is incorrectly formatted or out
7163        // of range". Rounding *up* is what keeps "does not restart-loop faster
7164        // than that bound" true on this manager.
7165        for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
7166            let policy =
7167                RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
7168                    .expect("inside the supported range");
7169            assert_eq!(
7170                policy
7171                    .effective_delay(DefinitionKind::WindowsScheduledTask)
7172                    .as_secs(),
7173                enforced,
7174                "a {asked}s delay must be enforced as {enforced}s"
7175            );
7176            assert!(
7177                policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
7178                "rounding must never shorten the bound"
7179            );
7180        }
7181    }
7182
7183    #[test]
7184    fn every_other_manager_enforces_the_delay_exactly_as_configured() {
7185        let policy = RestartPolicy::default();
7186        for kind in [
7187            DefinitionKind::WindowsService,
7188            DefinitionKind::LaunchdPlist,
7189            DefinitionKind::SystemdUnit,
7190        ] {
7191            assert_eq!(
7192                policy.effective_delay(kind),
7193                policy.delay(),
7194                "{kind:?} takes seconds and enforces exactly what it is given"
7195            );
7196        }
7197    }
7198
7199    #[test]
7200    fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
7201        let host = Host::new();
7202        let operations = host.operations();
7203        operations
7204            .install(&host.request(StartMode::Login))
7205            .expect("an install at login");
7206        // What a real Task Scheduler reports back for a 15-second policy.
7207        host.controls.edit("runner-manager", |registration| {
7208            registration.manager = DefinitionKind::WindowsScheduledTask;
7209            registration.restart_delay = Some(Duration::from_secs(60));
7210        });
7211
7212        let status = operations.status().expect("a status");
7213        assert!(
7214            status.is_healthy(),
7215            "minute granularity is the manager's, not a mis-registration: {status}"
7216        );
7217        assert!(
7218            status
7219                .notes()
7220                .iter()
7221                .any(|note| note.contains("whole minutes")),
7222            "but the operator must be told why 15 became 60: {status}"
7223        );
7224
7225        // The discriminator: a delay that is neither the configured one nor its
7226        // rounding is still a fault.
7227        host.controls.edit("runner-manager", |registration| {
7228            registration.restart_delay = Some(Duration::from_secs(1));
7229        });
7230        assert!(
7231            !operations.status().expect("a status").is_healthy(),
7232            "a one-second delay is not what any manager was asked for"
7233        );
7234    }
7235
7236    #[test]
7237    fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
7238        let plan = windows_plan(StartMode::Login);
7239        let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
7240        assert_eq!(
7241            xml_value(&xml, "Command").as_deref(),
7242            Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
7243            "{xml}"
7244        );
7245        assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
7246    }
7247
7248    #[test]
7249    fn an_account_name_holding_xml_punctuation_is_escaped() {
7250        let xml = windows_scheduled_task_xml(
7251            &windows_plan(StartMode::Login),
7252            &TaskPrincipal::named("R&D\\ops"),
7253        );
7254        assert!(xml.contains("<UserId>R&amp;D\\ops</UserId>"), "{xml}");
7255        assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
7256    }
7257
7258    // -----------------------------------------------------------------------
7259    // The Windows service descriptor
7260    // -----------------------------------------------------------------------
7261
7262    #[test]
7263    fn the_service_starts_automatically_under_the_account_the_store_admits() {
7264        let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
7265        let directives = ini_directives(&text, "windows-service");
7266        assert_eq!(
7267            directives.get("StartType").map(String::as_str),
7268            Some("AutoStart")
7269        );
7270        assert_eq!(
7271            directives.get("Account").map(String::as_str),
7272            Some("NT AUTHORITY\\SYSTEM")
7273        );
7274        assert_eq!(
7275            directives.get("ServiceType").map(String::as_str),
7276            Some("OWN_PROCESS")
7277        );
7278        assert_eq!(
7279            directives
7280                .get("FailureActionRestartDelaySecs")
7281                .map(String::as_str),
7282            Some("15")
7283        );
7284        assert_eq!(
7285            directives
7286                .get("FailureActionsOnNonCrashFailures")
7287                .map(String::as_str),
7288            Some("true"),
7289            "without this flag a non-zero exit is not a failure the manager restarts"
7290        );
7291    }
7292
7293    #[test]
7294    fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
7295        let spec = windows_service_spec(&windows_plan(StartMode::Boot));
7296        assert_eq!(spec.account, None);
7297        assert!(spec.automatic_start);
7298        assert!(
7299            spec.command_line.contains("daemon run"),
7300            "{}",
7301            spec.command_line
7302        );
7303    }
7304
7305    // -----------------------------------------------------------------------
7306    // Where each definition is installed
7307    // -----------------------------------------------------------------------
7308
7309    #[test]
7310    fn each_definition_goes_where_its_platform_expects_it() {
7311        let home = PathBuf::from("/home/op");
7312        assert_eq!(
7313            ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
7314            Some(Path::new(
7315                "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
7316            ))
7317        );
7318        assert_eq!(
7319            ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
7320            Some(Path::new(
7321                "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
7322            ))
7323        );
7324        assert_eq!(
7325            ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
7326            Some(Path::new("/etc/systemd/system/runner-manager.service"))
7327        );
7328        assert_eq!(
7329            ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
7330            Some(Path::new(
7331                "/home/op/.config/systemd/user/runner-manager.service"
7332            ))
7333        );
7334        assert_eq!(
7335            ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
7336            None,
7337            "the Service Control Manager has no file"
7338        );
7339    }
7340
7341    #[test]
7342    fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
7343        assert_eq!(
7344            ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
7345            None
7346        );
7347        assert_eq!(
7348            ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
7349            None
7350        );
7351    }
7352
7353    // -----------------------------------------------------------------------
7354    // The least-privilege review: the passing case, then every failing one
7355    // -----------------------------------------------------------------------
7356
7357    #[test]
7358    fn the_rendered_definitions_are_all_least_privilege() {
7359        let linux = linux_plan(StartMode::Boot);
7360        let windows = windows_plan(StartMode::Boot);
7361        for (definition, plan) in [
7362            (ServiceDefinition::systemd(&linux, None), &linux),
7363            (ServiceDefinition::launchd(&linux, None), &linux),
7364            (ServiceDefinition::windows_service(&windows), &windows),
7365        ] {
7366            let review = review_least_privilege(&definition, plan);
7367            assert!(
7368                review.is_least_privilege(),
7369                "{:?} should be least privilege, got:\n{review}",
7370                definition.kind()
7371            );
7372            assert!(
7373                !review.controls().is_empty(),
7374                "a review that confirms nothing proves nothing: {:?}",
7375                definition.kind()
7376            );
7377        }
7378    }
7379
7380    #[test]
7381    fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
7382        let plan = windows_plan(StartMode::Login);
7383        let definition =
7384            ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
7385        let review = review_least_privilege(&definition, &plan);
7386        assert!(review.is_least_privilege(), "{review}");
7387        assert!(
7388            review
7389                .controls()
7390                .iter()
7391                .any(|control| control.contains("LeastPrivilege")),
7392            "{review}"
7393        );
7394    }
7395
7396    #[test]
7397    fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
7398        let plan = linux_plan(StartMode::Boot);
7399        let rendered = systemd_unit(&plan);
7400        let widened = edited(
7401            &rendered,
7402            "ReadWritePaths=/var/lib/runner-manager/config",
7403            "ReadWritePaths=/etc /var/lib/runner-manager/config",
7404        );
7405        let review = review_least_privilege(
7406            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
7407            &plan,
7408        );
7409        assert!(!review.is_least_privilege(), "{review}");
7410        assert!(
7411            review
7412                .excesses()
7413                .iter()
7414                .any(|finding| finding.detail.contains("/etc")),
7415            "the review must name the directory it objects to: {review}"
7416        );
7417    }
7418
7419    #[test]
7420    fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
7421        let plan = linux_plan(StartMode::Boot);
7422        let rendered = systemd_unit(&plan);
7423        let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
7424        let review = review_least_privilege(
7425            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
7426            &plan,
7427        );
7428        assert!(!review.is_least_privilege(), "{review}");
7429        assert!(
7430            review
7431                .excesses()
7432                .iter()
7433                .any(|finding| finding.subject == "NoNewPrivileges"),
7434            "{review}"
7435        );
7436    }
7437
7438    #[test]
7439    fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
7440        let plan = linux_plan(StartMode::Boot);
7441        let rendered = systemd_unit(&plan);
7442        let widened = edited(
7443            &rendered,
7444            "CapabilityBoundingSet=\n",
7445            "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
7446        );
7447        let review = review_least_privilege(
7448            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
7449            &plan,
7450        );
7451        assert!(!review.is_least_privilege(), "{review}");
7452        assert!(
7453            review
7454                .excesses()
7455                .iter()
7456                .any(|finding| finding.subject == "CapabilityBoundingSet"),
7457            "{review}"
7458        );
7459    }
7460
7461    #[test]
7462    fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
7463        let plan = linux_plan(StartMode::Boot);
7464        let rendered = systemd_unit(&plan);
7465        let widened = edited(
7466            &rendered,
7467            "[Install]",
7468            "ListenStream=127.0.0.1:9000\n\n[Install]",
7469        );
7470        let review = review_least_privilege(
7471            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
7472            &plan,
7473        );
7474        assert!(
7475            !review.is_least_privilege(),
7476            "07-security.md rule 2 forbids any inbound surface: {review}"
7477        );
7478    }
7479
7480    #[test]
7481    fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
7482        let plan = linux_plan(StartMode::Boot);
7483        let rendered = systemd_unit(&plan);
7484        let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
7485        let review = review_least_privilege(
7486            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
7487            &plan,
7488        );
7489        assert!(
7490            review.is_least_privilege(),
7491            "too little authority is not an excess: {review}"
7492        );
7493        assert!(
7494            review
7495                .findings()
7496                .iter()
7497                .any(|finding| finding.kind == FindingKind::Shortfall
7498                    && finding.detail.contains("runtime")),
7499            "{review}"
7500        );
7501    }
7502
7503    #[test]
7504    fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
7505        let plan = linux_plan(StartMode::Login);
7506        let rendered = launchd_plist(&plan);
7507        let widened = edited(
7508            &rendered,
7509            "<key>ProcessType</key>",
7510            "<key>UserName</key>\n  <string>root</string>\n  <key>ProcessType</key>",
7511        );
7512        let review = review_least_privilege(
7513            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
7514            &plan,
7515        );
7516        assert!(!review.is_least_privilege(), "{review}");
7517        assert!(
7518            review
7519                .excesses()
7520                .iter()
7521                .any(|finding| finding.subject == "UserName"),
7522            "{review}"
7523        );
7524    }
7525
7526    #[test]
7527    fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
7528        let plan = linux_plan(StartMode::Boot);
7529        let rendered = launchd_plist(&plan);
7530        let widened = edited(
7531            &rendered,
7532            "<key>SessionCreate</key>\n  <false/>",
7533            "<key>SessionCreate</key>\n  <true/>",
7534        );
7535        let review = review_least_privilege(
7536            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
7537            &plan,
7538        );
7539        assert!(!review.is_least_privilege(), "{review}");
7540        assert!(
7541            review
7542                .excesses()
7543                .iter()
7544                .any(|finding| finding.subject == "SessionCreate"),
7545            "{review}"
7546        );
7547    }
7548
7549    #[test]
7550    fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
7551        let plan = linux_plan(StartMode::Boot);
7552        let rendered = launchd_plist(&plan);
7553        let widened = edited(
7554            &rendered,
7555            "<key>ProcessType</key>",
7556            "<key>MachServices</key>\n  <dict/>\n  <key>ProcessType</key>",
7557        );
7558        let review = review_least_privilege(
7559            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
7560            &plan,
7561        );
7562        assert!(!review.is_least_privilege(), "{review}");
7563    }
7564
7565    #[test]
7566    fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
7567        let plan = windows_plan(StartMode::Login);
7568        let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
7569        let widened = edited(
7570            &rendered,
7571            "<RunLevel>LeastPrivilege</RunLevel>",
7572            "<RunLevel>HighestAvailable</RunLevel>",
7573        );
7574        let review = review_least_privilege(
7575            &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
7576            &plan,
7577        );
7578        assert!(!review.is_least_privilege(), "{review}");
7579        assert!(
7580            review
7581                .excesses()
7582                .iter()
7583                .any(|finding| finding.subject == "RunLevel"),
7584            "{review}"
7585        );
7586    }
7587
7588    #[test]
7589    fn a_task_that_would_store_a_password_is_not_least_privilege() {
7590        let plan = windows_plan(StartMode::Login);
7591        let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
7592        let widened = edited(
7593            &rendered,
7594            "<LogonType>InteractiveToken</LogonType>",
7595            "<LogonType>Password</LogonType>",
7596        );
7597        let review = review_least_privilege(
7598            &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
7599            &plan,
7600        );
7601        assert!(!review.is_least_privilege(), "{review}");
7602    }
7603
7604    #[test]
7605    fn an_interactive_windows_service_is_not_least_privilege() {
7606        let plan = windows_plan(StartMode::Boot);
7607        let rendered = windows_service_descriptor(&plan);
7608        let widened = edited(
7609            &rendered,
7610            "ServiceType=OWN_PROCESS",
7611            "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
7612        );
7613        let review = review_least_privilege(
7614            &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
7615            &plan,
7616        );
7617        assert!(!review.is_least_privilege(), "{review}");
7618        assert!(
7619            review
7620                .excesses()
7621                .iter()
7622                .any(|finding| finding.subject == "ServiceType"),
7623            "{review}"
7624        );
7625    }
7626
7627    #[test]
7628    fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
7629        let plan = windows_plan(StartMode::Boot);
7630        let rendered = windows_service_descriptor(&plan);
7631        let changed = edited(
7632            &rendered,
7633            "Account=NT AUTHORITY\\SYSTEM",
7634            "Account=NT AUTHORITY\\LocalService",
7635        );
7636        let review = review_least_privilege(
7637            &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
7638            &plan,
7639        );
7640        // LocalService is *less* privileged, so this is not an excess. It is
7641        // still wrong, because `d2`'s DACL names SY, BA and OW and nothing else:
7642        // the daemon would start and find no credential. Reporting it as a
7643        // shortfall rather than as an excess is the difference between a check
7644        // that understands the requirement and one that counts adjectives.
7645        assert!(review.is_least_privilege(), "{review}");
7646        assert!(
7647            review.findings().iter().any(|finding| {
7648                finding.kind == FindingKind::Shortfall && finding.subject == "Account"
7649            }),
7650            "{review}"
7651        );
7652    }
7653
7654    // -----------------------------------------------------------------------
7655    // The recorded absolute path, and what became of it
7656    // -----------------------------------------------------------------------
7657
7658    #[test]
7659    fn a_recorded_path_that_is_still_there_is_current() {
7660        let host = Host::new();
7661        let state = inspect_binary(&host.binary, Some(&host.binary));
7662        assert!(!state.is_error(), "{state}");
7663        assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
7664    }
7665
7666    #[test]
7667    fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
7668        let host = Host::new();
7669        // An `npm i -g` binary lives under the active Node installation's
7670        // global prefix. Switching Node versions moves the prefix, and the
7671        // recorded path stops existing while the registration survives.
7672        let recorded = host.binary.clone();
7673        let healthy = inspect_binary(&recorded, Some(&recorded));
7674        assert!(
7675            !healthy.is_error(),
7676            "the discriminator: before the binary moves, this must be healthy"
7677        );
7678
7679        std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
7680
7681        let state = inspect_binary(&recorded, Some(&recorded));
7682        assert!(state.is_error(), "{state}");
7683        assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
7684        assert!(
7685            state.to_string().contains("npm"),
7686            "the message must name the cause an operator will not otherwise connect: {state}"
7687        );
7688    }
7689
7690    #[test]
7691    fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
7692        let root = tempfile::tempdir().expect("a temporary directory");
7693        let state = inspect_binary(root.path(), None);
7694        assert!(state.is_error(), "{state}");
7695        assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
7696    }
7697
7698    #[test]
7699    fn a_registration_naming_a_different_binary_is_a_divergence() {
7700        let host = Host::new();
7701        let other = host.binary.with_file_name("something-else");
7702        let state = inspect_binary(&host.binary, Some(&other));
7703        assert!(state.is_error(), "{state}");
7704        assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
7705    }
7706
7707    #[test]
7708    fn absence_is_reported_before_divergence() {
7709        let host = Host::new();
7710        let recorded = host.binary.clone();
7711        std::fs::remove_file(&recorded).expect("removable");
7712        let other = recorded.with_file_name("something-else");
7713        // Both faults are true at once. The operator needs to hear about the
7714        // missing file, not about a disagreement between two paths of which one
7715        // does not exist.
7716        assert!(matches!(
7717            inspect_binary(&recorded, Some(&other)),
7718            BinaryPath::Missing { .. }
7719        ));
7720    }
7721
7722    // -----------------------------------------------------------------------
7723    // The record
7724    // -----------------------------------------------------------------------
7725
7726    #[test]
7727    fn the_record_round_trips_through_toml() {
7728        let host = Host::new();
7729        let plan = InstallPlan::resolve(
7730            ServiceIdentity::product(),
7731            &host.request(StartMode::Boot),
7732            ServiceDirectories::of(&host.paths),
7733        )
7734        .expect("a resolvable plan");
7735        let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
7736        let record = InstallRecord::of(&plan, &definition, Utc::now());
7737        record.write(&host.paths).expect("a writable record");
7738        let read = InstallRecord::read(&host.paths)
7739            .expect("a readable record")
7740            .expect("a record is there");
7741        assert_eq!(read, record);
7742        assert_eq!(read.binary, host.binary);
7743        assert!(read.binary.is_absolute());
7744    }
7745
7746    #[test]
7747    fn a_unit_written_by_an_older_build_is_reported_and_a_current_one_is_not() {
7748        let host = Host::new();
7749        let identity = ServiceIdentity::product();
7750        let plan = InstallPlan::resolve(
7751            identity.clone(),
7752            &host.request(StartMode::Boot),
7753            ServiceDirectories::of(&host.paths),
7754        )
7755        .expect("a resolvable plan");
7756        let installed = systemd_unit(&plan);
7757        let definition =
7758            ServiceDefinition::from_text(DefinitionKind::SystemdUnit, installed.clone());
7759        let mut record = InstallRecord::of(&plan, &definition, Utc::now());
7760        let path = PathBuf::from("/etc/systemd/system/runner-manager.service");
7761
7762        assert_eq!(
7763            systemd_definition_drift(&identity, &record, path.clone(), &installed),
7764            None,
7765            "the record must rebuild exactly the unit `service install` wrote"
7766        );
7767
7768        record.installed_by_version = "0.4.7".into();
7769        let frozen = installed.replace(
7770            "[Service]\n",
7771            "[Service]\nLoadCredential=runner-manager.user-access-token:/var/lib/runner-manager/secrets/user-access-token\n",
7772        );
7773        let drift = systemd_definition_drift(&identity, &record, path, &frozen)
7774            .expect("an outdated unit is drift");
7775        assert_eq!(drift.installed_by_version, "0.4.7");
7776        assert_eq!(drift.rendered, installed);
7777
7778        let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
7779            .with_definition_drift(Some(drift));
7780        assert!(!status.is_healthy());
7781        assert!(
7782            status
7783                .problems()
7784                .iter()
7785                .any(|problem| problem.subject == "definition" && problem.detail.contains("0.4.7")),
7786            "{status}"
7787        );
7788    }
7789
7790    #[test]
7791    fn a_rejected_credential_keeps_its_first_moment_and_makes_the_service_unhealthy() {
7792        let host = Host::new();
7793        assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
7794        let first = Utc::now() - chrono::Duration::hours(9);
7795        record_github_credential_rejected(&host.paths, first).unwrap();
7796        record_github_credential_rejected(&host.paths, Utc::now()).unwrap();
7797        let since = github_credential_rejected_since(&host.paths).unwrap();
7798        assert_eq!(since, Some(first));
7799
7800        let status = ServiceStatus::compose(
7801            ServiceIdentity::product(),
7802            None,
7803            None,
7804            None,
7805            None,
7806            &host.paths,
7807        )
7808        .with_credential_rejection(since);
7809        assert!(!status.is_healthy());
7810        assert_eq!(status.credential_rejected_since(), Some(first));
7811        assert!(status.to_string().contains("auth login"), "{status}");
7812
7813        clear_github_credential_rejection(&host.paths).unwrap();
7814        clear_github_credential_rejection(&host.paths).unwrap();
7815        assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
7816    }
7817
7818    /// The record is readable by the account whose directory it is in.
7819    ///
7820    /// A boot-mode `service install` runs under `sudo`, and `NamedTempFile`
7821    /// creates at `0600`, so the record landed `root`-owned and unreadable by
7822    /// the operator — `service status` then failed on their own host with
7823    /// `Permission denied`. Ownership cannot be reproduced in an unprivileged
7824    /// test, but the mode that made ownership fatal can, and it is the half
7825    /// this code controls.
7826    #[cfg(unix)]
7827    #[test]
7828    fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
7829        use std::os::unix::fs::PermissionsExt as _;
7830
7831        let host = Host::new();
7832        let plan = InstallPlan::resolve(
7833            ServiceIdentity::product(),
7834            &host.request(StartMode::Boot),
7835            ServiceDirectories::of(&host.paths),
7836        )
7837        .expect("a resolvable plan");
7838        let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
7839        InstallRecord::of(&plan, &definition, Utc::now())
7840            .write(&host.paths)
7841            .expect("a writable record");
7842
7843        let mode = std::fs::metadata(InstallRecord::path(&host.paths))
7844            .expect("the record is there")
7845            .permissions()
7846            .mode()
7847            & 0o777;
7848        assert_eq!(
7849            mode, 0o644,
7850            "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
7851             service install` wrote, and `service status` fails on their own host. It holds no \
7852             credential and sits in a 0700 directory, so 0644 discloses nothing"
7853        );
7854    }
7855
7856    /// A record written before the service ran a copy of its own must still
7857    /// load. It reads as `None`, which is the truth about it: that registration
7858    /// names the package manager's file and cannot be upgraded under itself.
7859    #[test]
7860    fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
7861        let host = Host::new();
7862        let path = InstallRecord::path(&host.paths);
7863        std::fs::write(
7864            &path,
7865            format!(
7866                "schema_version = {RECORD_SCHEMA_VERSION}
7867service_name = \"runner-manager\"
7868                 manager = \"systemd\"
7869start_mode = \"boot\"
7870account = \"root\"
7871                 binary = \"/x\"
7872arguments = []
7873restart_delay_secs = 15
7874                 restart_reset_secs = 600
7875log_file = \"/x\"
7876                 installed_at = \"2026-01-01T00:00:00Z\"
7877installed_by_version = \"0.1.0\"
7878                 [directories]
7879config = \"/a\"
7880state = \"/b\"
7881runtime = \"/c\"
7882logs = \"/d\"
7883"
7884            ),
7885        )
7886        .expect("a writable record");
7887        let read = InstallRecord::read(&host.paths)
7888            .expect("a record missing an optional field is still readable")
7889            .expect("a record is there");
7890        assert_eq!(
7891            read.source_binary, None,
7892            "the legacy layout has no source, and must not invent one"
7893        );
7894    }
7895
7896    /// The field that makes an upgrade possible survives the write.
7897    #[test]
7898    fn a_registration_remembers_the_file_it_was_copied_from() {
7899        let host = Host::new();
7900        let source = host.binary.with_file_name("npm-installed-runner-manager");
7901        std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
7902        let plan = InstallPlan::resolve(
7903            ServiceIdentity::product(),
7904            &host.request(StartMode::Boot).copied_from(&source),
7905            ServiceDirectories::of(&host.paths),
7906        )
7907        .expect("a resolvable plan");
7908        let definition = ServiceDefinition::from_text(
7909            DefinitionKind::SystemdUnit,
7910            "[Service]
7911",
7912        );
7913        let record = InstallRecord::of(&plan, &definition, Utc::now());
7914        record.write(&host.paths).expect("a writable record");
7915
7916        let read = InstallRecord::read(&host.paths)
7917            .expect("a readable record")
7918            .expect("a record is there");
7919        assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
7920        assert_ne!(
7921            read.source_binary.as_deref(),
7922            Some(read.binary.as_path()),
7923            "the whole point is that the two are different files: one the service holds open,              one the package manager is free to replace"
7924        );
7925    }
7926
7927    #[test]
7928    fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
7929        let host = Host::new();
7930        let path = InstallRecord::path(&host.paths);
7931        std::fs::write(
7932            &path,
7933            format!(
7934                "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
7935                 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
7936                 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
7937                 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
7938                 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
7939                RECORD_SCHEMA_VERSION + 1
7940            ),
7941        )
7942        .expect("a writable record");
7943        let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
7944        assert!(
7945            matches!(error, ServiceError::RecordUnreadable { .. }),
7946            "{error}"
7947        );
7948        assert!(
7949            error.to_string().contains("service uninstall"),
7950            "the message must say how to recover: {error}"
7951        );
7952    }
7953
7954    #[test]
7955    fn no_record_is_not_an_error() {
7956        let host = Host::new();
7957        assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
7958        assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
7959    }
7960
7961    // -----------------------------------------------------------------------
7962    // The last successful GitHub contact
7963    // -----------------------------------------------------------------------
7964
7965    #[test]
7966    fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
7967        let host = Host::new();
7968        assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
7969    }
7970
7971    #[test]
7972    fn the_heartbeat_round_trips_to_the_second() {
7973        let host = Host::new();
7974        let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
7975            .expect("a valid timestamp")
7976            .with_timezone(&Utc);
7977        record_github_contact(&host.paths, at).expect("a writable heartbeat");
7978        assert_eq!(
7979            last_github_contact(&host.paths).expect("readable"),
7980            Some(at)
7981        );
7982    }
7983
7984    #[test]
7985    fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
7986        let host = Host::new();
7987        std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
7988        let error = last_github_contact(&host.paths)
7989            .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
7990        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
7991    }
7992
7993    /// The surface the three-hour outage did not have.
7994    ///
7995    /// A root that refuses a launch does so before any attempt row exists, and
7996    /// the daemon's log scrubs the paths out of the sentence, so this file is
7997    /// the only place the directory and the remediation can reach the operator.
7998    #[test]
7999    fn a_runner_root_refusal_round_trips_for_service_status() {
8000        let host = Host::new();
8001        let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
8002
8003        assert!(
8004            runner_root_refusals(&host.paths)
8005                .expect("readable")
8006                .is_empty(),
8007            "no record means every policy is placing runners"
8008        );
8009
8010        record_runner_root_refusal(
8011            &host.paths,
8012            "policy-a",
8013            at,
8014            "denied_by_privacy_policy",
8015            "/Volumes/NVME/runners",
8016            "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
8017        )
8018        .expect("a writable record");
8019
8020        let refusals = runner_root_refusals(&host.paths).expect("readable");
8021        assert_eq!(refusals.len(), 1);
8022        assert_eq!(refusals[0].policy, "policy-a");
8023        assert_eq!(refusals[0].at, at);
8024        assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
8025        assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
8026        assert!(
8027            refusals[0].detail.contains("/Volumes/NVME/runners")
8028                && refusals[0].detail.contains("Full Disk Access"),
8029            "the path and the remediation are the whole point of this file: {refusals:?}"
8030        );
8031    }
8032
8033    /// The regression that would put the outage straight back.
8034    ///
8035    /// A host runs several policies and they do not share a fate. When the
8036    /// record was one host-wide slot, the policy that placed a runner deleted
8037    /// the record of the policy that could not — on the same reconcile pass —
8038    /// and `service status` reported a healthy host that had started zero
8039    /// runners for an entire target.
8040    #[test]
8041    fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
8042        let host = Host::new();
8043        let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
8044        record_runner_root_refusal(
8045            &host.paths,
8046            "broken",
8047            at,
8048            "denied_by_privacy_policy",
8049            "/Volumes/NVME/runners",
8050            "detail",
8051        )
8052        .expect("a writable record");
8053        record_runner_root_refusal(
8054            &host.paths,
8055            "also-broken",
8056            at,
8057            "not_writable",
8058            "/srv/other",
8059            "detail",
8060        )
8061        .expect("a writable record");
8062
8063        // The policy with its own working root succeeds and clears only itself.
8064        clear_runner_root_refusal(&host.paths, "healthy")
8065            .expect("clearing an absent policy is fine");
8066        clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
8067
8068        let refusals = runner_root_refusals(&host.paths).expect("readable");
8069        assert_eq!(
8070            refusals
8071                .iter()
8072                .map(|r| r.policy.as_str())
8073                .collect::<Vec<_>>(),
8074            vec!["broken"],
8075            "the policy that is still refused must keep its record"
8076        );
8077    }
8078
8079    /// Clearing is what keeps a fixed host from reporting a stale fault, and the
8080    /// file goes when the last entry does.
8081    #[test]
8082    fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
8083        let host = Host::new();
8084        record_runner_root_refusal(
8085            &host.paths,
8086            "p",
8087            Utc::now(),
8088            "not_writable",
8089            "/srv/x",
8090            "detail",
8091        )
8092        .expect("a writable record");
8093
8094        clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
8095        assert!(
8096            !root_refusal_path(&host.paths).exists(),
8097            "a host with nothing refused leaves nothing behind"
8098        );
8099        clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
8100    }
8101
8102    /// Same rule as the heartbeat: absence means "every policy is placing
8103    /// runners", so a record that cannot be parsed must not be reported as one.
8104    #[test]
8105    fn a_malformed_refusal_is_an_error_and_not_silently_none() {
8106        let host = Host::new();
8107        std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
8108        let error = runner_root_refusals(&host.paths)
8109            .expect_err("an unparseable record is not the same as no record");
8110        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
8111    }
8112
8113    /// The half of this feature that turns the file into a diagnosis, and the
8114    /// half that had no test: `service status` must actually read it.
8115    ///
8116    /// A **note** and not a problem, deliberately. `service status` runs as
8117    /// whoever typed it, must stay readable on a host with nothing installed,
8118    /// and drives an exit code -- and nothing an operator can type clears this
8119    /// record, so a problem would fail that exit code on a host whose root was
8120    /// fixed but which has had no queued job since, with a printed remedy
8121    /// (`service uninstall && service install`) that does not touch `state/`.
8122    #[test]
8123    fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
8124        let host = Host::new();
8125        record_runner_root_refusal(
8126            &host.paths,
8127            "policy-a",
8128            DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
8129            "denied_by_privacy_policy",
8130            "/Volumes/NVME/runners",
8131            "Grant Full Disk Access to the program that runs the service",
8132        )
8133        .expect("a writable record");
8134
8135        let status = host.operations().status().expect("a readable status");
8136        let notes = status.notes().join("\n");
8137
8138        assert!(
8139            notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
8140            "the directory and the remediation the log had to scrub must appear here: {notes}"
8141        );
8142        assert!(
8143            notes.contains("policy-a"),
8144            "the operator has to know which target placed no runner: {notes}"
8145        );
8146        assert!(
8147            !status
8148                .problems()
8149                .iter()
8150                .any(|problem| problem.subject == "runner root"),
8151            "a record nothing an operator types can clear must not drive the exit code"
8152        );
8153    }
8154
8155    // -----------------------------------------------------------------------
8156    // -----------------------------------------------------------------------
8157    // Install
8158    // -----------------------------------------------------------------------
8159
8160    #[test]
8161    fn install_records_the_absolute_binary_path_and_the_four_directories() {
8162        let host = Host::new();
8163        let installed = host
8164            .operations()
8165            .install(&host.request(StartMode::Boot))
8166            .expect("an install against the recording controls");
8167
8168        assert_eq!(installed.record.binary, host.binary);
8169        assert!(installed.record.binary.is_absolute());
8170        assert_eq!(installed.record.start_mode, StartMode::Boot);
8171        assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
8172        assert_eq!(
8173            installed.record.directories,
8174            ServiceDirectories::of(&host.paths)
8175        );
8176        assert_eq!(
8177            installed.record.log_file,
8178            host.paths.logs_dir().join(LOG_FILE_STEM)
8179        );
8180        assert_eq!(installed.record.restart_delay_secs, 15);
8181
8182        let registrations = host.controls.registrations();
8183        assert_eq!(registrations.len(), 1);
8184        assert_eq!(registrations[0].0, StartMode::Boot);
8185        assert_eq!(registrations[0].1, "runner-manager");
8186    }
8187
8188    #[test]
8189    fn install_is_refused_while_the_single_instance_lock_is_held() {
8190        let host = Host::new();
8191        // The discriminator: with the lock free, the same call succeeds.
8192        {
8193            let operations = host.operations();
8194            operations
8195                .install(&host.request(StartMode::Boot))
8196                .expect("an install with the lock free");
8197            operations.uninstall().expect("a clean slate");
8198        }
8199
8200        let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
8201            .expect("this process takes the lock first");
8202
8203        let error = host
8204            .operations()
8205            .install(&host.request(StartMode::Boot))
8206            .expect_err("a second agent must not be registered while one is running");
8207        assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
8208        assert!(
8209            error.to_string().contains("already running"),
8210            "the message must be actionable: {error}"
8211        );
8212        assert!(
8213            host.controls.registrations().is_empty(),
8214            "a refused install must register nothing"
8215        );
8216        assert_eq!(
8217            InstallRecord::read(&host.paths).expect("readable"),
8218            None,
8219            "a refused install must write no record"
8220        );
8221    }
8222
8223    /// Installing over the same start mode replaces the registration.
8224    ///
8225    /// It used to be refused, and the refusal is what broke a real host: the
8226    /// caller has already swapped the binary the service runs by the time this
8227    /// is reached — that is the documented way to upgrade by hand — so refusing
8228    /// left a daemon running an unregistered binary and, on macOS, one the
8229    /// stored credential's keychain grant did not name.
8230    #[test]
8231    fn installing_over_the_same_start_mode_replaces_the_registration() {
8232        let host = Host::new();
8233        let operations = host.operations();
8234        operations
8235            .install(&host.request(StartMode::Boot))
8236            .expect("the first install");
8237
8238        let again = operations
8239            .install(&host.request(StartMode::Boot))
8240            .expect("an install over the same mode replaces rather than refusing");
8241
8242        assert!(
8243            again.replaced_existing,
8244            "the operator is told this replaced something rather than made it"
8245        );
8246        assert_eq!(
8247            host.controls.registrations().len(),
8248            1,
8249            "replacing must not leave two registrations behind"
8250        );
8251        assert_eq!(
8252            InstallRecord::read(&host.paths)
8253                .expect("readable")
8254                .expect("a record")
8255                .start_mode,
8256            StartMode::Boot
8257        );
8258    }
8259
8260    /// The *other* mode is still refused, because changing it moves the
8261    /// registration between two service managers and changes the account and
8262    /// the secret store with it. `set_start_mode` is what does that.
8263    #[test]
8264    fn installing_over_the_other_start_mode_is_refused() {
8265        let host = Host::new();
8266        let operations = host.operations();
8267        operations
8268            .install(&host.request(StartMode::Boot))
8269            .expect("the first install");
8270
8271        let error = operations
8272            .install(&host.request(StartMode::Login))
8273            .expect_err("a mode change is not an install");
8274        assert!(
8275            matches!(
8276                error,
8277                ServiceError::AlreadyInstalled {
8278                    existing: StartMode::Boot,
8279                    requested: StartMode::Login,
8280                    ..
8281                }
8282            ),
8283            "{error}"
8284        );
8285        assert!(
8286            !error
8287                .to_string()
8288                .contains("switch the start mode in place,"),
8289            "the old remedy named a capability no command offers; the terminal UI is where \
8290             the start mode moves: {error}"
8291        );
8292        assert_eq!(host.controls.registrations().len(), 1);
8293    }
8294
8295    #[test]
8296    fn install_rolls_back_the_registration_when_record_persistence_fails() {
8297        let host = Host::new();
8298        let record_path = InstallRecord::path(&host.paths);
8299        std::fs::create_dir(&record_path).expect("a directory blocks the record file");
8300
8301        let error = host
8302            .operations()
8303            .install(&host.request(StartMode::Boot))
8304            .expect_err("record persistence must fail");
8305
8306        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
8307        assert!(
8308            host.controls.registrations().is_empty(),
8309            "a failed install must not leave a live unrecorded registration"
8310        );
8311        assert!(
8312            host.controls
8313                .calls()
8314                .iter()
8315                .any(|call| call == "uninstall runner-manager (boot)"),
8316            "the registration must be explicitly rolled back: {:?}",
8317            host.controls.calls()
8318        );
8319        assert!(
8320            !host.runner_root.as_path().exists(),
8321            "the rollback must take the runner root this install created with it; a directory \
8322             prepared for a registration that does not exist is litter, and on Windows it is \
8323             litter with a security descriptor"
8324        );
8325    }
8326
8327    // -----------------------------------------------------------------------
8328    // The runner root (b2)
8329    // -----------------------------------------------------------------------
8330
8331    #[test]
8332    fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
8333        use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
8334
8335        // The mapping `prepare_runner_root` reads, asserted against the same
8336        // function the registration's own principal comes from — which is the
8337        // point of deriving it there rather than restating it.
8338        assert_eq!(
8339            ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
8340            ServiceAccount::LocalSystem
8341        );
8342        assert_eq!(
8343            ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
8344            ServiceAccount::InvokingUser
8345        );
8346
8347        let boot = default_root_sddl(&RootAdmission::LocalSystem);
8348        assert!(!grants_broad_write(&boot), "{boot}");
8349        assert!(
8350            !boot.contains("S-1-5-21"),
8351            "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
8352        );
8353
8354        // A login task runs under a *filtered* token — `RunLevel` is
8355        // `LeastPrivilege`, see `windows_scheduled_task_xml` — in which
8356        // Administrators is deny-only. Without an ace of its own the account
8357        // the task runs as would be admitted by nothing at all.
8358        let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
8359        assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
8360        assert!(!grants_broad_write(&login), "{login}");
8361    }
8362
8363    #[test]
8364    fn an_install_reports_the_runner_root_it_prepared() {
8365        let host = Host::new();
8366        let installed = host
8367            .operations()
8368            .install(&host.request(StartMode::Boot))
8369            .expect("an install");
8370        let rendered = installed.runner_root.to_string();
8371        assert!(
8372            !rendered.contains("S-1-5-21"),
8373            "the report must add no identity to the output: {rendered}"
8374        );
8375        if cfg!(windows) {
8376            assert_eq!(
8377                installed.runner_root.path(),
8378                Some(host.runner_root.as_path())
8379            );
8380            assert!(
8381                host.runner_root.as_path().is_dir(),
8382                "the directory jobs would run in has to exist once the service is registered"
8383            );
8384        } else {
8385            assert_eq!(
8386                installed.runner_root,
8387                crate::runner_root_access::RootAccessSummary::NotApplicable,
8388                "macOS and Linux keep the runtime directory they have always used"
8389            );
8390        }
8391    }
8392
8393    #[test]
8394    fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
8395        let host = Host::new();
8396        let operations = host.operations();
8397        operations
8398            .install(&host.request(StartMode::Boot))
8399            .expect("an install at boot");
8400
8401        let change = operations
8402            .set_start_mode(StartMode::Login)
8403            .expect("a switch to login");
8404
8405        assert!(change.changed);
8406        if cfg!(windows) {
8407            assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
8408            assert!(
8409                host.runner_root.as_path().is_dir(),
8410                "the switch must not remove the directory it reconciled"
8411            );
8412        }
8413        // The operator is told, because the account that may write there has
8414        // moved with the mode.
8415        assert!(
8416            change.to_string().contains("runner root"),
8417            "{}",
8418            change.to_string()
8419        );
8420    }
8421
8422    #[test]
8423    fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
8424        let host = Host::new();
8425        let operations = host.operations();
8426        operations
8427            .install(&host.request(StartMode::Boot))
8428            .expect("an install at boot");
8429
8430        let change = operations
8431            .set_start_mode(StartMode::Boot)
8432            .expect("a switch to the mode already in force");
8433
8434        assert!(!change.changed);
8435        assert_eq!(
8436            change.runner_root,
8437            crate::runner_root_access::RootAccessSummary::NotApplicable,
8438            "nothing moves, so nothing about the root's access control has to; reconciling here \
8439             would turn a no-op command into one that can fail on a permission it does not need"
8440        );
8441    }
8442
8443    #[cfg(windows)]
8444    #[test]
8445    fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
8446        let host = Host::new();
8447        host.controls
8448            .fail_next_install(StartMode::Boot, "injected registration failure");
8449
8450        let error = host
8451            .operations()
8452            .install(&host.request(StartMode::Boot))
8453            .expect_err("the manager refuses the registration");
8454
8455        assert!(matches!(error, ServiceError::Control { .. }), "{error}");
8456        assert!(
8457            !host.runner_root.as_path().exists(),
8458            "the directory was created for a registration that does not exist"
8459        );
8460    }
8461
8462    #[cfg(windows)]
8463    #[test]
8464    fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
8465        let host = Host::new();
8466        // What a directory created below `C:\` with inheritance left on looks
8467        // like, built deliberately because a per-account `%TEMP%` never
8468        // produces one by accident.
8469        crate::runner_root_access::create_with_descriptor_for_tests(
8470            host.runner_root.as_path(),
8471            "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
8472        )
8473        .expect("a deliberately open runner root");
8474        let before = crate::runner_root_access::report(host.runner_root.as_path());
8475
8476        let error = host
8477            .operations()
8478            .install(&host.request(StartMode::Boot))
8479            .expect_err("an open runner root is refused");
8480
8481        assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
8482        assert!(
8483            error.to_string().contains("nothing was registered"),
8484            "{error}"
8485        );
8486        assert!(
8487            host.controls.registrations().is_empty(),
8488            "the refusal has to come before the platform is asked to register anything: {:?}",
8489            host.controls.calls()
8490        );
8491        assert_eq!(
8492            crate::runner_root_access::report(host.runner_root.as_path()),
8493            before,
8494            "an open directory is refused rather than tightened: its contents cannot be trusted, \
8495             so adopting it would be worse than declining it"
8496        );
8497    }
8498
8499    #[cfg(windows)]
8500    #[test]
8501    fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
8502        let host = Host::new();
8503        let operations = host.operations();
8504        operations
8505            .install(&host.request(StartMode::Boot))
8506            .expect("an install");
8507        assert!(host.runner_root.as_path().is_dir());
8508
8509        operations.uninstall().expect("an uninstall");
8510
8511        assert!(
8512            host.runner_root.as_path().is_dir(),
8513            "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
8514             runner root may hold an operator's retained workspaces."
8515        );
8516    }
8517
8518    #[test]
8519    fn install_reviews_what_it_registered() {
8520        let host = Host::new();
8521        let installed = host
8522            .operations()
8523            .install(&host.request(StartMode::Boot))
8524            .expect("an install");
8525        assert!(
8526            installed.review.is_least_privilege(),
8527            "{}",
8528            installed.review
8529        );
8530        assert!(
8531            !installed.review.controls().is_empty(),
8532            "a review that confirms nothing proves nothing: {}",
8533            installed.review
8534        );
8535        assert_eq!(
8536            installed.review.kind(),
8537            host_definition_kind(StartMode::Boot),
8538            "the review must be of the definition this host's manager was given"
8539        );
8540        assert!(
8541            !installed.review.account().justification().is_empty(),
8542            "a privileged account with no stated reason is an unreviewed one"
8543        );
8544    }
8545
8546    // -----------------------------------------------------------------------
8547    // Uninstall deletes nothing else
8548    // -----------------------------------------------------------------------
8549
8550    #[test]
8551    fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
8552        let host = Host::new();
8553        let operations = host.operations();
8554        operations
8555            .install(&host.request(StartMode::Boot))
8556            .expect("an install");
8557
8558        // The files `05-infrastructure.md` says must survive. Written *after*
8559        // the install so that the snapshot below is of a host in the state an
8560        // operator's would be in.
8561        let config = host.paths.config_dir();
8562        std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
8563        std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
8564        std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
8565        std::fs::write(
8566            host.paths
8567                .state_dir()
8568                .join("packages/2.330.0/runner.tar.gz"),
8569            b"cached package",
8570        )
8571        .expect("writable");
8572        std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
8573        std::fs::write(
8574            host.paths.state_dir().join("secrets/user-access-token"),
8575            b"a stand-in for the stored credential",
8576        )
8577        .expect("writable");
8578        std::fs::write(
8579            host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
8580            b"diagnostics",
8581        )
8582        .expect("writable");
8583
8584        let roots: Vec<PathBuf> = host
8585            .paths
8586            .all()
8587            .iter()
8588            .map(|(_, path)| (*path).to_path_buf())
8589            .collect();
8590        let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
8591        let before = snapshot(&roots);
8592
8593        // Non-vacuity: a comparison of two empty maps would pass whatever
8594        // `uninstall` did.
8595        assert!(
8596            before.len() >= 6,
8597            "the fixture must actually contain the files this test is about, got {before:#?}"
8598        );
8599        let record_path = InstallRecord::path(&host.paths);
8600        assert!(
8601            before.contains_key(&record_path),
8602            "the install record must be present before uninstall"
8603        );
8604
8605        let uninstalled = operations.uninstall().expect("an uninstall");
8606        assert!(uninstalled.removed_registration);
8607        assert!(uninstalled.removed_record);
8608
8609        let after = snapshot(&roots);
8610
8611        // Exactly one thing changed, and it is the registration's own record.
8612        let mut expected = before.clone();
8613        expected.remove(&record_path);
8614        assert_eq!(
8615            after, expected,
8616            "uninstall must remove its own record and nothing else"
8617        );
8618        assert!(
8619            !record_path.exists(),
8620            "the record itself must go, or `uninstall` did nothing at all"
8621        );
8622        assert!(
8623            uninstalled
8624                .preserved
8625                .iter()
8626                .all(|path| roots.contains(&path.as_path())),
8627            "the preserved list must name the four directories: {uninstalled}"
8628        );
8629    }
8630
8631    #[test]
8632    fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
8633        let host = Host::new();
8634        let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
8635        assert!(!uninstalled.removed_registration);
8636        assert!(!uninstalled.removed_record);
8637    }
8638
8639    #[test]
8640    fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
8641        let host = Host::new();
8642        let operations = host.operations();
8643        operations
8644            .install(&host.request(StartMode::Boot))
8645            .expect("an install");
8646        std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
8647
8648        let uninstalled = operations.uninstall().expect("an uninstall");
8649        assert!(
8650            uninstalled.removed_registration,
8651            "a lost record must not strand a registration"
8652        );
8653        assert!(host.controls.registrations().is_empty());
8654    }
8655
8656    // -----------------------------------------------------------------------
8657    // Switching start mode
8658    // -----------------------------------------------------------------------
8659
8660    #[test]
8661    fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
8662        let host = Host::new();
8663        let operations = host.operations();
8664        operations
8665            .install(&host.request(StartMode::Boot))
8666            .expect("an install at boot");
8667
8668        // The discriminator. If the switch re-resolved the binary the way
8669        // `install` does, it would either fail here or silently record the test
8670        // binary instead. Removing the file makes the difference visible.
8671        std::fs::remove_file(&host.binary).expect("the installed binary goes away");
8672
8673        let change = operations
8674            .set_start_mode(StartMode::Login)
8675            .expect("a switch that does not reinstall the product");
8676        assert!(change.changed);
8677        assert_eq!(change.from, StartMode::Boot);
8678        assert_eq!(change.to, StartMode::Login);
8679        assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
8680
8681        let record = InstallRecord::read(&host.paths)
8682            .expect("readable")
8683            .expect("a record");
8684        assert_eq!(record.start_mode, StartMode::Login);
8685        assert_eq!(
8686            record.binary, host.binary,
8687            "the recorded path must survive the switch untouched"
8688        );
8689
8690        let registrations = host.controls.registrations();
8691        assert_eq!(registrations.len(), 1, "{registrations:?}");
8692        assert_eq!(registrations[0].0, StartMode::Login);
8693        assert!(
8694            registrations[0]
8695                .2
8696                .command_line
8697                .contains(&host.binary.to_string_lossy().into_owned()),
8698            "{:?}",
8699            registrations[0].2
8700        );
8701    }
8702
8703    #[test]
8704    fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
8705        let host = Host::new();
8706        let operations = host.operations();
8707        operations
8708            .install(&host.request(StartMode::Boot))
8709            .expect("an install at boot");
8710        let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
8711        host.controls
8712            .fail_next_install(StartMode::Login, "injected target failure");
8713
8714        let error = operations
8715            .set_start_mode(StartMode::Login)
8716            .expect_err("the target manager refuses the install");
8717
8718        assert!(matches!(error, ServiceError::Control { .. }), "{error}");
8719        assert_eq!(
8720            std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
8721            record_before
8722        );
8723        let registrations = host.controls.registrations();
8724        assert_eq!(registrations.len(), 1, "{registrations:?}");
8725        assert_eq!(registrations[0].0, StartMode::Boot);
8726    }
8727
8728    #[test]
8729    fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
8730        let host = Host::new();
8731        let operations = host.operations();
8732        operations
8733            .install(&host.request(StartMode::Boot))
8734            .expect("an install at boot");
8735        let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
8736        let config = host.paths.config_dir().to_path_buf();
8737        let hidden = config.with_file_name("config-hidden-by-fault");
8738        host.controls.hide_directory_after_install(
8739            StartMode::Login,
8740            config.clone(),
8741            hidden.clone(),
8742        );
8743
8744        let error = operations
8745            .set_start_mode(StartMode::Login)
8746            .expect_err("the injected filesystem fault prevents persistence");
8747
8748        std::fs::remove_file(&config).expect("remove the injected blocker");
8749        std::fs::rename(&hidden, &config).expect("restore the record directory");
8750        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
8751        assert_eq!(
8752            std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
8753            record_before
8754        );
8755        let registrations = host.controls.registrations();
8756        assert_eq!(registrations.len(), 1, "{registrations:?}");
8757        assert_eq!(registrations[0].0, StartMode::Boot);
8758        assert!(
8759            host.controls
8760                .calls()
8761                .iter()
8762                .any(|call| call == "uninstall runner-manager (login)"),
8763            "the target must be rolled back: {:?}",
8764            host.controls.calls()
8765        );
8766    }
8767
8768    #[test]
8769    fn switching_to_the_mode_already_in_force_registers_nothing_again() {
8770        let host = Host::new();
8771        let operations = host.operations();
8772        operations
8773            .install(&host.request(StartMode::Boot))
8774            .expect("an install");
8775        let before = host.controls.calls().len();
8776
8777        let change = operations
8778            .set_start_mode(StartMode::Boot)
8779            .expect("a no-op switch");
8780        assert!(!change.changed);
8781        assert_eq!(
8782            host.controls.calls().len(),
8783            before,
8784            "a no-op switch must not touch the service manager"
8785        );
8786    }
8787
8788    #[test]
8789    fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
8790        let host = Host::new();
8791        let error = host
8792            .operations()
8793            .set_start_mode(StartMode::Login)
8794            .expect_err("there is nothing to switch");
8795        assert!(
8796            matches!(error, ServiceError::NotInstalled { .. }),
8797            "{error}"
8798        );
8799    }
8800
8801    // -----------------------------------------------------------------------
8802    // Status
8803    // -----------------------------------------------------------------------
8804
8805    #[test]
8806    fn status_reports_the_four_facts_journey_five_asks_for() {
8807        let host = Host::new();
8808        let operations = host.operations();
8809        operations
8810            .install(&host.request(StartMode::Boot))
8811            .expect("an install");
8812        let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
8813            .expect("a valid timestamp")
8814            .with_timezone(&Utc);
8815        record_github_contact(&host.paths, at).expect("a heartbeat");
8816
8817        let status = operations.status().expect("a status");
8818        assert_eq!(status.start_mode(), Some(StartMode::Boot));
8819        assert_eq!(
8820            status.binary().map(BinaryPath::recorded),
8821            Some(host.binary.as_path())
8822        );
8823        assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
8824        assert_eq!(status.last_github_contact(), Some(at));
8825        assert!(status.is_installed());
8826        assert!(status.is_healthy(), "{status}");
8827
8828        let printed = status.to_string();
8829        for fragment in [
8830            "start mode",
8831            "diagnostic log",
8832            "last GitHub contact",
8833            "binary",
8834        ] {
8835            assert!(printed.contains(fragment), "{printed}");
8836        }
8837    }
8838
8839    /// A record this account may not read is reported, and `status` keeps
8840    /// going.
8841    ///
8842    /// It used to end the command with `Permission denied`, which is exactly
8843    /// what an operator met on a boot-mode host: `sudo service install` wrote
8844    /// the record `0600` and `root`-owned, and `service status` — a command
8845    /// that asks no privilege of anybody — then printed nothing at all about a
8846    /// registration the service manager could describe perfectly well.
8847    ///
8848    /// Ownership cannot be reproduced without privileges; the mode that made it
8849    /// fatal can.
8850    #[cfg(unix)]
8851    #[test]
8852    fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
8853        use std::os::unix::fs::PermissionsExt as _;
8854
8855        // `root` is not subject to the mode bits and would read the record
8856        // regardless, leaving the assertions below testing nothing.
8857        // SAFETY: `geteuid` takes no argument and cannot fail.
8858        if unsafe { libc::geteuid() } == 0 {
8859            return;
8860        }
8861
8862        let host = Host::new();
8863        let operations = host.operations();
8864        operations
8865            .install(&host.request(StartMode::Boot))
8866            .expect("an install");
8867        assert!(
8868            operations.status().expect("a status").is_healthy(),
8869            "the discriminator: healthy before the record is made unreadable"
8870        );
8871
8872        let record = InstallRecord::path(&host.paths);
8873        std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
8874            .expect("the mode is applied");
8875
8876        let status = operations
8877            .status()
8878            .expect("a record this account may not read is reported, not thrown");
8879        assert!(status.is_installed(), "{status}");
8880        assert!(!status.is_healthy(), "{status}");
8881
8882        let printed = status.to_string();
8883        assert!(
8884            printed.contains("this account may not read it"),
8885            "the operator is told which of the two states this is: {printed}"
8886        );
8887        assert!(
8888            !printed.contains("there is no install record"),
8889            "a record that is there and unreadable is not a record that is missing, and the \
8890             missing one's remedy starts with `service uninstall`: {printed}"
8891        );
8892
8893        // Left readable so the fixture's own cleanup is not the thing that
8894        // fails on a host where TempDir removal walks the tree.
8895        std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
8896            .expect("the mode is restored");
8897    }
8898
8899    #[test]
8900    fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
8901        let host = Host::new();
8902        let operations = host.operations();
8903        operations
8904            .install(&host.request(StartMode::Boot))
8905            .expect("an install");
8906
8907        // The discriminator: healthy first, so a `is_healthy` that always
8908        // returned false could not pass this test.
8909        assert!(
8910            operations.status().expect("a status").is_healthy(),
8911            "the freshly installed host must be healthy"
8912        );
8913
8914        std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
8915
8916        let status = operations.status().expect("a status");
8917        assert!(!status.is_healthy(), "{status}");
8918        assert!(
8919            status
8920                .problems()
8921                .iter()
8922                .any(|problem| problem.subject == "binary"),
8923            "{status}"
8924        );
8925        assert!(status.to_string().contains("STALE"), "{status}");
8926    }
8927
8928    #[test]
8929    fn status_reports_a_registration_that_would_not_start_at_boot() {
8930        let host = Host::new();
8931        let operations = host.operations();
8932        operations
8933            .install(&host.request(StartMode::Boot))
8934            .expect("an install");
8935        assert!(operations.status().expect("a status").is_healthy());
8936
8937        host.controls.edit("runner-manager", |registration| {
8938            registration.starts_automatically = false;
8939        });
8940
8941        let status = operations.status().expect("a status");
8942        assert!(!status.is_healthy(), "{status}");
8943        assert!(
8944            status
8945                .problems()
8946                .iter()
8947                .any(|problem| problem.detail.contains("after a reboot")),
8948            "{status}"
8949        );
8950    }
8951
8952    #[test]
8953    fn install_starts_the_registration_and_a_later_stop_is_unhealthy() {
8954        let host = Host::new();
8955        let operations = host.operations();
8956        operations
8957            .install(&host.request(StartMode::Login))
8958            .expect("install and immediate start");
8959
8960        assert!(
8961            operations.status().expect("running status").is_running(),
8962            "install must not wait for the next login trigger"
8963        );
8964        operations.stop().expect("stop the registration");
8965        let status = operations.status().expect("stopped status");
8966        assert!(!status.is_healthy(), "{status}");
8967        assert!(
8968            status
8969                .problems()
8970                .iter()
8971                .any(|problem| problem.subject == "runtime"),
8972            "{status}"
8973        );
8974        let rendered = status.to_string();
8975        assert!(
8976            rendered.contains("runner-manager service install --start-at login"),
8977            "{rendered}"
8978        );
8979        assert!(
8980            !rendered.contains("runner-manager service start"),
8981            "{rendered}"
8982        );
8983    }
8984
8985    #[test]
8986    fn status_reports_a_restart_policy_something_else_edited() {
8987        let host = Host::new();
8988        let operations = host.operations();
8989        operations
8990            .install(&host.request(StartMode::Boot))
8991            .expect("an install");
8992        assert!(operations.status().expect("a status").is_healthy());
8993
8994        host.controls.edit("runner-manager", |registration| {
8995            registration.restart_delay = Some(Duration::from_secs(1));
8996        });
8997
8998        let status = operations.status().expect("a status");
8999        assert!(!status.is_healthy(), "{status}");
9000        assert!(
9001            status
9002                .problems()
9003                .iter()
9004                .any(|problem| problem.subject == "restart policy"),
9005            "{status}"
9006        );
9007    }
9008
9009    #[test]
9010    fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
9011        let host = Host::new();
9012        let operations = host.operations();
9013        operations
9014            .install(&host.request(StartMode::Boot))
9015            .expect("an install");
9016        let other = host.binary.with_file_name("someone-elses.exe");
9017        std::fs::write(&other, b"x").expect("writable");
9018
9019        host.controls.edit("runner-manager", |registration| {
9020            registration.command_line = quote_argument(&other.to_string_lossy());
9021        });
9022
9023        let status = operations.status().expect("a status");
9024        assert!(!status.is_healthy(), "{status}");
9025        assert!(
9026            matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
9027            "{status}"
9028        );
9029    }
9030
9031    #[test]
9032    fn status_reports_a_record_no_service_manager_knows_about() {
9033        let host = Host::new();
9034        let operations = host.operations();
9035        operations
9036            .install(&host.request(StartMode::Boot))
9037            .expect("an install");
9038        // Something removed the registration behind this product's back.
9039        for mode in [StartMode::Boot, StartMode::Login] {
9040            host.controls
9041                .control(mode)
9042                .expect("a control")
9043                .uninstall(&ServiceIdentity::product())
9044                .expect("removed");
9045        }
9046
9047        let status = operations.status().expect("a status");
9048        assert!(!status.is_healthy(), "{status}");
9049        assert!(
9050            status
9051                .problems()
9052                .iter()
9053                .any(|problem| problem.subject == "registration"),
9054            "{status}"
9055        );
9056    }
9057
9058    #[test]
9059    fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
9060        let host = Host::new();
9061        let status = host.operations().status().expect("a status");
9062        assert!(!status.is_installed());
9063        assert!(
9064            status.is_healthy(),
9065            "a host that never installed the service has no fault to report: {status}"
9066        );
9067        assert!(status.to_string().contains("installed"), "{status}");
9068    }
9069
9070    #[test]
9071    fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
9072        let host = Host::new();
9073        let operations = host.operations();
9074        operations
9075            .install(&host.request(StartMode::Login))
9076            .expect("an install at login");
9077        let status = operations.status().expect("a status");
9078        assert!(
9079            status
9080                .notes()
9081                .iter()
9082                .any(|note| note.contains("does not run until the operator signs in")),
9083            "05-infrastructure.md requires `service status` to say so: {status}"
9084        );
9085    }
9086
9087    // -----------------------------------------------------------------------
9088    // Start and stop
9089    // -----------------------------------------------------------------------
9090
9091    #[test]
9092    fn start_and_stop_reach_the_domain_that_holds_the_registration() {
9093        let host = Host::new();
9094        let operations = host.operations();
9095        operations
9096            .install(&host.request(StartMode::Login))
9097            .expect("an install at login");
9098        operations.start().expect("a start");
9099        assert!(operations.status().expect("a status").is_running());
9100        assert!(operations.stop().expect("a stop"));
9101        assert!(!operations.status().expect("a status").is_running());
9102    }
9103
9104    #[test]
9105    fn starting_a_host_with_no_registration_is_refused() {
9106        let host = Host::new();
9107        let error = host.operations().start().expect_err("nothing to start");
9108        assert!(
9109            matches!(error, ServiceError::NotInstalled { .. }),
9110            "{error}"
9111        );
9112    }
9113
9114    // -----------------------------------------------------------------------
9115    // The seam with `d2`
9116    // -----------------------------------------------------------------------
9117
9118    /// Requirement 2 is *"an account that can **read the secret store**"*, and
9119    /// on Windows what an account may read is decided by a DACL in a file this
9120    /// task does not own. Asserting the account name alone would be asserting
9121    /// this module against itself; this reads `d2`'s protection back off a real
9122    /// store and checks that it admits the account this installer registers.
9123    ///
9124    /// No privileges are needed. `d2` documents that a rooted machine-scoped
9125    /// store *"is protected and encrypted exactly as the standard one is"* —
9126    /// the Windows backend picks its DACL from the scope, not from the site —
9127    /// so a store in a temporary directory carries the access control the real
9128    /// one does.
9129    #[cfg(windows)]
9130    #[test]
9131    fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
9132        use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
9133
9134        let root = tempfile::tempdir().expect("a temporary directory");
9135        let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
9136            .expect("a rooted machine-scoped store");
9137        store
9138            .store(&secrecy::SecretString::from("a stand-in for the token"))
9139            .expect("the store accepts a value");
9140        let protection = store.protection().expect("the DACL can be read back");
9141
9142        assert!(
9143            protection.description().contains(";;;SY)"),
9144            "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
9145             read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
9146            protection.description()
9147        );
9148        assert_eq!(
9149            ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
9150            ServiceAccount::LocalSystem,
9151            "and that is the account this installer registers, which is why SY is what matters"
9152        );
9153        assert!(
9154            !protection.readable_by_other_local_users(),
9155            "the same DACL must still exclude ordinary local users: {}",
9156            protection.description()
9157        );
9158
9159        // The other half of the analysis in `docs/service-account.md`, and the
9160        // reason LocalService and NetworkService are not used: the DACL names
9161        // three trustees and neither of them is among them.
9162        for rejected in [";;;LS)", ";;;NS)"] {
9163            assert!(
9164                !protection.description().contains(rejected),
9165                "if the store ever admitted {rejected}, the least-privilege analysis in \
9166                 docs/service-account.md would need redoing: {}",
9167                protection.description()
9168            );
9169        }
9170    }
9171}