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