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/// Present while the daemon cannot read its own stored credential, inside
159/// `state/`.
160///
161/// See [`record_credential_unreadable`] for the contract.
162pub const CREDENTIAL_UNREADABLE_FILE: &str = "credential-unreadable.toml";
163
164/// The `service status` subject a [`CREDENTIAL_UNREADABLE_FILE`] is reported
165/// under, which the TUI matches to offer the same command.
166pub const CREDENTIAL_UNREADABLE_SUBJECT: &str = "stored credential";
167
168/// The `service status` subject blocked runner launches are reported under.
169pub const LAUNCHES_BLOCKED_SUBJECT: &str = "runner launches";
170
171/// The [`StatusProblem::subject`] of a daemon that has stopped making progress.
172pub const DAEMON_STALLED_SUBJECT: &str = "daemon progress";
173
174/// The [`StatusProblem::subject`] of a daemon that refuses to start runners
175/// because a required host check fails from its own session.
176pub const HOST_UNFIT_SUBJECT: &str = "host fitness";
177
178/// The agent's last runner-root refusal, for `service status` to report.
179///
180/// See [`record_runner_root_refusal`] for the contract.
181pub const ROOT_REFUSAL_FILE: &str = "runner-root-refusal.toml";
182
183/// The rotating diagnostic log the **daemon** writes, inside `logs/`.
184///
185/// The appender adds its own date suffix, so this is the stem rather than a
186/// file that exists. `service status` reports the directory and the stem,
187/// because that is what an operator needs in order to find today's file and
188/// yesterday's.
189///
190/// It is [`crate::logging::SERVICE_LOG_STEM`] and not the operator's, because a
191/// boot-mode daemon runs under a different account and writes a different file.
192/// [`crate::logging::LogRole`] says why the two were separated.
193pub const LOG_FILE_STEM: &str = crate::logging::SERVICE_LOG_STEM;
194
195// ---------------------------------------------------------------------------
196// Identity
197// ---------------------------------------------------------------------------
198
199/// What the operating system calls this registration.
200///
201/// A type rather than three constants, for one reason that is not tidiness: the
202/// privileged installer tests register a **real** service on a **real** machine,
203/// and they must not be able to collide with — or remove — an operator's
204/// installation. [`ServiceIdentity::fixture`] produces a name that is
205/// unmistakably a test artefact, and every backend takes its name from here, so
206/// there is no path by which a test reaches the product's own registration.
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct ServiceIdentity {
209    name: String,
210    display_name: String,
211    description: String,
212}
213
214impl ServiceIdentity {
215    /// The registration an operator installs.
216    #[must_use]
217    pub fn product() -> Self {
218        Self {
219            name: SERVICE_NAME.to_string(),
220            display_name: DISPLAY_NAME.to_string(),
221            description: DESCRIPTION.to_string(),
222        }
223    }
224
225    /// A disposable registration for a privileged test, named so that it cannot
226    /// be mistaken for — or collide with — [`ServiceIdentity::product`].
227    ///
228    /// `tag` distinguishes concurrent runs; it is reduced to ASCII alphanumerics
229    /// and `-` so that it is a legal service name, launchd label, systemd unit
230    /// name, and Task Scheduler task name at once.
231    #[must_use]
232    pub fn fixture(tag: &str) -> Self {
233        let tag: String = tag
234            .chars()
235            .map(|c| {
236                if c.is_ascii_alphanumeric() {
237                    c.to_ascii_lowercase()
238                } else {
239                    '-'
240                }
241            })
242            .collect();
243        let name = format!("{SERVICE_NAME}-selftest-{tag}");
244        Self {
245            display_name: format!("{DISPLAY_NAME} (self-test fixture {tag})"),
246            description: "Disposable fixture created by runner-manager's own installer tests. \
247                          Safe to remove."
248                .to_string(),
249            name,
250        }
251    }
252
253    /// Whether this identity is a disposable test fixture.
254    ///
255    /// The privileged tests assert this before they delete anything, which is
256    /// the mechanical half of *"never touch a service you did not create"*.
257    #[must_use]
258    pub fn is_fixture(&self) -> bool {
259        self.name.starts_with(&format!("{SERVICE_NAME}-selftest-"))
260    }
261
262    /// The Windows service name, the systemd unit stem, and the Task Scheduler
263    /// task name.
264    #[must_use]
265    pub fn name(&self) -> &str {
266        &self.name
267    }
268
269    /// The human-readable name.
270    #[must_use]
271    pub fn display_name(&self) -> &str {
272        &self.display_name
273    }
274
275    /// One line of explanation.
276    #[must_use]
277    pub fn description(&self) -> &str {
278        &self.description
279    }
280
281    /// The launchd label — reverse-domain, as launchd expects.
282    ///
283    /// Built from the same three product segments [`crate::paths`] resolves the
284    /// application-data directories with, so a label and a directory cannot
285    /// drift apart.
286    #[must_use]
287    pub fn launchd_label(&self) -> String {
288        format!(
289            "{}.{}.{}",
290            crate::paths::QUALIFIER,
291            crate::paths::ORGANIZATION,
292            self.name
293        )
294    }
295
296    /// The systemd unit file name.
297    #[must_use]
298    pub fn systemd_unit(&self) -> String {
299        format!("{}.service", self.name)
300    }
301}
302
303impl fmt::Display for ServiceIdentity {
304    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
305        f.write_str(&self.name)
306    }
307}
308
309// ---------------------------------------------------------------------------
310// Restart policy
311// ---------------------------------------------------------------------------
312
313/// The restart-on-failure policy, and the bound on how fast it may retry.
314///
315/// `05-infrastructure.md` item 3: *"set a restart-on-failure policy with bounded
316/// delay"*. Both halves matter and they are different requirements. A service
317/// that does not come back after a crash defeats Journey 5; a service that comes
318/// back instantly, forever, turns one bad configuration into a fork bomb against
319/// GitHub's rate limit. So the delay has a floor as well as a ceiling, and the
320/// floor is what `does not restart-loop faster than that bound` is measured
321/// against.
322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323pub struct RestartPolicy {
324    delay: Duration,
325    reset_after: Duration,
326}
327
328impl RestartPolicy {
329    /// The shortest delay this will accept.
330    ///
331    /// One second is not a tuning preference. Below it, launchd's own throttle
332    /// and systemd's start-limit logic both take over and the configured number
333    /// stops being the number in force, so a policy under this floor would be a
334    /// value `service status` reported and the platform ignored.
335    pub const MIN_DELAY: Duration = Duration::from_secs(1);
336
337    /// The longest delay this will accept. Past five minutes an operator
338    /// watching a restart would reasonably conclude the service is simply gone.
339    pub const MAX_DELAY: Duration = Duration::from_secs(300);
340
341    /// What `service install` uses when nothing is said.
342    pub const DEFAULT_DELAY: Duration = Duration::from_secs(15);
343
344    /// How long the service must stay up before its failure count is forgotten.
345    pub const DEFAULT_RESET_AFTER: Duration = Duration::from_secs(600);
346
347    /// # Errors
348    ///
349    /// [`ServiceError::RestartDelay`] when `delay` is outside
350    /// [`RestartPolicy::MIN_DELAY`]..=[`RestartPolicy::MAX_DELAY`], or when
351    /// `reset_after` is not longer than `delay` — a reset window shorter than
352    /// the delay can never elapse between two restarts, so the failure count
353    /// would reset on every attempt and no start-limit could ever trip.
354    pub fn new(delay: Duration, reset_after: Duration) -> Result<Self, ServiceError> {
355        if delay < Self::MIN_DELAY || delay > Self::MAX_DELAY {
356            return Err(ServiceError::RestartDelay {
357                requested_secs: delay.as_secs(),
358                min_secs: Self::MIN_DELAY.as_secs(),
359                max_secs: Self::MAX_DELAY.as_secs(),
360            });
361        }
362        if reset_after <= delay {
363            return Err(ServiceError::RestartResetWindow {
364                reset_secs: reset_after.as_secs(),
365                delay_secs: delay.as_secs(),
366            });
367        }
368        Ok(Self { delay, reset_after })
369    }
370
371    /// How long the service manager waits before restarting a failed service.
372    #[must_use]
373    pub const fn delay(&self) -> Duration {
374        self.delay
375    }
376
377    /// How long the service must run before its failure count resets.
378    #[must_use]
379    pub const fn reset_after(&self) -> Duration {
380        self.reset_after
381    }
382
383    /// The delay a given manager can actually express.
384    ///
385    /// Three of the four take seconds and enforce exactly what they are given.
386    /// **Windows Task Scheduler does not**: `RestartOnFailure/Interval` is
387    /// expressed in whole minutes with a one-minute floor, and it *rejects* the
388    /// registration outright rather than rounding — `PT15S` comes back as
389    /// "The task XML contains a value which is incorrectly formatted or out of
390    /// range", which is how this was found.
391    ///
392    /// So the interval is rounded **up**, never down, and never below one
393    /// minute. The direction is the point: the requirement is that the service
394    /// *"does not restart-loop faster than that bound"*, and a delay longer
395    /// than the configured one still satisfies it, while a shorter one would
396    /// not. `service status` reports the difference as a note so that an
397    /// operator reading `15s` in the record and `60s` from the manager is told
398    /// why rather than left to wonder.
399    #[must_use]
400    pub const fn effective_delay(&self, kind: DefinitionKind) -> Duration {
401        match kind {
402            DefinitionKind::WindowsScheduledTask => {
403                let seconds = self.delay.as_secs();
404                let minutes = seconds.div_ceil(60);
405                Duration::from_secs(if minutes == 0 { 60 } else { minutes * 60 })
406            }
407            _ => self.delay,
408        }
409    }
410}
411
412impl Default for RestartPolicy {
413    fn default() -> Self {
414        Self {
415            delay: Self::DEFAULT_DELAY,
416            reset_after: Self::DEFAULT_RESET_AFTER,
417        }
418    }
419}
420
421impl fmt::Display for RestartPolicy {
422    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
423        write!(
424            f,
425            "restart after {}s, failure count resets after {}s",
426            self.delay.as_secs(),
427            self.reset_after.as_secs()
428        )
429    }
430}
431
432// ---------------------------------------------------------------------------
433// The account
434// ---------------------------------------------------------------------------
435
436/// The account a registration runs under.
437///
438/// Not an operator choice. It is a function of the start mode and the platform,
439/// because the start mode decides which secret store the daemon must read and
440/// the store's own access control decides which accounts can read it. See
441/// `docs/service-account.md`, and [`review_least_privilege`] for the check.
442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
443#[serde(rename_all = "snake_case")]
444pub enum ServiceAccount {
445    /// `NT AUTHORITY\SYSTEM`. The only stock Windows account named by the
446    /// machine-scoped store's DACL.
447    LocalSystem,
448    /// `root`. What a LaunchDaemon and a systemd system unit run as, and what
449    /// the macOS System Keychain's root-only master key requires.
450    Root,
451    /// The operator's own account, for a login-mode registration.
452    InvokingUser,
453}
454
455impl ServiceAccount {
456    /// The account a given kind of definition obliges.
457    ///
458    /// Keyed on the definition rather than on `cfg!(windows)`, and the
459    /// difference is not academic: a Windows developer rendering the launchd
460    /// property list would otherwise write `UserName = NT AUTHORITY\SYSTEM`
461    /// into it, which is not an account macOS has. A definition's account is a
462    /// property of the definition, and the whole point of rendering being pure
463    /// is that any host can render any platform's and get the right answer.
464    #[must_use]
465    pub const fn for_definition(kind: DefinitionKind, mode: StartMode) -> Self {
466        match (kind, mode) {
467            (DefinitionKind::WindowsService, _) => Self::LocalSystem,
468            (DefinitionKind::WindowsScheduledTask, _) | (_, StartMode::Login) => Self::InvokingUser,
469            (_, StartMode::Boot) => Self::Root,
470        }
471    }
472
473    /// The account the given start mode obliges on the platform this binary was
474    /// built for.
475    #[must_use]
476    pub const fn for_start_mode(mode: StartMode) -> Self {
477        Self::for_definition(host_definition_kind(mode), mode)
478    }
479
480    /// How the platform spells it.
481    #[must_use]
482    pub const fn as_str(&self) -> &'static str {
483        match self {
484            Self::LocalSystem => "NT AUTHORITY\\SYSTEM",
485            Self::Root => "root",
486            Self::InvokingUser => "the invoking user",
487        }
488    }
489
490    /// Why this is the *minimum* account that can do the job, not merely the
491    /// convenient one.
492    ///
493    /// Printed by `service status` and by [`review_least_privilege`], because a
494    /// privileged account with no stated reason is indistinguishable from a
495    /// privileged account nobody thought about.
496    #[must_use]
497    pub const fn justification(&self) -> &'static str {
498        match self {
499            Self::LocalSystem => {
500                "the machine-scoped store's DACL names SY, BA and OW only; LocalService and \
501                 NetworkService cannot read it, and widening the DACL to reach them would grant \
502                 every service on this host read access to the one credential this product holds"
503            }
504            Self::Root => {
505                "a boot-time registration runs outside every login session: on macOS the System \
506                 Keychain is unlocked by /var/db/SystemKey, which is root-only, and on Linux the \
507                 machine-scoped store is a 0600 file under /var/lib that only root can open \
508                 before a session exists"
509            }
510            Self::InvokingUser => {
511                "a login-mode registration reads the user-scoped store, which is deliberately \
512                 readable by exactly one account and needs no elevation at all"
513            }
514        }
515    }
516
517    /// Whether registering under this account needs administrative rights.
518    #[must_use]
519    pub const fn needs_elevation(&self) -> bool {
520        matches!(self, Self::LocalSystem | Self::Root)
521    }
522}
523
524impl fmt::Display for ServiceAccount {
525    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
526        f.write_str(self.as_str())
527    }
528}
529
530// ---------------------------------------------------------------------------
531// Errors
532// ---------------------------------------------------------------------------
533
534/// Something went wrong installing, inspecting, or removing the registration.
535///
536/// Every variant is formatted straight into an operator-facing message, so each
537/// one says what to do next rather than only what failed. No variant carries a
538/// secret: this module never reads the token, only the *location* `d2` publishes
539/// for exactly this purpose.
540#[derive(Debug, thiserror::Error)]
541pub enum ServiceError {
542    /// The single-instance lock is held, so a second agent must not be
543    /// registered.
544    ///
545    /// `05-infrastructure.md` item 1. The message is `d1`'s, verbatim, because
546    /// `d1` already worked out what to tell an operator who lost that race.
547    #[error("cannot install the service while an agent is already running on this host: {source}")]
548    LockHeld {
549        /// What `d1` said, including who holds it.
550        #[source]
551        source: Box<LockError>,
552    },
553
554    /// The lock could not be inspected at all, which is not the same as it being
555    /// free.
556    #[error(
557        "cannot tell whether an agent is already running on this host, so the service was not \
558         installed: {source}. Fix the reported problem with the state directory and try again."
559    )]
560    LockUnreadable {
561        /// The underlying failure.
562        #[source]
563        source: Box<LockError>,
564    },
565
566    /// The runner root this registration would run jobs under could not be
567    /// created, inspected, or given the access the registration's account needs.
568    ///
569    /// Reported *before* anything is registered. A registration whose workspaces
570    /// would land in a directory ordinary local users can write is a
571    /// registration that should not exist, and `04-security-recovery.md` asks
572    /// for exactly that refusal rather than a warning.
573    #[error(
574        "this registration would run jobs under the default runner root, and that root could \
575         not be prepared, so nothing was registered: {source}"
576    )]
577    RunnerRoot {
578        /// What [`crate::runner_root_access`] reported, including the remedy.
579        #[source]
580        source: Box<RootAccessError>,
581    },
582
583    /// The path of the running binary could not be resolved.
584    #[error(
585        "cannot resolve the absolute path of this executable, so there is nothing to register: \
586         {detail}. Run the installer from the installed binary rather than through a shell \
587         function or a wrapper that replaces argv[0]."
588    )]
589    BinaryPath {
590        /// What the platform reported.
591        detail: String,
592    },
593
594    /// The binary named for the registration is not there.
595    #[error(
596        "{} is not a file, so registering it would create a service that cannot start. Install \
597         the product first, then run `service install` from the installed binary.",
598        path.display()
599    )]
600    BinaryMissing {
601        /// The path that was to be registered.
602        path: PathBuf,
603    },
604
605    /// The requested restart delay is outside the bound.
606    #[error(
607        "a restart delay of {requested_secs}s is outside the supported range \
608         {min_secs}s-{max_secs}s. Below the floor the platform's own throttle overrides the \
609         value, so `service status` would report a delay that is not the one in force."
610    )]
611    RestartDelay {
612        /// What was asked for.
613        requested_secs: u64,
614        /// The floor.
615        min_secs: u64,
616        /// The ceiling.
617        max_secs: u64,
618    },
619
620    /// The failure-count reset window is not longer than the restart delay.
621    #[error(
622        "a failure-count reset window of {reset_secs}s is not longer than the {delay_secs}s \
623         restart delay, so the count would reset between every pair of restarts and no \
624         start limit could ever apply."
625    )]
626    RestartResetWindow {
627        /// What was asked for.
628        reset_secs: u64,
629        /// The delay it has to exceed.
630        delay_secs: u64,
631    },
632
633    /// A registration is already there, in the **other** start mode.
634    ///
635    /// Only the other one. A registration in the mode being asked for is
636    /// replaced rather than refused — see [`Operations::install`], and the
637    /// outage that taught us why.
638    #[error(
639        "{name} is already registered to start at {existing}, and this asks for {requested}. \
640         Changing the start mode moves the registration between two different service \
641         managers, so it is not something an install does by itself: run `service uninstall` \
642         first, or change the start mode in the terminal UI (`runner-manager tui`), which \
643         switches it in place and keeps the registration running throughout."
644    )]
645    AlreadyInstalled {
646        /// Which registration.
647        name: String,
648        /// The mode it currently carries.
649        existing: StartMode,
650        /// The mode this install asked for.
651        requested: StartMode,
652    },
653
654    /// No registration is there.
655    #[error("{name} is not registered on this host, so there is nothing to {operation}.")]
656    NotInstalled {
657        /// Which registration.
658        name: String,
659        /// What was being attempted.
660        operation: &'static str,
661    },
662
663    /// The record `install` wrote could not be read or written.
664    #[error("cannot {operation} the service record {}: {detail}", path.display())]
665    Record {
666        /// Read, write, or remove.
667        operation: &'static str,
668        /// The record file.
669        path: PathBuf,
670        /// The underlying failure.
671        detail: String,
672    },
673
674    /// The record is there but is not one this version wrote.
675    #[error(
676        "the service record {} was not written by this product, or was written by a version \
677         this one cannot read: {detail}. Run `service uninstall` and `service install` again; \
678         neither touches configuration, secrets, or the cache.",
679        path.display()
680    )]
681    RecordUnreadable {
682        /// The record file.
683        path: PathBuf,
684        /// What was wrong with it.
685        detail: String,
686    },
687
688    /// The record is there and **this account** may not read it.
689    ///
690    /// Its own variant rather than a [`ServiceError::Record`] with a
691    /// `Permission denied` in the detail, because it is the one record failure
692    /// that is not a fault in the record: `service status` reports it and
693    /// carries on, where every other read failure ends the command. See
694    /// [`Operations::status`].
695    ///
696    /// A version before this one wrote the record at `0600` through a temporary
697    /// file, so a boot-mode `sudo service install` left it owned by `root` and
698    /// unreadable by the operator whose profile it is in. `service status` then
699    /// failed on their own host, with a remedy naming the command that had just
700    /// failed.
701    #[error(
702        "the service record {} is there and this account may not read it: {detail}. It was \
703         written by whichever account installed the service -- on a boot-mode host, `sudo \
704         service install`. Running `service install` again rewrites it readable; until then \
705         only what the service manager itself reports is available.",
706        path.display()
707    )]
708    RecordNotPermitted {
709        /// The record file.
710        path: PathBuf,
711        /// What the operating system said.
712        detail: String,
713    },
714
715    /// The application-data directories could not be resolved or created.
716    #[error("cannot prepare this host's application-data directories: {source}")]
717    Paths {
718        /// The underlying failure.
719        #[source]
720        source: Box<crate::paths::PathsError>,
721    },
722
723    /// The platform's service manager refused, or could not be reached.
724    #[error("cannot {operation} {name} through {manager}: {detail}")]
725    Control {
726        /// What was being attempted.
727        operation: &'static str,
728        /// Which registration.
729        name: String,
730        /// Which service manager.
731        manager: &'static str,
732        /// What it said.
733        detail: String,
734    },
735
736    /// An operation failed and its compensating action failed too.
737    #[error(
738        "cannot {operation} {name}: {cause}. The attempted rollback also failed: {rollback}. \
739         Inspect `service status` before retrying."
740    )]
741    Rollback {
742        /// The transaction that could not be completed.
743        operation: &'static str,
744        /// Which registration was involved.
745        name: String,
746        /// The original failure.
747        cause: String,
748        /// The failure while restoring the previous state.
749        rollback: String,
750    },
751
752    /// The operation needs administrative rights it does not have.
753    #[error("{operation} {name} needs administrative rights: {detail}. {remedy}")]
754    NeedsElevation {
755        /// What was being attempted.
756        operation: &'static str,
757        /// Which registration.
758        name: String,
759        /// What the platform said.
760        detail: String,
761        /// How to get them, in this platform's own terms.
762        remedy: &'static str,
763    },
764}
765
766// ---------------------------------------------------------------------------
767// Directories
768// ---------------------------------------------------------------------------
769
770/// The four directories the registration was installed against.
771///
772/// Recorded rather than re-derived, because `05-infrastructure.md` item 2 speaks
773/// of the *configured* cache and runtime directories: the account the service
774/// runs under and the account that ran `service install` do not always resolve
775/// the same platform-standard locations, and a claim about what the service can
776/// write is only checkable against a specific set of paths.
777#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
778pub struct ServiceDirectories {
779    /// Non-secret TOML and the SQLite database.
780    pub config: PathBuf,
781    /// The agent lock, the attempt journal, and the runner package cache.
782    pub state: PathBuf,
783    /// Per-attempt disposable runner workspaces.
784    pub runtime: PathBuf,
785    /// Rotating redacted diagnostics.
786    pub logs: PathBuf,
787}
788
789impl ServiceDirectories {
790    /// Snapshots the four directories a resolved [`AppPaths`] names.
791    #[must_use]
792    pub fn of(paths: &AppPaths) -> Self {
793        Self {
794            config: paths.config_dir().to_path_buf(),
795            state: paths.state_dir().to_path_buf(),
796            runtime: paths.runtime_dir().to_path_buf(),
797            logs: paths.logs_dir().to_path_buf(),
798        }
799    }
800
801    /// The four, in the order `05-infrastructure.md` lists them.
802    #[must_use]
803    pub fn all(&self) -> [&Path; 4] {
804        [
805            self.config.as_path(),
806            self.state.as_path(),
807            self.runtime.as_path(),
808            self.logs.as_path(),
809        ]
810    }
811
812    /// The rotating diagnostic log's stem, which is what `service status`
813    /// reports and what `05-infrastructure.md` item 4 requires be preserved.
814    #[must_use]
815    pub fn log_file(&self) -> PathBuf {
816        self.logs.join(LOG_FILE_STEM)
817    }
818}
819
820// ---------------------------------------------------------------------------
821// The install plan
822// ---------------------------------------------------------------------------
823
824/// What `service install` was asked for, before anything has been resolved.
825///
826/// Separate from [`InstallPlan`] because a request is the operator's words and
827/// a plan is what the host actually supports: the plan carries a *resolved
828/// absolute* binary path, the account the start mode obliges, and the four
829/// directories the registration is installed against, none of which the caller
830/// supplies.
831#[derive(Debug, Clone, PartialEq, Eq)]
832pub struct InstallRequest {
833    start_mode: StartMode,
834    binary: Option<PathBuf>,
835    source_binary: Option<PathBuf>,
836    arguments: Vec<OsString>,
837    restart: RestartPolicy,
838    on_demand: bool,
839}
840
841impl InstallRequest {
842    /// Register the binary that is running this call.
843    #[must_use]
844    pub fn new(start_mode: StartMode) -> Self {
845        Self {
846            start_mode,
847            binary: None,
848            source_binary: None,
849            arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
850            restart: RestartPolicy::default(),
851            on_demand: false,
852        }
853    }
854
855    /// Registers the service but does not ask the manager to start it by
856    /// itself.
857    ///
858    /// Production never uses this: a boot-mode registration that does not start
859    /// at boot is the failure `service status` reports. **The privileged
860    /// installer tests use it for every fixture they create**, so that a
861    /// registration which somehow escaped its cleanup guard cannot start with
862    /// the owner's machine on the next reboot. A leaked service is bad; a
863    /// leaked service that runs is worse, and the difference costs one flag.
864    #[must_use]
865    pub const fn started_on_demand(mut self) -> Self {
866        self.on_demand = true;
867        self
868    }
869
870    /// Register a named binary instead of the running one.
871    ///
872    /// The privileged installer tests use this to register a fixture service
873    /// host. Production does not: `05-infrastructure.md` item 6 is specifically
874    /// about the path of *the running binary*, and letting an operator name a
875    /// different one would defeat the stale-path detection it asks for.
876    #[must_use]
877    pub fn for_binary(mut self, binary: impl Into<PathBuf>) -> Self {
878        self.binary = Some(binary.into());
879        self
880    }
881
882    /// Where the registered binary was copied from.
883    ///
884    /// # Why a registration has a source at all
885    ///
886    /// A service registered directly against a package manager's own file can
887    /// never be upgraded on Windows, because the running service holds that
888    /// file open and `npm i -g` cannot replace it. What it does instead is
889    /// worse than failing: it rewrites the package metadata, reports success,
890    /// and leaves the old executable in place — so the operator is told the new
891    /// version is installed while the old one keeps running. That was observed,
892    /// twice in a row, before this existed.
893    ///
894    /// So the service runs a copy the product owns, and this records where that
895    /// copy came from. The source is what a package manager updates, what
896    /// [`inspect_binary`] watches for item 6's stale-path case, and what the
897    /// daemon compares its own version against to know an upgrade is waiting.
898    #[must_use]
899    pub fn copied_from(mut self, source: impl Into<PathBuf>) -> Self {
900        self.source_binary = Some(source.into());
901        self
902    }
903
904    /// Replace the registered arguments. Defaults to [`DAEMON_ARGUMENTS`].
905    #[must_use]
906    pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
907    where
908        I: IntoIterator<Item = S>,
909        S: Into<OsString>,
910    {
911        self.arguments = arguments.into_iter().map(Into::into).collect();
912        self
913    }
914
915    /// Replace the restart-on-failure policy. Defaults to
916    /// [`RestartPolicy::default`].
917    #[must_use]
918    pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
919        self.restart = restart;
920        self
921    }
922
923    /// The start mode asked for.
924    #[must_use]
925    pub const fn start_mode(&self) -> StartMode {
926        self.start_mode
927    }
928}
929
930/// Everything a backend needs, resolved and validated.
931///
932/// Constructing one is where `05-infrastructure.md` item 6 is actually
933/// satisfied: the binary path is made absolute and is confirmed to be a file
934/// *before* anything is registered, so a registration that would fail at boot
935/// is refused at install time instead.
936#[derive(Debug, Clone, PartialEq, Eq)]
937pub struct InstallPlan {
938    identity: ServiceIdentity,
939    start_mode: StartMode,
940    binary: PathBuf,
941    source_binary: Option<PathBuf>,
942    arguments: Vec<OsString>,
943    account: ServiceAccount,
944    restart: RestartPolicy,
945    directories: ServiceDirectories,
946    secret_guard: Option<PathBuf>,
947    on_demand: bool,
948}
949
950impl InstallPlan {
951    /// Resolves a request against this host.
952    ///
953    /// # Errors
954    ///
955    /// [`ServiceError::BinaryPath`] when the running executable cannot be
956    /// located, and [`ServiceError::BinaryMissing`] when the path that would be
957    /// registered is not a file.
958    pub fn resolve(
959        identity: ServiceIdentity,
960        request: &InstallRequest,
961        directories: ServiceDirectories,
962    ) -> Result<Self, ServiceError> {
963        let binary = match &request.binary {
964            Some(named) => absolute(named)?,
965            None => running_executable()?,
966        };
967        if !binary.is_file() {
968            return Err(ServiceError::BinaryMissing { path: binary });
969        }
970        // `d2` publishes the guard file the store lives in so the systemd
971        // sandbox can admit only its parent for atomic token rotation. A store
972        // that cannot be resolved is not fatal here; it merely cannot receive
973        // that narrow writable-path exception.
974        let secret_guard = crate::secrets::PlatformSecretStore::for_start_mode(request.start_mode)
975            .ok()
976            .map(|store| store.guard());
977        Ok(Self {
978            identity,
979            start_mode: request.start_mode,
980            binary,
981            source_binary: request.source_binary.clone(),
982            arguments: request.arguments.clone(),
983            account: ServiceAccount::for_start_mode(request.start_mode),
984            restart: request.restart,
985            directories,
986            secret_guard,
987            on_demand: request.on_demand,
988        })
989    }
990
991    /// Builds a plan from values a caller already holds, without touching the
992    /// filesystem.
993    ///
994    /// This is what makes the renderers testable on a host that has none of the
995    /// paths involved: a Windows developer can render the systemd unit for
996    /// `/opt/runner-manager/bin/runner-manager` and assert every line of it.
997    #[must_use]
998    pub fn unchecked(
999        identity: ServiceIdentity,
1000        start_mode: StartMode,
1001        binary: impl Into<PathBuf>,
1002        directories: ServiceDirectories,
1003    ) -> Self {
1004        Self {
1005            identity,
1006            start_mode,
1007            binary: binary.into(),
1008            source_binary: None,
1009            arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
1010            account: ServiceAccount::for_start_mode(start_mode),
1011            restart: RestartPolicy::default(),
1012            directories,
1013            secret_guard: None,
1014            on_demand: false,
1015        }
1016    }
1017
1018    /// Registers without arming the manager's automatic start. See
1019    /// [`InstallRequest::started_on_demand`].
1020    #[must_use]
1021    pub const fn started_on_demand(mut self) -> Self {
1022        self.on_demand = true;
1023        self
1024    }
1025
1026    /// Whether the manager was asked not to start this by itself.
1027    #[must_use]
1028    pub const fn is_on_demand(&self) -> bool {
1029        self.on_demand
1030    }
1031
1032    /// Names the file the secret store lives in, so systemd can permit atomic
1033    /// replacement of it under `ProtectSystem=strict`.
1034    ///
1035    /// [`InstallPlan::resolve`] fills this from `d2`; a test names it directly
1036    /// so that a Windows or macOS CI leg can assert the Linux unit's writable
1037    /// path for a store that platform does not have.
1038    #[must_use]
1039    pub fn with_secret_guard(mut self, guard: impl Into<PathBuf>) -> Self {
1040        self.secret_guard = Some(guard.into());
1041        self
1042    }
1043
1044    /// The file the machine-scoped secret store lives in, when one resolved.
1045    #[must_use]
1046    pub fn secret_guard(&self) -> Option<&Path> {
1047        self.secret_guard.as_deref()
1048    }
1049
1050    /// Overrides the restart policy on an already-built plan.
1051    #[must_use]
1052    pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1053        self.restart = restart;
1054        self
1055    }
1056
1057    /// Overrides the registered arguments on an already-built plan.
1058    #[must_use]
1059    pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1060    where
1061        I: IntoIterator<Item = S>,
1062        S: Into<OsString>,
1063    {
1064        self.arguments = arguments.into_iter().map(Into::into).collect();
1065        self
1066    }
1067
1068    /// What the operating system calls this registration.
1069    #[must_use]
1070    pub const fn identity(&self) -> &ServiceIdentity {
1071        &self.identity
1072    }
1073
1074    /// Boot or login.
1075    #[must_use]
1076    pub const fn start_mode(&self) -> StartMode {
1077        self.start_mode
1078    }
1079
1080    /// The resolved absolute path of the binary to register.
1081    #[must_use]
1082    pub fn binary(&self) -> &Path {
1083        &self.binary
1084    }
1085
1086    /// Where [`Self::binary`] was copied from, when it is a copy.
1087    ///
1088    /// `None` is the legacy layout: the registration names the file a package
1089    /// manager owns, and cannot be upgraded while it runs. See
1090    /// [`InstallRequest::copied_from`].
1091    #[must_use]
1092    pub fn source_binary(&self) -> Option<&Path> {
1093        self.source_binary.as_deref()
1094    }
1095
1096    /// The arguments it is registered with.
1097    #[must_use]
1098    pub fn arguments(&self) -> &[OsString] {
1099        &self.arguments
1100    }
1101
1102    /// The account it runs under.
1103    #[must_use]
1104    pub const fn account(&self) -> &ServiceAccount {
1105        &self.account
1106    }
1107
1108    /// The restart-on-failure policy.
1109    #[must_use]
1110    pub const fn restart(&self) -> RestartPolicy {
1111        self.restart
1112    }
1113
1114    /// The four directories it was installed against.
1115    #[must_use]
1116    pub const fn directories(&self) -> &ServiceDirectories {
1117        &self.directories
1118    }
1119
1120    /// The command line as one string, quoted the way each platform expects.
1121    #[must_use]
1122    pub fn command_line(&self) -> String {
1123        let mut out = quote_argument(&self.binary.to_string_lossy());
1124        for argument in &self.arguments {
1125            out.push(' ');
1126            out.push_str(&quote_argument(&argument.to_string_lossy()));
1127        }
1128        out
1129    }
1130}
1131
1132/// Undoes a runner-root change, and says what could not be undone.
1133///
1134/// `None` when the rollback was complete — either because there was nothing to
1135/// undo, or because the directory this operation created was removed again and
1136/// the descriptor it replaced was written back. `Some` is the "report any
1137/// non-reversible existing directory state explicitly" half of the requirement,
1138/// and the caller folds it into the failure it was already reporting.
1139fn retained_runner_root(change: &RootAccessChange) -> Option<String> {
1140    let reversal = change.revert();
1141    matches!(reversal, Reversal::Retained { .. }).then(|| reversal.to_string())
1142}
1143
1144/// The error a failed step returns once both of its rollbacks have been
1145/// attempted.
1146///
1147/// `retained` is [`retained_runner_root`]'s answer and `rollback` is what
1148/// undoing the *registration* reported — whatever it reports on success, which
1149/// a failure has no use for. Both arrive already evaluated, because each may be
1150/// attempted exactly once and the order is the caller's to choose.
1151///
1152/// A complete rollback leaves `cause` exactly as it was: the operator's problem
1153/// is what failed, not the tidying up afterwards. An incomplete one is promoted
1154/// to [`ServiceError::Rollback`], which is the variant that exists to say "this
1155/// failed *and* something is left behind" — and it names everything that did.
1156fn rolled_back<T>(
1157    retained: Option<String>,
1158    rollback: Result<T, ServiceError>,
1159    operation: &'static str,
1160    identity: &ServiceIdentity,
1161    cause: ServiceError,
1162) -> ServiceError {
1163    let left_behind = match (rollback.err(), retained) {
1164        (Some(rollback), Some(retained)) => Some(format!("{rollback}; {retained}")),
1165        (Some(rollback), None) => Some(rollback.to_string()),
1166        (None, retained) => retained,
1167    };
1168    match left_behind {
1169        Some(rollback) => ServiceError::Rollback {
1170            operation,
1171            name: identity.name().to_string(),
1172            cause: cause.to_string(),
1173            rollback,
1174        },
1175        None => cause,
1176    }
1177}
1178
1179/// [`rolled_back`] for a failure with nothing registered yet, where the runner
1180/// root is the only thing there is to undo.
1181fn undo_runner_root(
1182    change: &RootAccessChange,
1183    operation: &'static str,
1184    identity: &ServiceIdentity,
1185    cause: ServiceError,
1186) -> ServiceError {
1187    rolled_back(
1188        retained_runner_root(change),
1189        Ok(()),
1190        operation,
1191        identity,
1192        cause,
1193    )
1194}
1195
1196/// Makes a path absolute without resolving symlinks.
1197///
1198/// Symbolic links are deliberately left alone. A Homebrew installation puts a
1199/// stable link at `/opt/homebrew/bin/runner-manager` and moves the file it
1200/// points at on every upgrade; recording the link's target would make an
1201/// ordinary `brew upgrade` look exactly like the npm failure this module exists
1202/// to detect, and recording the link records the thing the operator installed.
1203///
1204/// Linux is the one platform where this is not fully in the caller's hands:
1205/// `std::env::current_exe` reads `/proc/self/exe`, which the kernel has already
1206/// resolved. There the recorded path is the real file rather than the shim —
1207/// which is still correct for the npm case, because a real file under a Node
1208/// prefix disappears with the prefix.
1209fn absolute(path: &Path) -> Result<PathBuf, ServiceError> {
1210    std::path::absolute(path).map_err(|error| ServiceError::BinaryPath {
1211        detail: format!("{} could not be made absolute: {error}", path.display()),
1212    })
1213}
1214
1215/// The absolute path of the executable running this call.
1216fn running_executable() -> Result<PathBuf, ServiceError> {
1217    let raw = std::env::current_exe().map_err(|error| ServiceError::BinaryPath {
1218        detail: error.to_string(),
1219    })?;
1220    absolute(&raw)
1221}
1222
1223/// Quotes one command-line argument when it needs it.
1224///
1225/// The rule is Windows', because Windows is the platform where a command line is
1226/// a single string the callee re-splits, and because `windows-service` applies
1227/// exactly this rule when it builds `lpBinaryPathName`. The Unix backends embed
1228/// arguments in a plist array and in a systemd `ExecStart=`, both of which
1229/// accept the same quoting, so one rule serves all three rather than three
1230/// nearly-identical ones.
1231pub(crate) fn quote_argument(argument: &str) -> String {
1232    if !argument.is_empty() && !argument.contains([' ', '"', '\t', '\n']) {
1233        return argument.to_string();
1234    }
1235    let mut out = String::with_capacity(argument.len() + 2);
1236    out.push('"');
1237    let mut backslashes = 0usize;
1238    for c in argument.chars() {
1239        match c {
1240            '\\' => {
1241                backslashes += 1;
1242                out.push('\\');
1243            }
1244            '"' => {
1245                // Every backslash immediately before a quote must be doubled,
1246                // and the quote itself escaped.
1247                for _ in 0..=backslashes {
1248                    out.push('\\');
1249                }
1250                out.push('"');
1251                backslashes = 0;
1252            }
1253            other => {
1254                backslashes = 0;
1255                out.push(other);
1256            }
1257        }
1258    }
1259    // Trailing backslashes would otherwise escape the closing quote.
1260    for _ in 0..backslashes {
1261        out.push('\\');
1262    }
1263    out.push('"');
1264    out
1265}
1266
1267/// Reads the executable back out of a command line quoted by
1268/// [`quote_argument`].
1269///
1270/// `service status` needs this because the Windows Service Control Manager
1271/// stores the binary and its arguments as one string and hands the whole thing
1272/// back from `QueryServiceConfigW`. Comparing a record against a registration
1273/// means splitting that string the same way Windows itself does.
1274///
1275/// Returns `None` for an empty or whitespace-only command line, which is the
1276/// only input with no first argument to find.
1277#[must_use]
1278pub fn executable_from_command_line(command_line: &str) -> Option<PathBuf> {
1279    let trimmed = command_line.trim_start();
1280    if trimmed.is_empty() {
1281        return None;
1282    }
1283    let mut out = String::new();
1284    let mut chars = trimmed.chars().peekable();
1285    let quoted = chars.peek() == Some(&'"');
1286    if quoted {
1287        chars.next();
1288        let mut backslashes = 0usize;
1289        for c in chars {
1290            match c {
1291                '\\' => {
1292                    backslashes += 1;
1293                }
1294                '"' => {
1295                    // `2n` backslashes then a quote closes the argument; `2n+1`
1296                    // is a literal quote inside it.
1297                    out.extend(std::iter::repeat_n('\\', backslashes / 2));
1298                    if backslashes.is_multiple_of(2) {
1299                        break;
1300                    }
1301                    backslashes = 0;
1302                    out.push('"');
1303                }
1304                other => {
1305                    out.extend(std::iter::repeat_n('\\', backslashes));
1306                    backslashes = 0;
1307                    out.push(other);
1308                }
1309            }
1310        }
1311    } else {
1312        for c in chars {
1313            if c == ' ' || c == '\t' {
1314                break;
1315            }
1316            out.push(c);
1317        }
1318    }
1319    if out.is_empty() {
1320        None
1321    } else {
1322        Some(PathBuf::from(out))
1323    }
1324}
1325
1326// ---------------------------------------------------------------------------
1327// Rendered definitions
1328// ---------------------------------------------------------------------------
1329
1330/// Which of the four things a platform reads.
1331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1332pub enum DefinitionKind {
1333    /// The parameters `CreateServiceW` is called with, rendered as a canonical
1334    /// descriptor so that they can be reviewed and asserted like the other
1335    /// three. The Service Control Manager has no file.
1336    WindowsService,
1337    /// A Task Scheduler XML document, for `--start-at login` on Windows.
1338    WindowsScheduledTask,
1339    /// A launchd property list — a LaunchDaemon at boot, a LaunchAgent at login.
1340    LaunchdPlist,
1341    /// A systemd unit — a system unit at boot, a user unit at login.
1342    SystemdUnit,
1343}
1344
1345impl DefinitionKind {
1346    /// What to call the thing that reads it, in an operator-facing message.
1347    #[must_use]
1348    pub const fn manager(self) -> &'static str {
1349        match self {
1350            Self::WindowsService => "the Windows Service Control Manager",
1351            Self::WindowsScheduledTask => "Windows Task Scheduler",
1352            Self::LaunchdPlist => "launchd",
1353            Self::SystemdUnit => "systemd",
1354        }
1355    }
1356}
1357
1358impl fmt::Display for DefinitionKind {
1359    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1360        f.write_str(self.manager())
1361    }
1362}
1363
1364/// One platform's definition of the registration, as text.
1365///
1366/// Producing this is pure: no `cfg`, no privileges, no filesystem. That is what
1367/// lets every leg of the CI matrix assert every platform's definition, instead
1368/// of each leg asserting only its own and the other two being reviewed by
1369/// reading.
1370#[derive(Debug, Clone, PartialEq, Eq)]
1371pub struct ServiceDefinition {
1372    kind: DefinitionKind,
1373    text: String,
1374    install_path: Option<PathBuf>,
1375}
1376
1377impl ServiceDefinition {
1378    /// Which platform this is for.
1379    #[must_use]
1380    pub const fn kind(&self) -> DefinitionKind {
1381        self.kind
1382    }
1383
1384    /// The definition itself.
1385    #[must_use]
1386    pub fn text(&self) -> &str {
1387        &self.text
1388    }
1389
1390    /// Where the platform expects the file, when the platform reads a file.
1391    ///
1392    /// `None` for [`DefinitionKind::WindowsService`], whose "definition" is a
1393    /// set of arguments to `CreateServiceW`, and for a Task Scheduler document,
1394    /// which is handed to `schtasks` from a temporary file and is not stored
1395    /// where it was written.
1396    #[must_use]
1397    pub fn install_path(&self) -> Option<&Path> {
1398        self.install_path.as_deref()
1399    }
1400
1401    /// The definition this host's own service manager would be given.
1402    ///
1403    /// The value-level twin of [`host_definition_kind`]. Rendering needs no
1404    /// privileges and no service manager, so anything that wants to *see* what
1405    /// an install would register — `f3`'s `service install --dry-run`, a
1406    /// support bundle, [`RecordingControls`] — can have it without registering
1407    /// anything.
1408    ///
1409    /// # Errors
1410    ///
1411    /// [`ServiceError::Control`] when a Windows logon-triggered registration is
1412    /// asked for and the session reports no account to register it for.
1413    pub fn for_host(plan: &InstallPlan) -> Result<Self, ServiceError> {
1414        Ok(match host_definition_kind(plan.start_mode()) {
1415            DefinitionKind::WindowsService => Self::windows_service(plan),
1416            DefinitionKind::WindowsScheduledTask => {
1417                Self::windows_scheduled_task(plan, &TaskPrincipal::current()?)
1418            }
1419            DefinitionKind::LaunchdPlist => Self::launchd(plan, host_home().as_deref()),
1420            DefinitionKind::SystemdUnit => Self::systemd(plan, host_home().as_deref()),
1421        })
1422    }
1423
1424    /// The Windows service parameters, as a canonical descriptor.
1425    #[must_use]
1426    pub fn windows_service(plan: &InstallPlan) -> Self {
1427        Self {
1428            kind: DefinitionKind::WindowsService,
1429            text: windows_service_descriptor(plan),
1430            install_path: None,
1431        }
1432    }
1433
1434    /// A Task Scheduler document for `--start-at login`.
1435    #[must_use]
1436    pub fn windows_scheduled_task(plan: &InstallPlan, principal: &TaskPrincipal) -> Self {
1437        Self {
1438            kind: DefinitionKind::WindowsScheduledTask,
1439            text: windows_scheduled_task_xml(plan, principal),
1440            install_path: None,
1441        }
1442    }
1443
1444    /// A launchd property list.
1445    ///
1446    /// `home` is only consulted for `--start-at login`, where the file belongs
1447    /// in the operator's own `~/Library/LaunchAgents`.
1448    #[must_use]
1449    pub fn launchd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1450        let file = format!("{}.plist", plan.identity().launchd_label());
1451        let install_path = match plan.start_mode() {
1452            StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
1453            StartMode::Login => home.map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file)),
1454        };
1455        Self {
1456            kind: DefinitionKind::LaunchdPlist,
1457            text: launchd_plist(plan),
1458            install_path,
1459        }
1460    }
1461
1462    /// Wraps text this module did not render.
1463    ///
1464    /// Two callers need it and both matter. A test builds a deliberately
1465    /// widened definition and watches [`review_least_privilege`] reject it —
1466    /// without which the review would be a function nobody had ever seen fail.
1467    /// And `service status` can review the definition **actually on disk**,
1468    /// which on Linux and macOS an operator is free to edit after installation.
1469    #[must_use]
1470    pub fn from_text(kind: DefinitionKind, text: impl Into<String>) -> Self {
1471        Self {
1472            kind,
1473            text: text.into(),
1474            install_path: None,
1475        }
1476    }
1477
1478    /// A systemd unit.
1479    ///
1480    /// `home` is only consulted for `--start-at login`, where the unit belongs
1481    /// in the operator's own `~/.config/systemd/user`.
1482    #[must_use]
1483    pub fn systemd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1484        let file = plan.identity().systemd_unit();
1485        let install_path = match plan.start_mode() {
1486            StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
1487            StartMode::Login => home.map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file)),
1488        };
1489        Self {
1490            kind: DefinitionKind::SystemdUnit,
1491            text: systemd_unit(plan),
1492            install_path,
1493        }
1494    }
1495}
1496
1497/// Where a LaunchDaemon lives.
1498pub const LAUNCH_DAEMONS_DIR: &str = "/Library/LaunchDaemons";
1499/// Where a LaunchAgent lives, under the operator's home directory.
1500pub const LAUNCH_AGENTS_SUBDIR: &str = "Library/LaunchAgents";
1501/// Where a systemd system unit lives.
1502pub const SYSTEMD_SYSTEM_DIR: &str = "/etc/systemd/system";
1503/// Where a systemd user unit lives, under the operator's home directory.
1504pub const SYSTEMD_USER_SUBDIR: &str = ".config/systemd/user";
1505
1506/// The product's own page, cited by every definition so an operator who finds
1507/// one on a host can find out what put it there.
1508const DOCUMENTATION: &str = "https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI";
1509
1510/// How many consecutive failures the platform tolerates before it stops
1511/// retrying.
1512///
1513/// The companion to [`RestartPolicy::reset_after`]: the delay bounds how *fast*
1514/// a restart may come, and this bounds how *many* come before the host gives
1515/// up and leaves the failure visible instead of hiding it behind an endless
1516/// retry.
1517pub const START_LIMIT_BURST: u32 = 5;
1518
1519// -- systemd -----------------------------------------------------------------
1520
1521/// Renders the systemd unit for this plan.
1522///
1523/// # Why the sandbox is this tight, and what it costs
1524///
1525/// `05-infrastructure.md` item 2 ends *"and write its configured cache and
1526/// runtime directories — and no more"*. `ProtectSystem=strict` plus an explicit
1527/// `ReadWritePaths` is what "and no more" means on Linux: everything outside the
1528/// recorded application-data directories and the credential store directory
1529/// is read-only to this unit. The latter must be writable because refresh-token
1530/// rotation atomically replaces the credential file.
1531///
1532/// **The runner inherits it.** The agent spawns the GitHub Actions runner as a
1533/// child, so a workflow running on this host also runs inside this sandbox: it
1534/// cannot write outside those directories and its private `/tmp`, and
1535/// `NoNewPrivileges=yes` means it cannot `sudo`. `07-security.md` assumes a
1536/// hostile workflow may run here, so that is the intended direction — but it is
1537/// a real behavioural limit and `docs/service-account.md` states it where an
1538/// operator will find it.
1539#[must_use]
1540pub fn systemd_unit(plan: &InstallPlan) -> String {
1541    let identity = plan.identity();
1542    let restart = plan.restart();
1543    let directories = plan.directories();
1544
1545    let mut out = String::new();
1546    out.push_str("[Unit]\n");
1547    out.push_str(&format!("Description={}\n", identity.display_name()));
1548    out.push_str(&format!("Documentation={DOCUMENTATION}\n"));
1549    out.push_str("After=network-online.target\n");
1550    out.push_str("Wants=network-online.target\n");
1551    // In `[Unit]` rather than `[Service]`: systemd moved these in v229 and only
1552    // accepts them here without a deprecation warning.
1553    out.push_str(&format!(
1554        "StartLimitIntervalSec={}\n",
1555        restart.reset_after().as_secs()
1556    ));
1557    out.push_str(&format!("StartLimitBurst={START_LIMIT_BURST}\n"));
1558
1559    out.push_str("\n[Service]\n");
1560    out.push_str("Type=simple\n");
1561    // A runner is a child of the daemon. In particular, systemd must not
1562    // signal it merely because a cooperative daemon handover replaces the
1563    // manager process; startup recovery supervises any survivor instead.
1564    out.push_str("KillMode=process\n");
1565    out.push_str(&format!("ExecStart={}\n", plan.command_line()));
1566    out.push_str(&format!(
1567        "WorkingDirectory={}\n",
1568        directories.state.display()
1569    ));
1570    out.push_str(&format!("SyslogIdentifier={identity}\n"));
1571    out.push_str("Restart=on-failure\n");
1572    out.push_str(&format!("RestartSec={}\n", restart.delay().as_secs()));
1573
1574    out.push_str("\n# Least privilege. See docs/service-account.md.\n");
1575    for directive in SYSTEMD_HARDENING {
1576        out.push_str(directive);
1577        out.push('\n');
1578    }
1579    let mut writable = directories.all().to_vec();
1580    if let Some(secret_directory) = plan.secret_guard().and_then(Path::parent) {
1581        writable.push(secret_directory);
1582    }
1583    out.push_str(&format!(
1584        "ReadWritePaths={}\n",
1585        writable
1586            .iter()
1587            .map(|path| quote_argument(&path.to_string_lossy()))
1588            .collect::<Vec<_>>()
1589            .join(" ")
1590    ));
1591
1592    out.push_str("\n[Install]\n");
1593    out.push_str(match plan.start_mode() {
1594        StartMode::Boot => "WantedBy=multi-user.target\n",
1595        StartMode::Login => "WantedBy=default.target\n",
1596    });
1597    out
1598}
1599
1600/// The hardening directives every unit carries, in the order they are rendered.
1601///
1602/// A constant rather than a sequence of `push_str` calls so that
1603/// [`review_least_privilege`] can be written against the same list the renderer
1604/// emits: a directive dropped from the unit is a directive the review then
1605/// reports as missing, rather than one that silently stops being checked.
1606pub const SYSTEMD_HARDENING: [&str; 13] = [
1607    "NoNewPrivileges=yes",
1608    "CapabilityBoundingSet=",
1609    "AmbientCapabilities=",
1610    "PrivateTmp=yes",
1611    "PrivateDevices=yes",
1612    "ProtectSystem=strict",
1613    "ProtectKernelTunables=yes",
1614    "ProtectKernelModules=yes",
1615    "ProtectControlGroups=yes",
1616    "RestrictNamespaces=yes",
1617    "RestrictRealtime=yes",
1618    "RestrictSUIDSGID=yes",
1619    "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1620];
1621
1622// -- launchd -----------------------------------------------------------------
1623
1624/// The `ProcessType` every rendered plist carries.
1625///
1626/// # Why not `Background`
1627///
1628/// launchd applies a job's `ProcessType` to the whole process tree it starts,
1629/// not only to the daemon. Under `Background` every runner the daemon launched
1630/// ran at base priority 4, on efficiency cores with throttled I/O, so each job
1631/// took 4 to 20 times as long as on a classic runner.
1632///
1633/// A child cannot leave it. This is the process type launchd chose at spawn
1634/// (`spawn type = background (5)` in `launchctl print`), not the
1635/// `PRIO_DARWIN_BG` flag `setpriority` controls: measured on macOS 27, a child
1636/// of a `Background` agent reads `getpriority(PRIO_DARWIN_PROCESS) == 0`, and
1637/// `setpriority(PRIO_DARWIN_PROCESS, 0, 0)` returns success and leaves it at
1638/// 4, before `exec` and after it alike. 0.4.31 tried exactly that.
1639///
1640/// The measured alternatives, as the base priority of a runner the agent
1641/// starts: `Background` 4, `Adaptive` 4, `Standard` (the default when the key
1642/// is absent) 20, `Interactive` 31 (the same as a classic runner, and the value
1643/// GitHub's own runner service template uses). The daemon itself mostly waits,
1644/// so it costs nothing to run it at that priority too.
1645pub const LAUNCHD_PROCESS_TYPE: &str = "Interactive";
1646
1647/// Renders the launchd property list for this plan.
1648///
1649/// `KeepAlive` is a dictionary rather than `<true/>` on purpose: the
1650/// requirement is *restart on failure*, and a bare `KeepAlive` also restarts a
1651/// job that exited cleanly, which would turn a deliberate `service stop` into a
1652/// fight with launchd.
1653#[must_use]
1654pub fn launchd_plist(plan: &InstallPlan) -> String {
1655    let identity = plan.identity();
1656    let directories = plan.directories();
1657    let mut out = String::new();
1658    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
1659    out.push_str(
1660        "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
1661         \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
1662    );
1663    out.push_str("<plist version=\"1.0\">\n<dict>\n");
1664    out.push_str(&plist_string("Label", &identity.launchd_label()));
1665
1666    out.push_str("  <key>ProgramArguments</key>\n  <array>\n");
1667    out.push_str(&format!(
1668        "    <string>{}</string>\n",
1669        xml_escape(&plan.binary().to_string_lossy())
1670    ));
1671    for argument in plan.arguments() {
1672        out.push_str(&format!(
1673            "    <string>{}</string>\n",
1674            xml_escape(&argument.to_string_lossy())
1675        ));
1676    }
1677    out.push_str("  </array>\n");
1678
1679    out.push_str("  <key>RunAtLoad</key>\n  <true/>\n");
1680    out.push_str("  <key>KeepAlive</key>\n  <dict>\n");
1681    out.push_str("    <key>SuccessfulExit</key>\n    <false/>\n");
1682    out.push_str("  </dict>\n");
1683    out.push_str(&format!(
1684        "  <key>ThrottleInterval</key>\n  <integer>{}</integer>\n",
1685        plan.restart().delay().as_secs()
1686    ));
1687    out.push_str(&plist_string("ProcessType", LAUNCHD_PROCESS_TYPE));
1688    out.push_str(&plist_string(
1689        "WorkingDirectory",
1690        &directories.state.to_string_lossy(),
1691    ));
1692    out.push_str(&plist_string(
1693        "StandardOutPath",
1694        &directories
1695            .logs
1696            .join("runner-manager.launchd.out.log")
1697            .to_string_lossy(),
1698    ));
1699    out.push_str(&plist_string(
1700        "StandardErrorPath",
1701        &directories
1702            .logs
1703            .join("runner-manager.launchd.err.log")
1704            .to_string_lossy(),
1705    ));
1706
1707    match plan.start_mode() {
1708        StartMode::Boot => {
1709            // A LaunchDaemon runs as root unless told otherwise, and here it
1710            // must: the System Keychain's master key is root-only.
1711            out.push_str(&plist_string(
1712                "UserName",
1713                ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
1714                    .as_str(),
1715            ));
1716            // A daemon has no user session and must not be given one.
1717            out.push_str("  <key>SessionCreate</key>\n  <false/>\n");
1718        }
1719        StartMode::Login => {
1720            // A LaunchAgent already runs as the operator. Naming a `UserName`
1721            // here would be asking launchd for an account switch a login-mode
1722            // registration has no reason to want.
1723        }
1724    }
1725
1726    out.push_str("</dict>\n</plist>\n");
1727    out
1728}
1729
1730/// One `<key>`/`<string>` pair, indented as the rest of the plist is.
1731fn plist_string(key: &str, value: &str) -> String {
1732    format!(
1733        "  <key>{}</key>\n  <string>{}</string>\n",
1734        xml_escape(key),
1735        xml_escape(value)
1736    )
1737}
1738
1739// -- Windows Task Scheduler --------------------------------------------------
1740
1741/// The account a Task Scheduler task runs as.
1742///
1743/// Task Scheduler, unlike the other three managers, needs the principal spelled
1744/// out in the document. Isolating the one impure step keeps
1745/// [`windows_scheduled_task_xml`] a pure function that every CI leg can assert.
1746#[derive(Debug, Clone, PartialEq, Eq)]
1747pub struct TaskPrincipal {
1748    user_id: String,
1749}
1750
1751impl TaskPrincipal {
1752    /// The account running this call, as `DOMAIN\user`.
1753    ///
1754    /// # Errors
1755    ///
1756    /// [`ServiceError::BinaryPath`] is not the right shape here, so this
1757    /// reports [`ServiceError::Control`] naming what the environment failed to
1758    /// say.
1759    pub fn current() -> Result<Self, ServiceError> {
1760        let user = std::env::var("USERNAME")
1761            .ok()
1762            .filter(|value| !value.trim().is_empty());
1763        let Some(user) = user else {
1764            return Err(ServiceError::Control {
1765                operation: "identify the account for",
1766                name: SERVICE_NAME.to_string(),
1767                manager: "Windows Task Scheduler",
1768                detail: "this session reports no %USERNAME%, so there is no principal to \
1769                         register a logon-triggered task for"
1770                    .to_string(),
1771            });
1772        };
1773        let domain = std::env::var("USERDOMAIN")
1774            .ok()
1775            .filter(|value| !value.trim().is_empty());
1776        Ok(Self {
1777            user_id: match domain {
1778                Some(domain) => format!("{domain}\\{user}"),
1779                None => user,
1780            },
1781        })
1782    }
1783
1784    /// A named principal, for tests and for a caller that already knows.
1785    #[must_use]
1786    pub fn named(user_id: impl Into<String>) -> Self {
1787        Self {
1788            user_id: user_id.into(),
1789        }
1790    }
1791
1792    /// `DOMAIN\user`.
1793    #[must_use]
1794    pub fn user_id(&self) -> &str {
1795        &self.user_id
1796    }
1797}
1798
1799/// Renders the Task Scheduler document for a `--start-at login` registration.
1800///
1801/// `RunLevel` is `LeastPrivilege`, which is the whole of Windows' answer to
1802/// item 2 in this domain: the task runs with the operator's ordinary filtered
1803/// token and never with an elevated one, whatever the operator's group
1804/// membership.
1805#[must_use]
1806pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
1807    let identity = plan.identity();
1808    let user = xml_escape(principal.user_id());
1809    let arguments = plan
1810        .arguments()
1811        .iter()
1812        .map(|argument| quote_argument(&argument.to_string_lossy()))
1813        .collect::<Vec<_>>()
1814        .join(" ");
1815    let mut out = String::new();
1816    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
1817    out.push_str(
1818        "<Task version=\"1.4\" \
1819         xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
1820    );
1821    out.push_str("  <RegistrationInfo>\n");
1822    out.push_str(&format!(
1823        "    <Description>{}</Description>\n",
1824        xml_escape(identity.description())
1825    ));
1826    out.push_str(&format!(
1827        "    <URI>\\{}</URI>\n",
1828        xml_escape(identity.name())
1829    ));
1830    out.push_str("  </RegistrationInfo>\n");
1831
1832    out.push_str("  <Triggers>\n    <LogonTrigger>\n");
1833    out.push_str("      <Enabled>true</Enabled>\n");
1834    out.push_str(&format!("      <UserId>{user}</UserId>\n"));
1835    out.push_str("    </LogonTrigger>\n  </Triggers>\n");
1836
1837    out.push_str("  <Principals>\n    <Principal id=\"Author\">\n");
1838    out.push_str(&format!("      <UserId>{user}</UserId>\n"));
1839    out.push_str("      <LogonType>InteractiveToken</LogonType>\n");
1840    out.push_str("      <RunLevel>LeastPrivilege</RunLevel>\n");
1841    out.push_str("    </Principal>\n  </Principals>\n");
1842
1843    out.push_str("  <Settings>\n");
1844    // One agent per host is `d1`'s lock; saying so here means Task Scheduler
1845    // refuses the second start rather than starting a process that then loses
1846    // the race and exits.
1847    out.push_str("    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
1848    out.push_str("    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
1849    out.push_str("    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
1850    out.push_str("    <AllowHardTerminate>true</AllowHardTerminate>\n");
1851    out.push_str("    <StartWhenAvailable>true</StartWhenAvailable>\n");
1852    out.push_str("    <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
1853    out.push_str("    <IdleSettings>\n");
1854    out.push_str("      <StopOnIdleEnd>false</StopOnIdleEnd>\n");
1855    out.push_str("      <RestartOnIdle>false</RestartOnIdle>\n");
1856    out.push_str("    </IdleSettings>\n");
1857    out.push_str("    <AllowStartOnDemand>true</AllowStartOnDemand>\n");
1858    out.push_str("    <Enabled>true</Enabled>\n");
1859    out.push_str("    <Hidden>false</Hidden>\n");
1860    out.push_str("    <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
1861    out.push_str("    <WakeToRun>false</WakeToRun>\n");
1862    // A daemon has no natural end, so any limit here would be a scheduled kill.
1863    out.push_str("    <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
1864    out.push_str("    <Priority>7</Priority>\n");
1865    out.push_str("    <RestartOnFailure>\n");
1866    out.push_str(&format!(
1867        "      <Interval>{}</Interval>\n",
1868        iso8601_minutes(
1869            plan.restart()
1870                .effective_delay(DefinitionKind::WindowsScheduledTask)
1871        )
1872    ));
1873    out.push_str(&format!("      <Count>{START_LIMIT_BURST}</Count>\n"));
1874    out.push_str("    </RestartOnFailure>\n");
1875    out.push_str("  </Settings>\n");
1876
1877    out.push_str("  <Actions Context=\"Author\">\n    <Exec>\n");
1878    out.push_str(&format!(
1879        "      <Command>{}</Command>\n",
1880        xml_escape(&plan.binary().to_string_lossy())
1881    ));
1882    if !arguments.is_empty() {
1883        out.push_str(&format!(
1884            "      <Arguments>{}</Arguments>\n",
1885            xml_escape(&arguments)
1886        ));
1887    }
1888    out.push_str(&format!(
1889        "      <WorkingDirectory>{}</WorkingDirectory>\n",
1890        xml_escape(&plan.directories().state.to_string_lossy())
1891    ));
1892    out.push_str("    </Exec>\n  </Actions>\n");
1893    out.push_str("</Task>\n");
1894    out
1895}
1896
1897/// `PT<n>M`, which is the only shape Task Scheduler accepts for a restart
1898/// interval. See [`RestartPolicy::effective_delay`].
1899fn iso8601_minutes(duration: Duration) -> String {
1900    format!("PT{}M", duration.as_secs() / 60)
1901}
1902
1903/// Escapes the five XML entities. Applied to every value that reaches a plist
1904/// or a task document, because a Windows account name may legitimately contain
1905/// `&` and a path may contain `<`.
1906///
1907/// `pub(crate)` rather than private because [`crate::wsl::task`] renders a
1908/// second kind of Task Scheduler document and must escape it the same way; two
1909/// escapers in one crate is exactly how one of them ends up subtly different.
1910pub(crate) fn xml_escape(value: &str) -> String {
1911    let mut out = String::with_capacity(value.len());
1912    for c in value.chars() {
1913        match c {
1914            '&' => out.push_str("&amp;"),
1915            '<' => out.push_str("&lt;"),
1916            '>' => out.push_str("&gt;"),
1917            '"' => out.push_str("&quot;"),
1918            '\'' => out.push_str("&apos;"),
1919            other => out.push(other),
1920        }
1921    }
1922    out
1923}
1924
1925/// The inverse of [`xml_escape`], for reading a definition back off disk.
1926///
1927/// `&amp;` is replaced last, which is the whole subtlety: replacing it first
1928/// would turn a literal `&amp;amp;` into `&` in two passes instead of `&amp;`
1929/// in one.
1930fn xml_unescape(value: &str) -> String {
1931    value
1932        .replace("&lt;", "<")
1933        .replace("&gt;", ">")
1934        .replace("&quot;", "\"")
1935        .replace("&apos;", "'")
1936        .replace("&amp;", "&")
1937}
1938
1939// -- Windows Service Control Manager -----------------------------------------
1940
1941/// How `CreateServiceW` should be called, in a form that can be reviewed,
1942/// asserted, and printed on any platform.
1943///
1944/// The Windows backend builds its `ServiceInfo` from **this** value rather than
1945/// from the plan, so the descriptor a Linux CI leg asserts and the parameters a
1946/// Windows host registers cannot drift apart.
1947#[derive(Debug, Clone, PartialEq, Eq)]
1948pub struct WindowsServiceSpec {
1949    /// The service name.
1950    pub name: String,
1951    /// What `services.msc` shows.
1952    pub display_name: String,
1953    /// The one-line description.
1954    pub description: String,
1955    /// `true` for `SERVICE_AUTO_START`, `false` for `SERVICE_DEMAND_START`.
1956    pub automatic_start: bool,
1957    /// The account, or `None` for `LocalSystem`, which is what
1958    /// `CreateServiceW` takes a null `lpServiceStartName` to mean.
1959    pub account: Option<String>,
1960    /// The full launch command, quoted.
1961    pub command_line: String,
1962    /// The restart-on-failure policy.
1963    pub restart: RestartPolicy,
1964}
1965
1966/// Derives the Windows service parameters from a plan.
1967#[must_use]
1968pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
1969    WindowsServiceSpec {
1970        name: plan.identity().name().to_string(),
1971        display_name: plan.identity().display_name().to_string(),
1972        description: plan.identity().description().to_string(),
1973        // Only a boot-mode registration is a service at all; a login-mode one
1974        // is a scheduled task. So an automatic start is the only kind here, and
1975        // the field exists because the privileged tests register an on-demand
1976        // fixture rather than one that starts with the test machine.
1977        automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
1978        account: match ServiceAccount::for_definition(
1979            DefinitionKind::WindowsService,
1980            plan.start_mode(),
1981        ) {
1982            // `None` means LocalSystem to `CreateServiceW`, and naming it
1983            // explicitly would be one more string to spell right.
1984            ServiceAccount::LocalSystem => None,
1985            other => Some(other.as_str().to_string()),
1986        },
1987        command_line: plan.command_line(),
1988        restart: plan.restart(),
1989    }
1990}
1991
1992/// Renders the descriptor [`review_least_privilege`] reads and `service status`
1993/// can print.
1994#[must_use]
1995fn windows_service_descriptor(plan: &InstallPlan) -> String {
1996    let spec = windows_service_spec(plan);
1997    let mut out = String::new();
1998    out.push_str("[windows-service]\n");
1999    out.push_str(&format!("Name={}\n", spec.name));
2000    out.push_str(&format!("DisplayName={}\n", spec.display_name));
2001    out.push_str(&format!("Description={}\n", spec.description));
2002    // OWN_PROCESS and never INTERACTIVE_PROCESS: an interactive service would
2003    // put a process this product controls on the operator's desktop, which is
2004    // both deprecated by Windows and more than the requirement asks for.
2005    out.push_str("ServiceType=OWN_PROCESS\n");
2006    out.push_str(&format!(
2007        "StartType={}\n",
2008        if spec.automatic_start {
2009            "AutoStart"
2010        } else {
2011            "OnDemand"
2012        }
2013    ));
2014    out.push_str("ErrorControl=Normal\n");
2015    out.push_str(&format!(
2016        "Account={}\n",
2017        spec.account
2018            .as_deref()
2019            .unwrap_or(ServiceAccount::LocalSystem.as_str())
2020    ));
2021    out.push_str(&format!("CommandLine={}\n", spec.command_line));
2022    out.push_str(&format!(
2023        "FailureActionRestartDelaySecs={}\n",
2024        spec.restart.delay().as_secs()
2025    ));
2026    out.push_str(&format!(
2027        "FailureActionsResetPeriodSecs={}\n",
2028        spec.restart.reset_after().as_secs()
2029    ));
2030    // Without this flag the Service Control Manager applies failure actions
2031    // only to a crash, and a daemon that exits non-zero after failing to reach
2032    // GitHub is not a crash.
2033    out.push_str("FailureActionsOnNonCrashFailures=true\n");
2034    out.push_str(&format!(
2035        "ReadWritePaths={}\n",
2036        plan.directories()
2037            .all()
2038            .iter()
2039            .map(|path| quote_argument(&path.to_string_lossy()))
2040            .collect::<Vec<_>>()
2041            .join(" ")
2042    ));
2043    out
2044}
2045
2046// ---------------------------------------------------------------------------
2047// The least-privilege review
2048// ---------------------------------------------------------------------------
2049
2050/// Whether a finding is about too much authority or too little.
2051#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2052pub enum FindingKind {
2053    /// The definition grants more than `05-infrastructure.md` item 2 asks for.
2054    /// This is what makes a review fail.
2055    Excess,
2056    /// The definition does not grant something the daemon needs, or does not
2057    /// state something the review cannot verify without.
2058    Shortfall,
2059}
2060
2061impl fmt::Display for FindingKind {
2062    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2063        f.write_str(match self {
2064            Self::Excess => "excess",
2065            Self::Shortfall => "shortfall",
2066        })
2067    }
2068}
2069
2070/// One thing the review has to say about a definition.
2071#[derive(Debug, Clone, PartialEq, Eq)]
2072pub struct PrivilegeFinding {
2073    /// Excess or shortfall.
2074    pub kind: FindingKind,
2075    /// The directive, key, or element it is about.
2076    pub subject: String,
2077    /// What is wrong with it, in terms an operator can act on.
2078    pub detail: String,
2079}
2080
2081impl fmt::Display for PrivilegeFinding {
2082    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2083        write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2084    }
2085}
2086
2087/// What a definition actually grants, measured against the requirement.
2088///
2089/// `05-infrastructure.md` item 2 and `07-security.md`'s release gate
2090/// (*"Service account permissions are documented and verified least
2091/// privilege"*) are the same requirement stated twice, and this is the
2092/// verification half. The documentation half is `docs/service-account.md`.
2093///
2094/// The review reads the **rendered text**, not the plan it came from. That is
2095/// deliberate and it is the only version of this check worth having: a review
2096/// that re-derived its expectations from the same values the renderer used
2097/// would agree with the renderer by construction and could never fail. Reading
2098/// the text means a hand-edited unit on a real host is reviewed as it is, and
2099/// means a test can widen a definition and watch this say so.
2100#[derive(Debug, Clone, PartialEq, Eq)]
2101pub struct PrivilegeReview {
2102    kind: DefinitionKind,
2103    account: ServiceAccount,
2104    controls: Vec<String>,
2105    findings: Vec<PrivilegeFinding>,
2106}
2107
2108impl PrivilegeReview {
2109    /// Whether the definition grants nothing beyond the requirement.
2110    #[must_use]
2111    pub fn is_least_privilege(&self) -> bool {
2112        !self
2113            .findings
2114            .iter()
2115            .any(|finding| finding.kind == FindingKind::Excess)
2116    }
2117
2118    /// Everything the review found, excesses and shortfalls together.
2119    #[must_use]
2120    pub fn findings(&self) -> &[PrivilegeFinding] {
2121        &self.findings
2122    }
2123
2124    /// Only the excesses — the findings that make [`Self::is_least_privilege`]
2125    /// false.
2126    #[must_use]
2127    pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2128        self.findings
2129            .iter()
2130            .filter(|finding| finding.kind == FindingKind::Excess)
2131            .collect()
2132    }
2133
2134    /// The controls the definition was confirmed to carry.
2135    ///
2136    /// Present so that a passing review says *what it checked* rather than only
2137    /// that it passed. A check that reports nothing when it succeeds is
2138    /// indistinguishable from a check that did not run.
2139    #[must_use]
2140    pub fn controls(&self) -> &[String] {
2141        &self.controls
2142    }
2143
2144    /// The account the registration runs under, and why it is the minimum.
2145    #[must_use]
2146    pub const fn account(&self) -> &ServiceAccount {
2147        &self.account
2148    }
2149
2150    /// Which platform's definition was reviewed.
2151    #[must_use]
2152    pub const fn kind(&self) -> DefinitionKind {
2153        self.kind
2154    }
2155}
2156
2157impl fmt::Display for PrivilegeReview {
2158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2159        writeln!(
2160            f,
2161            "{} runs as {} ({})",
2162            self.kind,
2163            self.account,
2164            self.account.justification()
2165        )?;
2166        for control in &self.controls {
2167            writeln!(f, "  confirmed  {control}")?;
2168        }
2169        for finding in &self.findings {
2170            writeln!(f, "  {finding}")?;
2171        }
2172        if self.is_least_privilege() {
2173            write!(f, "  verdict    least privilege")
2174        } else {
2175            write!(
2176                f,
2177                "  verdict    NOT least privilege: {} excess(es)",
2178                self.excesses().len()
2179            )
2180        }
2181    }
2182}
2183
2184/// Reads a definition back and reports what it grants.
2185///
2186/// `plan` supplies only the two things the text cannot: the four directories
2187/// the registration is *allowed* to write, and the start mode, which decides
2188/// which account is the minimum.
2189#[must_use]
2190pub fn review_least_privilege(
2191    definition: &ServiceDefinition,
2192    plan: &InstallPlan,
2193) -> PrivilegeReview {
2194    let mut controls = Vec::new();
2195    let mut findings = Vec::new();
2196    match definition.kind() {
2197        DefinitionKind::SystemdUnit => {
2198            review_systemd(definition.text(), plan, &mut controls, &mut findings);
2199        }
2200        DefinitionKind::LaunchdPlist => {
2201            review_launchd(definition.text(), plan, &mut controls, &mut findings);
2202        }
2203        DefinitionKind::WindowsScheduledTask => {
2204            review_scheduled_task(definition.text(), &mut controls, &mut findings);
2205        }
2206        DefinitionKind::WindowsService => {
2207            review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2208        }
2209    }
2210    PrivilegeReview {
2211        kind: definition.kind(),
2212        // The definition's account, not the plan's: a Linux CI leg reviewing the
2213        // Windows descriptor must report `LocalSystem`, which is the account
2214        // that descriptor registers, and not `root`, which is the account this
2215        // leg's own host would use.
2216        account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2217        controls,
2218        findings,
2219    }
2220}
2221
2222/// Every writable path a definition may name, as the strings it names them by.
2223fn permitted_paths(kind: DefinitionKind, plan: &InstallPlan) -> Vec<String> {
2224    let mut permitted = plan
2225        .directories()
2226        .all()
2227        .iter()
2228        .map(|path| path.to_string_lossy().into_owned())
2229        .collect::<Vec<_>>();
2230    if kind == DefinitionKind::SystemdUnit
2231        && let Some(secret_directory) = plan.secret_guard().and_then(Path::parent)
2232    {
2233        permitted.push(secret_directory.to_string_lossy().into_owned());
2234    }
2235    permitted
2236}
2237
2238/// Whether two path spellings name the same place on **this host's**
2239/// filesystem. Case-insensitive on Windows, exact elsewhere.
2240///
2241/// For a rendered definition use [`same_path_for`] instead: which comparison is
2242/// right there follows the platform the definition is *for*, and every leg of
2243/// the CI matrix reviews all four.
2244fn same_path_text(left: &str, right: &str) -> bool {
2245    if cfg!(windows) {
2246        left.eq_ignore_ascii_case(right)
2247    } else {
2248        left == right
2249    }
2250}
2251
2252/// Whether two path spellings name the same place on the platform a given
2253/// definition targets.
2254fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
2255    match kind {
2256        DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
2257            left.eq_ignore_ascii_case(right)
2258        }
2259        DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
2260    }
2261}
2262
2263/// Checks a `ReadWritePaths`-style list against the permitted directories.
2264fn review_writable_paths(
2265    kind: DefinitionKind,
2266    subject: &str,
2267    listed: &[String],
2268    plan: &InstallPlan,
2269    controls: &mut Vec<String>,
2270    findings: &mut Vec<PrivilegeFinding>,
2271) {
2272    let permitted = permitted_paths(kind, plan);
2273    for entry in listed {
2274        if !permitted
2275            .iter()
2276            .any(|allowed| same_path_for(kind, allowed, entry))
2277        {
2278            findings.push(PrivilegeFinding {
2279                kind: FindingKind::Excess,
2280                subject: subject.to_string(),
2281                detail: format!(
2282                    "{entry} is writable but is not one of this registration's required paths"
2283                ),
2284            });
2285        }
2286    }
2287    for allowed in &permitted {
2288        if !listed
2289            .iter()
2290            .any(|entry| same_path_for(kind, allowed, entry))
2291        {
2292            findings.push(PrivilegeFinding {
2293                kind: FindingKind::Shortfall,
2294                subject: subject.to_string(),
2295                detail: format!(
2296                    "{allowed} is one of this registration's directories but is not writable, \
2297                     so the daemon cannot use it"
2298                ),
2299            });
2300        }
2301    }
2302    if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
2303        controls.push(format!(
2304            "{subject} names exactly the required application-data and credential paths"
2305        ));
2306    }
2307}
2308
2309/// The one inbound-surface rule, applied to whichever platform names it.
2310///
2311/// `07-security.md` handling rule 2: *"The product exposes no inbound HTTP,
2312/// socket, or RPC surface anywhere, in any command."* A definition that asks the
2313/// service manager to open a socket or publish a Mach service on the daemon's
2314/// behalf would create exactly that surface without a line of product code
2315/// changing, which is why it is checked here and not only in review.
2316fn review_inbound_surface(
2317    text: &str,
2318    markers: &[(&str, &str)],
2319    controls: &mut Vec<String>,
2320    findings: &mut Vec<PrivilegeFinding>,
2321) {
2322    let mut clean = true;
2323    for (marker, detail) in markers {
2324        if text.contains(marker) {
2325            clean = false;
2326            findings.push(PrivilegeFinding {
2327                kind: FindingKind::Excess,
2328                subject: (*marker).to_string(),
2329                detail: (*detail).to_string(),
2330            });
2331        }
2332    }
2333    if clean {
2334        controls.push(
2335            "no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
2336        );
2337    }
2338}
2339
2340fn review_systemd(
2341    text: &str,
2342    plan: &InstallPlan,
2343    controls: &mut Vec<String>,
2344    findings: &mut Vec<PrivilegeFinding>,
2345) {
2346    let directives = ini_directives(text, "Service");
2347    for expected in SYSTEMD_HARDENING {
2348        let (key, value) = expected
2349            .split_once('=')
2350            .expect("every hardening directive is written as key=value");
2351        match directives.get(key) {
2352            Some(actual) if actual == value => controls.push((*expected).to_string()),
2353            Some(actual) => findings.push(PrivilegeFinding {
2354                kind: FindingKind::Excess,
2355                subject: key.to_string(),
2356                detail: format!(
2357                    "is `{actual}`, not `{value}`, so the unit keeps authority the \
2358                                 requirement does not ask for"
2359                ),
2360            }),
2361            None => findings.push(PrivilegeFinding {
2362                kind: FindingKind::Excess,
2363                subject: key.to_string(),
2364                detail: format!(
2365                    "is absent, so the unit inherits systemd's default rather than `{value}`"
2366                ),
2367            }),
2368        }
2369    }
2370
2371    match directives.get("ReadWritePaths") {
2372        Some(value) => {
2373            let listed = split_quoted(value);
2374            review_writable_paths(
2375                DefinitionKind::SystemdUnit,
2376                "ReadWritePaths",
2377                &listed,
2378                plan,
2379                controls,
2380                findings,
2381            );
2382        }
2383        None => findings.push(PrivilegeFinding {
2384            kind: FindingKind::Shortfall,
2385            subject: "ReadWritePaths".to_string(),
2386            detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
2387                .to_string(),
2388        }),
2389    }
2390
2391    // `User=` on a system unit is an escalation only in the other direction, so
2392    // what is checked is the pair systemd actually treats as privileged.
2393    if directives.contains_key("PrivateUsers")
2394        && directives.get("PrivateUsers") == Some(&"no".to_string())
2395    {
2396        findings.push(PrivilegeFinding {
2397            kind: FindingKind::Excess,
2398            subject: "PrivateUsers".to_string(),
2399            detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
2400                .to_string(),
2401        });
2402    }
2403
2404    review_inbound_surface(
2405        text,
2406        &[
2407            (
2408                "ListenStream=",
2409                "asks systemd to open a listening socket for this service, which \
2410                 07-security.md rule 2 forbids the product to have",
2411            ),
2412            (
2413                "ListenDatagram=",
2414                "asks systemd to open a listening socket for this service, which \
2415                 07-security.md rule 2 forbids the product to have",
2416            ),
2417        ],
2418        controls,
2419        findings,
2420    );
2421}
2422
2423fn review_launchd(
2424    text: &str,
2425    plan: &InstallPlan,
2426    controls: &mut Vec<String>,
2427    findings: &mut Vec<PrivilegeFinding>,
2428) {
2429    // A scheduling class is not an authority, so no value here is an excess.
2430    // Anything but `Interactive` is a shortfall: launchd applies it to every
2431    // runner the daemon starts. See `LAUNCHD_PROCESS_TYPE`.
2432    match plist_string_value(text, "ProcessType") {
2433        Some(value) if value == LAUNCHD_PROCESS_TYPE => {
2434            controls.push(format!("ProcessType={LAUNCHD_PROCESS_TYPE}"));
2435        }
2436        found => findings.push(PrivilegeFinding {
2437            kind: FindingKind::Shortfall,
2438            subject: "ProcessType".to_string(),
2439            detail: format!(
2440                "is {}, and launchd applies it to every runner the daemon starts, so their jobs \
2441                 run below normal priority. The daemon rewrites it to `{LAUNCHD_PROCESS_TYPE}` \
2442                 when it next starts with no runner; `service status` says how to restart it",
2443                found.map_or_else(
2444                    || "absent (launchd's `Standard`)".to_string(),
2445                    |value| format!("`{value}`")
2446                )
2447            ),
2448        }),
2449    }
2450
2451    match plan.start_mode() {
2452        StartMode::Boot => {
2453            if plist_bool_value(text, "SessionCreate") == Some(true) {
2454                findings.push(PrivilegeFinding {
2455                    kind: FindingKind::Excess,
2456                    subject: "SessionCreate".to_string(),
2457                    detail: "asks launchd to create a security session for a job that runs \
2458                             outside every login session and has no use for one"
2459                        .to_string(),
2460                });
2461            } else {
2462                controls.push("SessionCreate is not requested".to_string());
2463            }
2464            match plist_string_value(text, "UserName").as_deref() {
2465                Some("root") => {
2466                    controls.push("UserName=root, stated rather than inherited".to_string())
2467                }
2468                Some(other) => findings.push(PrivilegeFinding {
2469                    kind: FindingKind::Shortfall,
2470                    subject: "UserName".to_string(),
2471                    detail: format!(
2472                        "is `{other}`, which cannot unlock the System Keychain: \
2473                         /var/db/SystemKey is root-only, so the daemon would start and then \
2474                         find no credential"
2475                    ),
2476                }),
2477                None => findings.push(PrivilegeFinding {
2478                    kind: FindingKind::Shortfall,
2479                    subject: "UserName".to_string(),
2480                    detail: "is absent, so the account is launchd's implicit default and this \
2481                             review cannot confirm it"
2482                        .to_string(),
2483                }),
2484            }
2485        }
2486        StartMode::Login => {
2487            if let Some(named) = plist_string_value(text, "UserName") {
2488                findings.push(PrivilegeFinding {
2489                    kind: FindingKind::Excess,
2490                    subject: "UserName".to_string(),
2491                    detail: format!(
2492                        "names `{named}` in a LaunchAgent, which already runs as the operator; \
2493                         naming an account here asks launchd for a switch a login-mode \
2494                         registration has no reason to want"
2495                    ),
2496                });
2497            } else {
2498                controls
2499                    .push("no UserName: the agent runs as the operator and no other".to_string());
2500            }
2501        }
2502    }
2503
2504    review_inbound_surface(
2505        text,
2506        &[
2507            (
2508                "<key>Sockets</key>",
2509                "asks launchd to open a socket for this job, which 07-security.md rule 2 \
2510                 forbids the product to have",
2511            ),
2512            (
2513                "<key>MachServices</key>",
2514                "publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
2515                 forbids the product to have",
2516            ),
2517        ],
2518        controls,
2519        findings,
2520    );
2521}
2522
2523fn review_scheduled_task(
2524    text: &str,
2525    controls: &mut Vec<String>,
2526    findings: &mut Vec<PrivilegeFinding>,
2527) {
2528    match xml_value(text, "RunLevel").as_deref() {
2529        Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
2530        Some(other) => findings.push(PrivilegeFinding {
2531            kind: FindingKind::Excess,
2532            subject: "RunLevel".to_string(),
2533            detail: format!(
2534                "is `{other}`, so the task runs with an elevated token whenever the operator is \
2535                 an administrator"
2536            ),
2537        }),
2538        None => findings.push(PrivilegeFinding {
2539            kind: FindingKind::Excess,
2540            subject: "RunLevel".to_string(),
2541            detail: "is absent, so Task Scheduler decides the token rather than the definition"
2542                .to_string(),
2543        }),
2544    }
2545
2546    match xml_value(text, "LogonType").as_deref() {
2547        Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
2548        Some(other) => findings.push(PrivilegeFinding {
2549            kind: FindingKind::Excess,
2550            subject: "LogonType".to_string(),
2551            detail: format!(
2552                "is `{other}`, which asks Windows to store or synthesise a credential for this \
2553                 task; an interactive token needs neither"
2554            ),
2555        }),
2556        None => findings.push(PrivilegeFinding {
2557            kind: FindingKind::Shortfall,
2558            subject: "LogonType".to_string(),
2559            detail: "is absent, so this review cannot confirm that no credential is stored"
2560                .to_string(),
2561        }),
2562    }
2563}
2564
2565fn review_windows_service(
2566    text: &str,
2567    plan: &InstallPlan,
2568    controls: &mut Vec<String>,
2569    findings: &mut Vec<PrivilegeFinding>,
2570) {
2571    let directives = ini_directives(text, "windows-service");
2572
2573    match directives.get("ServiceType").map(String::as_str) {
2574        Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
2575        Some(other) => findings.push(PrivilegeFinding {
2576            kind: FindingKind::Excess,
2577            subject: "ServiceType".to_string(),
2578            detail: format!(
2579                "is `{other}`; an interactive or shared-process service reaches further than a \
2580                 daemon that only talks to GitHub over HTTPS"
2581            ),
2582        }),
2583        None => findings.push(PrivilegeFinding {
2584            kind: FindingKind::Shortfall,
2585            subject: "ServiceType".to_string(),
2586            detail: "is absent, so this review cannot confirm the service is not interactive"
2587                .to_string(),
2588        }),
2589    }
2590
2591    // The account is not a free choice on Windows: `d2`'s DACL decides it. What
2592    // the review can check is that the definition names the one account that
2593    // DACL admits, and no broader one.
2594    match directives.get("Account").map(String::as_str) {
2595        Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
2596            controls.push(format!(
2597                "Account={account}: the only stock account the machine-scoped store's DACL \
2598                 (SY, BA, OW) admits"
2599            ));
2600        }
2601        Some(other) => findings.push(PrivilegeFinding {
2602            kind: FindingKind::Shortfall,
2603            subject: "Account".to_string(),
2604            detail: format!(
2605                "is `{other}`, which the machine-scoped store's DACL does not name, so the \
2606                 daemon would start and then find no credential. Widening that DACL is not this \
2607                 registration's to do: an ACE reaching `{other}` would also reach every other \
2608                 service running under it"
2609            ),
2610        }),
2611        None => findings.push(PrivilegeFinding {
2612            kind: FindingKind::Shortfall,
2613            subject: "Account".to_string(),
2614            detail: "is absent, so this review cannot confirm which account was registered"
2615                .to_string(),
2616        }),
2617    }
2618
2619    match directives.get("ReadWritePaths") {
2620        Some(value) => {
2621            let listed = split_quoted(value);
2622            review_writable_paths(
2623                DefinitionKind::WindowsService,
2624                "ReadWritePaths",
2625                &listed,
2626                plan,
2627                controls,
2628                findings,
2629            );
2630        }
2631        None => findings.push(PrivilegeFinding {
2632            kind: FindingKind::Shortfall,
2633            subject: "ReadWritePaths".to_string(),
2634            detail: "is absent, so the directories the service was installed against are not \
2635                     recorded"
2636                .to_string(),
2637        }),
2638    }
2639}
2640
2641// -- parsing helpers ---------------------------------------------------------
2642
2643/// Reads `key=value` lines out of one `[section]` of an INI-shaped document.
2644///
2645/// systemd's unit format and this module's Windows descriptor are both this
2646/// shape. A repeated key takes its last value, which is systemd's own rule for
2647/// every directive here.
2648fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
2649    let mut out = BTreeMap::new();
2650    let mut inside = false;
2651    for line in text.lines() {
2652        let line = line.trim();
2653        if line.starts_with('[') && line.ends_with(']') {
2654            inside = &line[1..line.len() - 1] == section;
2655            continue;
2656        }
2657        if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2658            continue;
2659        }
2660        if let Some((key, value)) = line.split_once('=') {
2661            out.insert(key.trim().to_string(), value.trim().to_string());
2662        }
2663    }
2664    out
2665}
2666
2667/// Splits a whitespace-separated list that may contain arguments quoted by
2668/// [`quote_argument`].
2669fn split_quoted(value: &str) -> Vec<String> {
2670    let mut out = Vec::new();
2671    let mut rest = value.trim();
2672    while !rest.is_empty() {
2673        if rest.starts_with('"') {
2674            // `executable_from_command_line` already implements Windows' quoting
2675            // rules; reuse it rather than write a second, subtly different one.
2676            if let Some(parsed) = executable_from_command_line(rest) {
2677                out.push(parsed.to_string_lossy().into_owned());
2678            }
2679            // Advance past the closing quote.
2680            let mut depth = 0usize;
2681            let mut end = rest.len();
2682            for (index, c) in rest.char_indices() {
2683                match c {
2684                    '\\' => depth += 1,
2685                    '"' => {
2686                        if depth.is_multiple_of(2) && index > 0 {
2687                            end = index + 1;
2688                            break;
2689                        }
2690                        depth = 0;
2691                    }
2692                    _ => depth = 0,
2693                }
2694            }
2695            rest = rest[end..].trim_start();
2696        } else {
2697            let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
2698            out.push(rest[..end].to_string());
2699            rest = rest[end..].trim_start();
2700        }
2701    }
2702    out
2703}
2704
2705/// The text between the **first** `<tag>` and its `</tag>`, unescaped, for the
2706/// small, known documents this module renders. Not a general XML parser and
2707/// does not pretend to be one.
2708///
2709/// "First" matters for a document that repeats a tag: a Task Scheduler task has
2710/// an `<Enabled>` in its trigger *and* one in its settings, so a caller after
2711/// the second one passes the slice that starts at `<Settings>`.
2712///
2713/// `pub(crate)` rather than private because [`crate::wsl::task`] reads back a
2714/// second kind of Task Scheduler document; one reader for both is the same
2715/// argument [`xml_escape`] makes for one escaper.
2716pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
2717    let open = format!("<{tag}>");
2718    let close = format!("</{tag}>");
2719    let start = text.find(&open)? + open.len();
2720    let end = text[start..].find(&close)? + start;
2721    Some(xml_unescape(text[start..end].trim()))
2722}
2723
2724/// Whether Task Scheduler will launch a login task automatically.
2725///
2726/// Its `/Query /XML` output normalises away values that equal the schema
2727/// default. In particular, an enabled task commonly has no `<Enabled>` node at
2728/// all, so only an explicit `false` means disabled.
2729#[cfg(any(windows, test))]
2730fn windows_login_task_starts_automatically(document: &str) -> bool {
2731    document.contains("<LogonTrigger>")
2732        && xml_value(document, "Enabled").as_deref() != Some("false")
2733}
2734
2735/// The `<string>` that follows `<key>key</key>` in a property list.
2736fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
2737    let marker = format!("<key>{key}</key>");
2738    let start = text.find(&marker)? + marker.len();
2739    Some(text[start..].trim_start())
2740}
2741
2742pub fn plist_string_value(text: &str, key: &str) -> Option<String> {
2743    let rest = plist_value_after_key(text, key)?;
2744    if !rest.starts_with("<string>") {
2745        return None;
2746    }
2747    xml_value(rest, "string")
2748}
2749
2750fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
2751    let rest = plist_value_after_key(text, key)?;
2752    if rest.starts_with("<true/>") {
2753        Some(true)
2754    } else if rest.starts_with("<false/>") {
2755        Some(false)
2756    } else {
2757        None
2758    }
2759}
2760
2761// ---------------------------------------------------------------------------
2762// The install record
2763// ---------------------------------------------------------------------------
2764
2765/// The version of [`InstallRecord`] on disk.
2766///
2767/// Bumped when a record written by an older version can no longer be read.
2768/// `service status` reports an unreadable record as a problem with a remedy
2769/// rather than failing, because the remedy — uninstall and install again —
2770/// touches no configuration, no secret and no cache.
2771pub const RECORD_SCHEMA_VERSION: u32 = 1;
2772
2773/// What `service install` wrote down, and what `service status` reads back.
2774///
2775/// This is the *record* half of `05-infrastructure.md` item 6. The service
2776/// manager also knows the binary path, and the two are compared: a registration
2777/// whose command line no longer matches the record is a registration something
2778/// else has edited, and saying so is more useful than picking one of them to
2779/// believe.
2780///
2781/// It holds no credential. The nearest thing is the *location* of the secret
2782/// store, which `d2` publishes for `host show` to print, and even that is
2783/// derived rather than stored.
2784#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2785pub struct InstallRecord {
2786    /// [`RECORD_SCHEMA_VERSION`] at the time of writing.
2787    pub schema_version: u32,
2788    /// What the operating system calls the registration.
2789    pub service_name: String,
2790    /// Which service manager holds it.
2791    pub manager: String,
2792    /// Boot or login.
2793    pub start_mode: StartMode,
2794    /// The account it runs under.
2795    pub account: ServiceAccount,
2796    /// **The resolved absolute path of the binary at install time.** Item 6.
2797    pub binary: PathBuf,
2798    /// Where [`Self::binary`] was copied from, when it is a copy.
2799    ///
2800    /// `#[serde(default)]` rather than a schema bump: a record written before
2801    /// this existed is still readable, and reads as `None`. That is not a
2802    /// silent downgrade — `None` means the registration names a package
2803    /// manager's own file, which is the layout that cannot be upgraded while
2804    /// the service runs, and `service status` says so by name.
2805    #[serde(default)]
2806    pub source_binary: Option<PathBuf>,
2807    /// The arguments it was registered with.
2808    pub arguments: Vec<String>,
2809    /// The restart-on-failure delay.
2810    pub restart_delay_secs: u64,
2811    /// How long it must run before the failure count resets.
2812    pub restart_reset_secs: u64,
2813    /// The rotating diagnostic log's stem. Item 4.
2814    pub log_file: PathBuf,
2815    /// Whether the manager was asked not to start this by itself. Always
2816    /// `false` for a registration an operator made; see
2817    /// [`InstallRequest::started_on_demand`].
2818    ///
2819    /// `#[serde(default)]` so that a record missing the field is rejected by
2820    /// the schema check below, with its remedy, rather than by a parse error
2821    /// that names a field an operator has never heard of.
2822    #[serde(default)]
2823    pub starts_on_demand: bool,
2824    /// Where the platform's definition file was written, when there is one.
2825    pub definition_path: Option<PathBuf>,
2826    /// When the registration was made.
2827    pub installed_at: DateTime<Utc>,
2828    /// Which build of the product made it.
2829    pub installed_by_version: String,
2830    // A TOML table has to follow every scalar at its level, so this field is
2831    // last by necessity rather than by taste.
2832    /// The four directories the registration was installed against. Item 2's
2833    /// *"configured cache and runtime directories"*.
2834    pub directories: ServiceDirectories,
2835}
2836
2837impl InstallRecord {
2838    /// Where the record lives, given this host's directories.
2839    #[must_use]
2840    pub fn path(paths: &AppPaths) -> PathBuf {
2841        paths.config_dir().join(RECORD_FILE)
2842    }
2843
2844    /// Builds the record for a plan that has just been applied.
2845    #[must_use]
2846    pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
2847        Self {
2848            schema_version: RECORD_SCHEMA_VERSION,
2849            service_name: plan.identity().name().to_string(),
2850            manager: definition.kind().manager().to_string(),
2851            start_mode: plan.start_mode(),
2852            account: plan.account().clone(),
2853            binary: plan.binary().to_path_buf(),
2854            arguments: plan
2855                .arguments()
2856                .iter()
2857                .map(|argument| argument.to_string_lossy().into_owned())
2858                .collect(),
2859            restart_delay_secs: plan.restart().delay().as_secs(),
2860            restart_reset_secs: plan.restart().reset_after().as_secs(),
2861            starts_on_demand: plan.is_on_demand(),
2862            source_binary: plan.source_binary().map(Path::to_path_buf),
2863            log_file: plan.directories().log_file(),
2864            definition_path: definition.install_path().map(Path::to_path_buf),
2865            installed_at: at,
2866            installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
2867            directories: plan.directories().clone(),
2868        }
2869    }
2870
2871    /// The plan this record describes, as this build would register it.
2872    ///
2873    /// The directories, binary, arguments and restart policy are the recorded
2874    /// ones; everything else -- hardening, the secret store's guard -- is what
2875    /// this build renders. That difference is the point: it is how a
2876    /// registration written by an older build is compared against, and
2877    /// repaired to, the current definition.
2878    #[must_use]
2879    pub fn plan(&self, identity: ServiceIdentity) -> InstallPlan {
2880        let plan = InstallPlan::unchecked(
2881            identity,
2882            self.start_mode,
2883            self.binary.clone(),
2884            self.directories.clone(),
2885        )
2886        .with_arguments(self.arguments.clone())
2887        .with_restart(self.restart());
2888        let plan = if self.starts_on_demand {
2889            plan.started_on_demand()
2890        } else {
2891            plan
2892        };
2893        match crate::secrets::PlatformSecretStore::for_start_mode(self.start_mode) {
2894            Ok(store) => plan.with_secret_guard(store.guard()),
2895            Err(_) => plan,
2896        }
2897    }
2898
2899    /// The restart policy this record describes, or [`RestartPolicy::default`]
2900    /// when the recorded numbers are outside the supported range — which can
2901    /// only happen to a record something else has edited.
2902    #[must_use]
2903    pub fn restart(&self) -> RestartPolicy {
2904        RestartPolicy::new(
2905            Duration::from_secs(self.restart_delay_secs),
2906            Duration::from_secs(self.restart_reset_secs),
2907        )
2908        .unwrap_or_default()
2909    }
2910
2911    /// Reads the record, or reports that there is none.
2912    ///
2913    /// `Ok(None)` means no registration was made by this product on this host.
2914    ///
2915    /// # Errors
2916    ///
2917    /// [`ServiceError::Record`] when the file exists and cannot be read,
2918    /// [`ServiceError::RecordNotPermitted`] when it exists and this account may
2919    /// not read it, and [`ServiceError::RecordUnreadable`] when it is not a
2920    /// record this version understands.
2921    pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
2922        let path = Self::path(paths);
2923        let text = match std::fs::read_to_string(&path) {
2924            Ok(text) => text,
2925            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2926            // Told apart from every other read failure because `status` carries
2927            // on through this one alone. See `ServiceError::RecordNotPermitted`.
2928            Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
2929                return Err(ServiceError::RecordNotPermitted {
2930                    path,
2931                    detail: error.to_string(),
2932                });
2933            }
2934            Err(error) => {
2935                return Err(ServiceError::Record {
2936                    operation: "read",
2937                    path,
2938                    detail: error.to_string(),
2939                });
2940            }
2941        };
2942        let record: Self =
2943            toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
2944                path: path.clone(),
2945                detail: error.to_string(),
2946            })?;
2947        if record.schema_version != RECORD_SCHEMA_VERSION {
2948            return Err(ServiceError::RecordUnreadable {
2949                path,
2950                detail: format!(
2951                    "it declares schema version {} and this build reads version {}",
2952                    record.schema_version, RECORD_SCHEMA_VERSION
2953                ),
2954            });
2955        }
2956        Ok(Some(record))
2957    }
2958
2959    /// Writes the record, replacing whatever was there.
2960    ///
2961    /// # Errors
2962    ///
2963    /// [`ServiceError::Record`].
2964    pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
2965        use std::io::Write as _;
2966
2967        let path = Self::path(paths);
2968        let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
2969            operation: "encode",
2970            path: path.clone(),
2971            detail: error.to_string(),
2972        })?;
2973        if let Some(parent) = path.parent() {
2974            std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
2975                operation: "write",
2976                path: path.clone(),
2977                detail: error.to_string(),
2978            })?;
2979        }
2980        let parent = path.parent().ok_or_else(|| ServiceError::Record {
2981            operation: "write",
2982            path: path.clone(),
2983            detail: "the record path has no parent directory".to_string(),
2984        })?;
2985        let mut temporary =
2986            tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
2987                operation: "write",
2988                path: path.clone(),
2989                detail: error.to_string(),
2990            })?;
2991        temporary
2992            .write_all(text.as_bytes())
2993            .and_then(|()| temporary.as_file().sync_all())
2994            .map_err(|error| ServiceError::Record {
2995                operation: "write",
2996                path: path.clone(),
2997                detail: error.to_string(),
2998            })?;
2999        // ------------------------------------------------------------------
3000        // THE RECORD IS READABLE BY THE ACCOUNT WHOSE DIRECTORY IT IS IN.
3001        // ------------------------------------------------------------------
3002        // `NamedTempFile` creates at `0600`, which is right for a temporary
3003        // file and wrong for this one. A boot-mode `service install` runs under
3004        // `sudo`, so `0600` made the record `root`-owned and unreadable by the
3005        // operator — and `service status`, which asks no privilege of anybody,
3006        // failed on their own host with `Permission denied` and a remedy that
3007        // named the command that had just failed.
3008        //
3009        // `0644` does not widen anything: `RECORD_FILE`'s own documentation is
3010        // that this is *"non-secret TOML"* holding no credential, and
3011        // `AppPaths::create_all` puts it in a `0700` directory, so no other
3012        // local account can reach it whatever its own mode says. It is the same
3013        // arrangement the rotating diagnostics already have.
3014        #[cfg(unix)]
3015        {
3016            use std::os::unix::fs::PermissionsExt as _;
3017
3018            temporary
3019                .as_file()
3020                .set_permissions(std::fs::Permissions::from_mode(0o644))
3021                .map_err(|error| ServiceError::Record {
3022                    operation: "write",
3023                    path: path.clone(),
3024                    detail: error.to_string(),
3025                })?;
3026        }
3027        temporary
3028            .persist(&path)
3029            .map(|_| ())
3030            .map_err(|error| ServiceError::Record {
3031                operation: "write",
3032                path,
3033                detail: error.error.to_string(),
3034            })
3035    }
3036
3037    /// Removes the record. Returns whether there was one.
3038    ///
3039    /// # Errors
3040    ///
3041    /// [`ServiceError::Record`].
3042    pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
3043        let path = Self::path(paths);
3044        match std::fs::remove_file(&path) {
3045            Ok(()) => Ok(true),
3046            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
3047            Err(error) => Err(ServiceError::Record {
3048                operation: "remove",
3049                path,
3050                detail: error.to_string(),
3051            }),
3052        }
3053    }
3054}
3055
3056// ---------------------------------------------------------------------------
3057// The last successful GitHub contact
3058// ---------------------------------------------------------------------------
3059
3060/// The heartbeat file's own schema version.
3061const CONTACT_SCHEMA_VERSION: u32 = 1;
3062
3063#[derive(Debug, Clone, Serialize, Deserialize)]
3064struct ContactRecord {
3065    schema_version: u32,
3066    last_success: DateTime<Utc>,
3067}
3068
3069/// Records that GitHub was reached successfully, for `service status` to
3070/// report.
3071///
3072/// # The contract, for `f3` and for the agent
3073///
3074/// Journey 5 step 4 requires `service status` to report *"the last successful
3075/// GitHub contact"*, and `service status` runs in the operator's terminal while
3076/// the daemon runs in a service. They share no memory, so the fact has to be on
3077/// disk, and this is the file. **The daemon calls this after each successful
3078/// GitHub call; nothing else writes it.**
3079///
3080/// It is a single timestamp and deliberately not a log: an operator asking this
3081/// question wants to know whether the agent is alive and reaching GitHub *now*,
3082/// and a value that is minutes old answers it. Writing it is a whole-file
3083/// replace through a temporary in the same directory, so a status command can
3084/// never read a half-written timestamp.
3085///
3086/// # Errors
3087///
3088/// [`ServiceError::Record`] when `state/` cannot be written.
3089pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3090    write_state_record(
3091        &contact_path(paths),
3092        &ContactRecord {
3093            schema_version: CONTACT_SCHEMA_VERSION,
3094            last_success: at,
3095        },
3096    )
3097}
3098
3099/// Reads the last successful GitHub contact, or reports that none was recorded.
3100///
3101/// `Ok(None)` is the honest answer on a host whose agent has never run, and it
3102/// is what `service status` prints rather than a zero timestamp.
3103///
3104/// # Errors
3105///
3106/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3107/// A malformed heartbeat is reported rather than treated as absence: absence
3108/// means *"the agent has never reached GitHub"*, and reporting a parse failure
3109/// as that would be a wrong answer to the question Journey 5 asks.
3110pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3111    Ok(read_state_record::<ContactRecord>(&contact_path(paths))?.map(|record| record.last_success))
3112}
3113
3114/// Where the heartbeat lives.
3115#[must_use]
3116pub fn contact_path(paths: &AppPaths) -> PathBuf {
3117    paths.state_dir().join(CONTACT_FILE)
3118}
3119
3120/// Writes a small TOML record under `state/`, through a temporary in the same
3121/// directory and a rename, so a status command never reads half of one.
3122pub(crate) fn write_state_record(path: &Path, record: &impl Serialize) -> Result<(), ServiceError> {
3123    let failed = |detail: String| ServiceError::Record {
3124        operation: "write",
3125        path: path.to_path_buf(),
3126        detail,
3127    };
3128    let text = toml::to_string_pretty(record).map_err(|error| failed(error.to_string()))?;
3129    let directory = path.parent().unwrap_or_else(|| Path::new("."));
3130    std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3131    let temporary = path.with_extension("toml.new");
3132    std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3133    std::fs::rename(&temporary, path).map_err(|error| failed(error.to_string()))
3134}
3135
3136/// Reads a record [`write_state_record`] wrote. `Ok(None)` when there is none;
3137/// one that exists and does not parse is an error, never absence.
3138pub(crate) fn read_state_record<T: serde::de::DeserializeOwned>(
3139    path: &Path,
3140) -> Result<Option<T>, ServiceError> {
3141    let failed = |detail: String| ServiceError::Record {
3142        operation: "read",
3143        path: path.to_path_buf(),
3144        detail,
3145    };
3146    let text = match std::fs::read_to_string(path) {
3147        Ok(text) => text,
3148        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3149        Err(error) => return Err(failed(error.to_string())),
3150    };
3151    toml::from_str(&text)
3152        .map(Some)
3153        .map_err(|error| failed(error.to_string()))
3154}
3155
3156/// Removes a record; one that is not there is already removed.
3157pub(crate) fn remove_state_record(path: &Path) -> Result<(), ServiceError> {
3158    match std::fs::remove_file(path) {
3159        Ok(()) => Ok(()),
3160        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3161        Err(error) => Err(ServiceError::Record {
3162            operation: "remove",
3163            path: path.to_path_buf(),
3164            detail: error.to_string(),
3165        }),
3166    }
3167}
3168
3169// ---------------------------------------------------------------------------
3170// The credential GitHub rejected
3171// ---------------------------------------------------------------------------
3172
3173#[derive(Debug, Serialize, Deserialize)]
3174#[serde(deny_unknown_fields)]
3175struct CredentialRejectionRecord {
3176    schema_version: u32,
3177    rejected_since: DateTime<Utc>,
3178}
3179
3180/// Records that GitHub rejected the daemon's credential, keeping the first
3181/// moment of an ongoing rejection.
3182///
3183/// # Why a file of its own
3184///
3185/// A rejected credential used to be visible only in the daemon's log: the
3186/// contact heartbeat simply stopped moving, `service status` still printed
3187/// `healthy`, and the TUI -- which authenticates with its own, perhaps freshly
3188/// renewed, copy -- showed nothing at all while the service started no runner
3189/// for hours. Separate from [`CONTACT_FILE`] so that an older build reading
3190/// that file is not handed a field it refuses.
3191///
3192/// # Errors
3193///
3194/// [`ServiceError::Record`] when `state/` cannot be written.
3195pub fn record_github_credential_rejected(
3196    paths: &AppPaths,
3197    at: DateTime<Utc>,
3198) -> Result<(), ServiceError> {
3199    if github_credential_rejected_since(paths)
3200        .ok()
3201        .flatten()
3202        .is_some()
3203    {
3204        return Ok(());
3205    }
3206    write_state_record(
3207        &credential_rejection_path(paths),
3208        &CredentialRejectionRecord {
3209            schema_version: CONTACT_SCHEMA_VERSION,
3210            rejected_since: at,
3211        },
3212    )
3213}
3214
3215/// Clears a recorded rejection once GitHub accepts the credential again.
3216///
3217/// # Errors
3218///
3219/// [`ServiceError::Record`] when the record exists and cannot be removed.
3220pub fn clear_github_credential_rejection(paths: &AppPaths) -> Result<(), ServiceError> {
3221    remove_state_record(&credential_rejection_path(paths))
3222}
3223
3224/// Since when GitHub has been rejecting the daemon's credential, if it is.
3225///
3226/// # Errors
3227///
3228/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3229pub fn github_credential_rejected_since(
3230    paths: &AppPaths,
3231) -> Result<Option<DateTime<Utc>>, ServiceError> {
3232    Ok(
3233        read_state_record::<CredentialRejectionRecord>(&credential_rejection_path(paths))?
3234            .map(|record| record.rejected_since),
3235    )
3236}
3237
3238/// Where the rejection record lives.
3239#[must_use]
3240pub fn credential_rejection_path(paths: &AppPaths) -> PathBuf {
3241    paths.state_dir().join(CREDENTIAL_REJECTION_FILE)
3242}
3243
3244#[derive(Debug, Serialize, Deserialize)]
3245#[serde(deny_unknown_fields)]
3246struct CredentialUnreadableRecord {
3247    schema_version: u32,
3248    unreadable_since: DateTime<Utc>,
3249}
3250
3251/// Records that the daemon could not read its stored credential, keeping the
3252/// first moment of an ongoing failure.
3253///
3254/// # Why a file of its own
3255///
3256/// A daemon that cannot read its credential exits, and the service manager
3257/// starts it again a few seconds later, for ever. `service status` reads the
3258/// registration, which is fine, and the heartbeat, which merely stops moving --
3259/// so it said `healthy` while a macOS host that had just been upgraded was
3260/// locked out of its own keychain item and started no runner. The reason was
3261/// only in the daemon's log. The daemon is the one process whose view of the
3262/// store matters, and it shares no memory with `service status`, so it says
3263/// so on disk; [`clear_credential_unreadable`] removes it as soon as a read
3264/// succeeds.
3265///
3266/// # Errors
3267///
3268/// [`ServiceError::Record`] when `state/` cannot be written.
3269pub fn record_credential_unreadable(
3270    paths: &AppPaths,
3271    at: DateTime<Utc>,
3272) -> Result<(), ServiceError> {
3273    if credential_unreadable_since(paths).ok().flatten().is_some() {
3274        return Ok(());
3275    }
3276    write_state_record(
3277        &credential_unreadable_path(paths),
3278        &CredentialUnreadableRecord {
3279            schema_version: CONTACT_SCHEMA_VERSION,
3280            unreadable_since: at,
3281        },
3282    )
3283}
3284
3285/// Clears a recorded failure once the daemon reads its credential again.
3286///
3287/// # Errors
3288///
3289/// [`ServiceError::Record`] when the record exists and cannot be removed.
3290pub fn clear_credential_unreadable(paths: &AppPaths) -> Result<(), ServiceError> {
3291    remove_state_record(&credential_unreadable_path(paths))
3292}
3293
3294/// Since when the daemon has been unable to read its stored credential, if it
3295/// has.
3296///
3297/// # Errors
3298///
3299/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3300pub fn credential_unreadable_since(
3301    paths: &AppPaths,
3302) -> Result<Option<DateTime<Utc>>, ServiceError> {
3303    Ok(
3304        read_state_record::<CredentialUnreadableRecord>(&credential_unreadable_path(paths))?
3305            .map(|record| record.unreadable_since),
3306    )
3307}
3308
3309/// Where the unreadable-credential record lives.
3310#[must_use]
3311pub fn credential_unreadable_path(paths: &AppPaths) -> PathBuf {
3312    paths.state_dir().join(CREDENTIAL_UNREADABLE_FILE)
3313}
3314
3315/// How to sign this host in again for `mode`: the exact command, in
3316/// backticks, and where to run it when that matters.
3317///
3318/// A machine-scoped store needs privilege: `sudo` on Unix, an elevated
3319/// terminal on Windows. A login-mode store on macOS is the login keychain,
3320/// which is unlocked for the desktop session only, so an SSH session cannot
3321/// write it.
3322#[must_use]
3323pub fn sign_in_instruction(mode: StartMode) -> String {
3324    let (command, place) = match mode {
3325        StartMode::Boot if cfg!(unix) => ("sudo runner-manager auth login --start-at boot", ""),
3326        StartMode::Boot => (
3327            "runner-manager auth login --start-at boot",
3328            " from an elevated terminal",
3329        ),
3330        StartMode::Login => (
3331            "runner-manager auth login --start-at login",
3332            MACOS_LOGIN_PLACE,
3333        ),
3334    };
3335    format!("`{command}`{place}")
3336}
3337
3338/// Where a login-mode sign-in has to happen on macOS, as a phrase to follow
3339/// the command; nothing elsewhere.
3340pub const MACOS_LOGIN_PLACE: &str = if cfg!(target_os = "macos") {
3341    " in a Terminal in this Mac's desktop session (an SSH session cannot unlock the login \
3342     keychain)"
3343} else {
3344    ""
3345};
3346
3347// ---------------------------------------------------------------------------
3348// A definition written by an older build
3349// ---------------------------------------------------------------------------
3350
3351/// An installed service definition that differs from what this build renders
3352/// for the same recorded installation.
3353///
3354/// # Why this exists
3355///
3356/// An upgrade replaces the binary a registration runs and nothing else. A
3357/// systemd unit written by 0.4.7 still carried `LoadCredential=`, which hands
3358/// the daemon a copy of the credential frozen at service start, and lacked
3359/// write access to the credential store. Every release after the fix kept
3360/// running under that unit: the daemon could neither renew the token nor see
3361/// one renewed by anybody else, and stopped starting runners the moment the
3362/// frozen access token expired.
3363#[derive(Debug, Clone, PartialEq, Eq)]
3364pub struct DefinitionDrift {
3365    /// The definition file on disk.
3366    pub path: PathBuf,
3367    /// The build that wrote it, as its install record says.
3368    pub installed_by_version: String,
3369    /// What this build renders in its place.
3370    pub rendered: String,
3371    /// The start mode it was installed for.
3372    pub start_mode: StartMode,
3373    /// Which manager reads the definition.
3374    pub manager: DefinitionKind,
3375}
3376
3377/// Compares the installed definition against this build's rendering.
3378///
3379/// A systemd unit is compared byte for byte. A launchd plist is compared on
3380/// `ProcessType` alone (see [`launchd_definition_drift`]). `None` for every
3381/// other manager, for a definition that cannot be read, and for one that
3382/// already matches. The file is read through `controls`, so a double never
3383/// compares against a real host's definition.
3384fn definition_drift(
3385    controls: &dyn ControlFactory,
3386    identity: &ServiceIdentity,
3387    record: &InstallRecord,
3388) -> Option<DefinitionDrift> {
3389    let compare = match host_definition_kind(record.start_mode) {
3390        DefinitionKind::SystemdUnit => systemd_definition_drift,
3391        DefinitionKind::LaunchdPlist => launchd_definition_drift,
3392        DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => return None,
3393    };
3394    let path = record.definition_path.clone()?;
3395    let installed = controls.installed_definition(&path)?;
3396    compare(identity, record, path, &installed)
3397}
3398
3399/// A plist whose `ProcessType` is not [`LAUNCHD_PROCESS_TYPE`], with the plist
3400/// this build renders in its place.
3401///
3402/// Only that one key, unlike the systemd comparison. The daemon repairs this
3403/// drift by itself (`daemon.rs`, `converge_launchd_definition`), so a
3404/// whole-file comparison would make it rewrite and reload over every edit an
3405/// operator made to the plist. A plist that does carry the wrong value is
3406/// replaced whole, as `service install` would replace it.
3407fn launchd_definition_drift(
3408    identity: &ServiceIdentity,
3409    record: &InstallRecord,
3410    path: PathBuf,
3411    installed: &str,
3412) -> Option<DefinitionDrift> {
3413    (plist_string_value(installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE)).then(
3414        || DefinitionDrift {
3415            path,
3416            installed_by_version: record.installed_by_version.clone(),
3417            rendered: launchd_plist(&record.plan(identity.clone())),
3418            start_mode: record.start_mode,
3419            manager: DefinitionKind::LaunchdPlist,
3420        },
3421    )
3422}
3423
3424fn systemd_definition_drift(
3425    identity: &ServiceIdentity,
3426    record: &InstallRecord,
3427    path: PathBuf,
3428    installed: &str,
3429) -> Option<DefinitionDrift> {
3430    let rendered = systemd_unit(&record.plan(identity.clone()));
3431    (installed != rendered).then(|| DefinitionDrift {
3432        path,
3433        installed_by_version: record.installed_by_version.clone(),
3434        rendered,
3435        start_mode: record.start_mode,
3436        manager: DefinitionKind::SystemdUnit,
3437    })
3438}
3439
3440// ---------------------------------------------------------------------------
3441// The last runner-root refusal
3442// ---------------------------------------------------------------------------
3443
3444const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
3445
3446#[derive(Debug, Clone, Serialize, Deserialize)]
3447struct RootRefusalFile {
3448    schema_version: u32,
3449    /// Keyed by policy, because the fact is per-policy and not per-host.
3450    #[serde(default)]
3451    refusals: BTreeMap<String, RootRefusalEntry>,
3452}
3453
3454#[derive(Debug, Clone, Serialize, Deserialize)]
3455struct RootRefusalEntry {
3456    at: DateTime<Utc>,
3457    kind: String,
3458    root: String,
3459    detail: String,
3460}
3461
3462/// One policy's last runner-root refusal, as `service status` reports it.
3463#[derive(Debug, Clone, PartialEq, Eq)]
3464pub struct RunnerRootRefusal {
3465    /// The policy that placed no runner.
3466    pub policy: String,
3467    /// When it was last refused.
3468    pub at: DateTime<Utc>,
3469    /// The closed-vocabulary cause, as `RunnerRootError::kind` names it.
3470    pub kind: String,
3471    /// The root it was refused.
3472    pub root: String,
3473    /// The refusal in full, including the remediation. Not redacted: this file
3474    /// is read by a command in the operator's terminal, not by the log sink.
3475    pub detail: String,
3476}
3477
3478/// Records that one policy could not use its runner root.
3479///
3480/// # The contract, and why a file
3481///
3482/// A launch the runner root refuses fails before any attempt row exists, so the
3483/// journal cannot carry it and no screen can show it. The daemon's own log
3484/// cannot either: it redacts every field it does not allow-list and scrubs
3485/// anything path-shaped out of the ones it does, which is most of a
3486/// `RunnerRootError`. So the sentence an operator needs — the directory and the
3487/// remediation, verbatim — has nowhere to go, and the failure reads
3488/// `runner_start_failed reason=other` once per poll for as long as it lasts.
3489/// That is not hypothetical: it cost three hours.
3490///
3491/// **Keyed by policy, and that is load-bearing.** A host runs several policies
3492/// and they do not share a fate: `allocate_persistent_slot` tolerates an
3493/// unresolvable host default, so a repository with its own persistent root
3494/// places runners happily while an ephemeral policy on a withheld volume places
3495/// none. A single host-wide slot would have the working policy clear the broken
3496/// one's record on the same reconcile pass, and `service status` would report a
3497/// healthy host that had started zero runners for an entire target.
3498///
3499/// **The daemon writes this when a root refuses a launch and clears the policy's
3500/// entry when one succeeds; nothing else writes it.** It keeps the last refusal
3501/// per policy rather than a history, for the reason [`record_github_contact`] is
3502/// a single timestamp: the operator is asking whether runners can be placed
3503/// *now*.
3504///
3505/// Written whole through a temporary in the same directory, so a status command
3506/// cannot read half a record.
3507///
3508/// # Errors
3509///
3510/// [`ServiceError::Record`] when `state/` cannot be read or written.
3511pub fn record_runner_root_refusal(
3512    paths: &AppPaths,
3513    policy: &str,
3514    at: DateTime<Utc>,
3515    kind: &str,
3516    root: &str,
3517    detail: &str,
3518) -> Result<(), ServiceError> {
3519    let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
3520        schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
3521        refusals: BTreeMap::new(),
3522    });
3523    file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
3524    file.refusals.insert(
3525        policy.to_owned(),
3526        RootRefusalEntry {
3527            at,
3528            kind: kind.to_owned(),
3529            root: root.to_owned(),
3530            detail: detail.to_owned(),
3531        },
3532    );
3533    write_refusal_file(paths, &file)
3534}
3535
3536/// Clears one policy's entry, because that policy placed a runner.
3537///
3538/// Removes the file once the last entry goes, so a host that is working leaves
3539/// nothing behind for a later `service status` to report. A policy with no entry
3540/// is not an error: this is called on every successful placement, and almost
3541/// every one of those follows another success.
3542///
3543/// # Errors
3544///
3545/// [`ServiceError::Record`] when `state/` cannot be read or written.
3546pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
3547    let Some(mut file) = read_refusal_file(paths)? else {
3548        return Ok(());
3549    };
3550    if file.refusals.remove(policy).is_none() {
3551        return Ok(());
3552    }
3553    if file.refusals.is_empty() {
3554        let path = root_refusal_path(paths);
3555        return match std::fs::remove_file(&path) {
3556            Ok(()) => Ok(()),
3557            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3558            Err(error) => Err(ServiceError::Record {
3559                operation: "remove",
3560                path,
3561                detail: error.to_string(),
3562            }),
3563        };
3564    }
3565    write_refusal_file(paths, &file)
3566}
3567
3568/// Every policy whose runner root refused it and has not since succeeded.
3569///
3570/// Ordered by policy, so two runs of `service status` on an unchanged host
3571/// print the same thing in the same order.
3572///
3573/// # Errors
3574///
3575/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3576/// Reported rather than treated as absence, for the reason
3577/// [`last_github_contact`] gives: absence means "every policy is placing
3578/// runners", and answering a parse failure with that would be a wrong answer.
3579pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
3580    Ok(read_refusal_file(paths)?
3581        .map(|file| {
3582            file.refusals
3583                .into_iter()
3584                .map(|(policy, entry)| RunnerRootRefusal {
3585                    policy,
3586                    at: entry.at,
3587                    kind: entry.kind,
3588                    root: entry.root,
3589                    detail: entry.detail,
3590                })
3591                .collect()
3592        })
3593        .unwrap_or_default())
3594}
3595
3596fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
3597    read_state_record(&root_refusal_path(paths))
3598}
3599
3600fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
3601    write_state_record(&root_refusal_path(paths), file)
3602}
3603
3604/// Where the refusals live.
3605#[must_use]
3606pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
3607    paths.state_dir().join(ROOT_REFUSAL_FILE)
3608}
3609
3610// ---------------------------------------------------------------------------
3611// The recorded binary path
3612// ---------------------------------------------------------------------------
3613
3614/// What became of the absolute path `install` recorded.
3615///
3616/// `05-infrastructure.md` item 6 in one type. Three of the four variants are
3617/// errors, and `service status` reports them as errors rather than as health —
3618/// which is the whole point, because the npm case produces a service that looks
3619/// installed, is registered, and cannot start.
3620#[derive(Debug, Clone, PartialEq, Eq)]
3621pub enum BinaryPath {
3622    /// The recorded path is a file, and the service manager still starts it.
3623    Current {
3624        /// The path.
3625        path: PathBuf,
3626    },
3627    /// **Nothing is at the recorded path.**
3628    ///
3629    /// This is the npm upgrade: `npm i -g @ivan-murzak/runner-manager` puts the binary under
3630    /// the active Node installation's global prefix, and switching Node versions
3631    /// with `nvm`, `fnm`, or `volta` moves that prefix. The service is still
3632    /// registered, still set to start at boot, and starts nothing.
3633    Missing {
3634        /// The path the record names.
3635        recorded: PathBuf,
3636    },
3637    /// Something is at the recorded path, but it is not a file the service
3638    /// manager could start.
3639    NotExecutable {
3640        /// The path the record names.
3641        recorded: PathBuf,
3642        /// What is there instead.
3643        detail: String,
3644    },
3645    /// The record and the service manager name different binaries.
3646    Diverged {
3647        /// What the record says.
3648        recorded: PathBuf,
3649        /// What the service manager is registered to start.
3650        registered: PathBuf,
3651    },
3652}
3653
3654impl BinaryPath {
3655    /// Whether this is a state `service status` must report as an error.
3656    #[must_use]
3657    pub const fn is_error(&self) -> bool {
3658        !matches!(self, Self::Current { .. })
3659    }
3660
3661    /// The path the record names, whatever state it is in.
3662    #[must_use]
3663    pub fn recorded(&self) -> &Path {
3664        match self {
3665            Self::Current { path } => path,
3666            Self::Missing { recorded }
3667            | Self::NotExecutable { recorded, .. }
3668            | Self::Diverged { recorded, .. } => recorded,
3669        }
3670    }
3671}
3672
3673impl fmt::Display for BinaryPath {
3674    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3675        match self {
3676            Self::Current { path } => write!(f, "{}", path.display()),
3677            Self::Missing { recorded } => write!(
3678                f,
3679                "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
3680                 package manager that moved the binary is the usual cause; an `npm i -g` \
3681                 installation moves with the active Node version. Run `service install` again \
3682                 from the binary that is now installed.",
3683                recorded.display()
3684            ),
3685            Self::NotExecutable { recorded, detail } => write!(
3686                f,
3687                "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
3688                 from the installed binary.",
3689                recorded.display()
3690            ),
3691            Self::Diverged {
3692                recorded,
3693                registered,
3694            } => write!(
3695                f,
3696                "{} -- STALE: the service manager is registered to start {} instead. Something \
3697                 has edited the registration since it was installed. Run `service uninstall` and \
3698                 `service install`; neither touches configuration, secrets, or the cache.",
3699                recorded.display(),
3700                registered.display()
3701            ),
3702        }
3703    }
3704}
3705
3706/// Decides what became of a recorded path.
3707///
3708/// `registered` is what the service manager says it will start, when the
3709/// platform can be asked. Order matters and is deliberate: **absence is checked
3710/// first**, because a missing binary is the failure item 6 exists for and an
3711/// operator hearing "the registration disagrees with the record" about a file
3712/// that is not there would be sent to the wrong problem.
3713#[must_use]
3714pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
3715    match std::fs::metadata(recorded) {
3716        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3717            return BinaryPath::Missing {
3718                recorded: recorded.to_path_buf(),
3719            };
3720        }
3721        Err(error) => {
3722            return BinaryPath::NotExecutable {
3723                recorded: recorded.to_path_buf(),
3724                detail: format!("it cannot be inspected ({error})"),
3725            };
3726        }
3727        Ok(metadata) if !metadata.is_file() => {
3728            return BinaryPath::NotExecutable {
3729                recorded: recorded.to_path_buf(),
3730                detail: "what is there is not a file".to_string(),
3731            };
3732        }
3733        Ok(_) => {}
3734    }
3735    if let Some(registered) = registered
3736        && !same_path_text(&recorded.to_string_lossy(), &registered.to_string_lossy())
3737    {
3738        return BinaryPath::Diverged {
3739            recorded: recorded.to_path_buf(),
3740            registered: registered.to_path_buf(),
3741        };
3742    }
3743    BinaryPath::Current {
3744        path: recorded.to_path_buf(),
3745    }
3746}
3747
3748// ---------------------------------------------------------------------------
3749// The control seam
3750// ---------------------------------------------------------------------------
3751
3752/// What a service manager says about a registration it holds.
3753#[derive(Debug, Clone, PartialEq, Eq)]
3754pub struct Registration {
3755    /// Which manager holds it, which is also what decides the start mode:
3756    /// a Windows service is a boot registration and a Task Scheduler task is a
3757    /// login one; a LaunchDaemon and a system unit are boot, a LaunchAgent and
3758    /// a user unit are login.
3759    pub manager: DefinitionKind,
3760    /// The start mode the *domain* it was found in implies.
3761    pub start_mode: StartMode,
3762    /// The full command line, as the manager stores it.
3763    pub command_line: String,
3764    /// The account it runs under, when the manager reports one.
3765    pub account: Option<String>,
3766    /// Whether it is running now.
3767    pub running: bool,
3768    /// Whether it will start by itself.
3769    ///
3770    /// Distinct from [`Registration::start_mode`]: a Windows service can be
3771    /// registered in the boot domain and still be set to `demand` start, which
3772    /// is a service that exists, looks installed, and does not come back after
3773    /// a reboot.
3774    pub starts_automatically: bool,
3775    /// The restart-on-failure delay the manager reports, when it reports one.
3776    ///
3777    /// The *delay* rather than the whole [`RestartPolicy`], because that is the
3778    /// half every one of the four managers can be asked for. The failure-count
3779    /// reset window has no representation at all in Task Scheduler or launchd,
3780    /// so a `RestartPolicy` read back from them would carry one number the
3781    /// manager reported and one this module invented — and a comparison against
3782    /// an invented number is a comparison that cannot fail.
3783    pub restart_delay: Option<Duration>,
3784}
3785
3786impl Registration {
3787    /// The executable the manager will start, parsed out of the command line.
3788    #[must_use]
3789    pub fn binary(&self) -> Option<PathBuf> {
3790        executable_from_command_line(&self.command_line)
3791    }
3792}
3793
3794/// One platform's service manager, for one start-mode domain.
3795///
3796/// Every method takes the identity rather than storing it, so one control can
3797/// answer about the product's registration and about a test fixture's without
3798/// either being able to reach the other by accident.
3799pub trait ServiceControl: fmt::Debug {
3800    /// Which manager this is.
3801    fn manager(&self) -> DefinitionKind;
3802
3803    /// Registers the plan, returning the definition that was applied.
3804    ///
3805    /// # Errors
3806    ///
3807    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`].
3808    fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
3809
3810    /// Deregisters. Returns whether there was a registration to remove.
3811    ///
3812    /// **Removes the registration and nothing else.** No implementation of this
3813    /// method may delete a directory, a database, a secret, or a cache;
3814    /// `05-infrastructure.md` item 5 is a property of every backend, not a
3815    /// check somewhere above them.
3816    ///
3817    /// # Errors
3818    ///
3819    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`].
3820    fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3821
3822    /// What the manager knows about this registration, if anything.
3823    ///
3824    /// # Errors
3825    ///
3826    /// [`ServiceError::Control`].
3827    fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
3828
3829    /// Starts it now, without waiting for the next boot or logon.
3830    ///
3831    /// # Errors
3832    ///
3833    /// [`ServiceError::Control`], [`ServiceError::NeedsElevation`], or
3834    /// [`ServiceError::NotInstalled`].
3835    fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
3836
3837    /// Stops it. Returns whether it was running.
3838    ///
3839    /// # Errors
3840    ///
3841    /// As [`ServiceControl::start`].
3842    fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3843}
3844
3845/// Chooses the control for a start mode.
3846///
3847/// The indirection is what makes [`ServiceOperations`] testable: production
3848/// hands it [`HostControls`], every test in this file hands it
3849/// [`RecordingControls`], and neither knows the difference.
3850pub trait ControlFactory: fmt::Debug + Send + Sync {
3851    /// The control for one start-mode domain.
3852    ///
3853    /// # Errors
3854    ///
3855    /// [`ServiceError::Control`] when this host has no manager for that domain.
3856    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
3857
3858    /// The text of an installed definition file, for drift detection. `None`
3859    /// when there is none to read. The default reads nothing, so a double
3860    /// never reports the real host's unit as its own.
3861    fn installed_definition(&self, _path: &Path) -> Option<String> {
3862        None
3863    }
3864
3865    /// Whether this Mac signs somebody in after a restart, and whether
3866    /// FileVault holds it at the unlock screen. `None` where the platform has
3867    /// no such probe; the default probes nothing, for the same reason as
3868    /// [`Self::installed_definition`].
3869    fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
3870        None
3871    }
3872}
3873
3874/// The real service managers of the host this binary was built for.
3875#[derive(Debug, Clone, Copy, Default)]
3876pub struct HostControls;
3877
3878// ---------------------------------------------------------------------------
3879// The operations
3880// ---------------------------------------------------------------------------
3881
3882/// What `install` did.
3883#[derive(Debug, Clone)]
3884pub struct Installed {
3885    /// The plan that was applied, including the resolved absolute binary path.
3886    pub plan: InstallPlan,
3887    /// The definition the platform was given.
3888    pub definition: ServiceDefinition,
3889    /// The record that was written.
3890    pub record: InstallRecord,
3891    /// What the definition grants, measured against the requirement.
3892    pub review: PrivilegeReview,
3893    /// What was done to the runner root the registration will run jobs under.
3894    ///
3895    /// Named trustees rather than SIDs, so printing it adds no identity to the
3896    /// output. See [`crate::runner_root_access`].
3897    pub runner_root: RootAccessSummary,
3898    /// Whether this replaced a registration that was already there, rather than
3899    /// making a new one.
3900    ///
3901    /// Reported rather than glossed over: "Service installed" on a host that
3902    /// had one already reads as though nothing had been taken away and put
3903    /// back, and what was taken away and put back is the thing an operator
3904    /// watching a running agent wants to know about.
3905    pub replaced_existing: bool,
3906}
3907
3908/// What `uninstall` did — and, as importantly, what it did not.
3909#[derive(Debug, Clone, PartialEq, Eq)]
3910pub struct Uninstalled {
3911    /// Whether a registration was removed.
3912    pub removed_registration: bool,
3913    /// Whether the install record was removed.
3914    pub removed_record: bool,
3915    /// Whether a definition file was removed, and which.
3916    pub removed_definition: Option<PathBuf>,
3917    /// The directories that were **left exactly as they were**.
3918    ///
3919    /// `05-infrastructure.md` item 5 stated as a value rather than as a
3920    /// promise: `service uninstall` prints this list, so an operator can see
3921    /// that the configuration, the SQLite database, the stored token and the
3922    /// runner package cache are all still there.
3923    pub preserved: Vec<PathBuf>,
3924}
3925
3926impl fmt::Display for Uninstalled {
3927    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3928        if self.removed_registration {
3929            writeln!(f, "The service registration was removed.")?;
3930        } else {
3931            writeln!(f, "There was no service registration to remove.")?;
3932        }
3933        writeln!(f, "Nothing else was deleted. These are untouched:")?;
3934        for path in &self.preserved {
3935            writeln!(f, "  {}", path.display())?;
3936        }
3937        write!(
3938            f,
3939            "The stored GitHub token is untouched too; `auth logout` is what purges it."
3940        )
3941    }
3942}
3943
3944/// What `set_start_mode` did.
3945#[derive(Debug, Clone, PartialEq, Eq)]
3946pub struct StartModeChange {
3947    /// What it was.
3948    pub from: StartMode,
3949    /// What it is now.
3950    pub to: StartMode,
3951    /// `false` when the registration was already in the requested mode.
3952    pub changed: bool,
3953    /// The secret store the new mode obliges, so a caller can tell an operator
3954    /// whether the token has to move too.
3955    pub store_scope: crate::secrets::SecretScope,
3956    /// What the mode change did to the runner root's access control.
3957    ///
3958    /// A mode change moves the account the daemon runs as, and the runner root
3959    /// admits that account by name — so the two move together or the new
3960    /// registration cannot write its own workspaces.
3961    pub runner_root: RootAccessSummary,
3962}
3963
3964impl fmt::Display for StartModeChange {
3965    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3966        if !self.changed {
3967            return write!(f, "The service already starts at {}.", self.to);
3968        }
3969        write!(
3970            f,
3971            "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
3972             if the token was stored under the other scope, run `auth login` again. {}",
3973            self.to, self.from, self.store_scope, self.runner_root
3974        )
3975    }
3976}
3977
3978/// Install, uninstall, inspect, and switch start mode.
3979///
3980/// This is the whole of the library contract `f3` builds `service install`,
3981/// `service uninstall` and `service status` on. Nothing here prints; every
3982/// operation returns a value with a [`fmt::Display`] a command can write, so the
3983/// same facts are available to the TUI and to a JSON document without being
3984/// re-derived from text.
3985#[derive(Debug, Clone)]
3986pub struct ServiceOperations {
3987    paths: AppPaths,
3988    identity: ServiceIdentity,
3989    controls: std::sync::Arc<dyn ControlFactory>,
3990    runner_root: Option<LocalAbsolutePath>,
3991}
3992
3993impl ServiceOperations {
3994    /// Operates on this host's real service managers.
3995    #[must_use]
3996    pub fn on_this_host(paths: AppPaths) -> Self {
3997        Self::with_controls(
3998            paths,
3999            ServiceIdentity::product(),
4000            std::sync::Arc::new(HostControls),
4001        )
4002    }
4003
4004    /// Operates on the controls a caller supplies.
4005    ///
4006    /// Both other arguments are explicit for the same reason: the privileged
4007    /// installer tests register a real service under a
4008    /// [`ServiceIdentity::fixture`] name, against a disposable
4009    /// [`AppPaths::rooted_at`] tree, and must not be able to touch an operator's
4010    /// installation even by mistake.
4011    #[must_use]
4012    pub fn with_controls(
4013        paths: AppPaths,
4014        identity: ServiceIdentity,
4015        controls: std::sync::Arc<dyn ControlFactory>,
4016    ) -> Self {
4017        Self {
4018            paths,
4019            identity,
4020            controls,
4021            runner_root: None,
4022        }
4023    }
4024
4025    /// Points runner-root preparation at a directory the caller owns, for the
4026    /// **current account**.
4027    ///
4028    /// **Only a [`ServiceIdentity::fixture`] registration is allowed to move
4029    /// it, and the guard is here rather than at the call site.** A product
4030    /// registration ignores the override entirely and resolves
4031    /// [`crate::runner_root::default_runner_root`] itself, which is what keeps
4032    /// "custom roots are never re-ACLed" true no matter what a caller passes.
4033    ///
4034    /// It exists because the directory the product uses is
4035    /// `%SystemDrive%\rman`, and a smoke test that created and re-permissioned
4036    /// *that* would be editing the machine it runs on from outside its own
4037    /// fixture. So a fixture aims this at a temporary directory it created and
4038    /// will delete — and "and will delete" is why the override is always
4039    /// reconciled for the calling account rather than for the account the start
4040    /// mode obliges. A boot-mode root admits `SY` and `BA` only, which an
4041    /// ordinary filtered token is neither, so a test that supplied one could
4042    /// not then inspect or remove its own temporary directory.
4043    #[must_use]
4044    pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
4045        self.runner_root = Some(root);
4046        self
4047    }
4048
4049    /// The directories this operates against.
4050    #[must_use]
4051    pub const fn paths(&self) -> &AppPaths {
4052        &self.paths
4053    }
4054
4055    /// The registration this operates on.
4056    #[must_use]
4057    pub const fn identity(&self) -> &ServiceIdentity {
4058        &self.identity
4059    }
4060
4061    /// Registers `daemon run` with the operating system.
4062    ///
4063    /// The order is the contract, and each step is a requirement:
4064    ///
4065    /// 1. the four directories are created, because item 2 is about writing them;
4066    /// 2. **the single-instance lock is taken** — item 1. It is held for the
4067    ///    whole of the install rather than probed and released, so a daemon that
4068    ///    starts halfway through cannot end up racing a registration;
4069    /// 3. neither start-mode domain already holds a registration — item 6's
4070    ///    record would otherwise describe one of two;
4071    /// 4. the binary path is resolved and confirmed to be a file — item 6;
4072    /// 5. the platform registers it;
4073    /// 6. the record is written.
4074    ///
4075    /// # Errors
4076    ///
4077    /// [`ServiceError::LockHeld`] when an agent is already running,
4078    /// [`ServiceError::AlreadyInstalled`], [`ServiceError::BinaryMissing`], and
4079    /// whatever the platform reports.
4080    pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
4081        self.paths
4082            .create_all()
4083            .map_err(|source| ServiceError::Paths {
4084                source: Box::new(source),
4085            })?;
4086
4087        // Item 1. The guard lives until the end of this function; dropping it is
4088        // the release, so an early return releases it too.
4089        let _guard = self.refuse_while_an_agent_runs()?;
4090
4091        // --------------------------------------------------------------------
4092        // AN INSTALL OVER THE SAME START MODE REPLACES; IT DOES NOT REFUSE.
4093        // --------------------------------------------------------------------
4094        // This used to refuse any existing registration, and that was wrong in
4095        // a way that took a real host down.
4096        //
4097        // `service install` is *also* how an operator moves to a new version by
4098        // hand — `install_owned_copy` says so, and the `.old` rename it does
4099        // exists precisely so that reinstalling over a **running** service
4100        // works. So the caller replaces the copy the service runs and *then*
4101        // asks to register it. Refusing at that point does not undo the swap:
4102        // the daemon is left running a binary nobody registered, the command
4103        // reports failure, and on macOS the new binary is a program the stored
4104        // credential's keychain grant does not name. Watched on 0.1.17: the
4105        // copy under `state/bin` was replaced at 00:16, `service install` said
4106        // "already registered", and the daemon crash-looped on `-25293` from
4107        // then on.
4108        //
4109        // Registering the same service twice is not what an operator is asking
4110        // for here and is not what they get: the platform is asked to drop the
4111        // registration and take it again, which is a deregister followed by a
4112        // register because that is the only thing launchd, systemd and the SCM
4113        // all support — `launchctl bootstrap` refuses a label already loaded.
4114        //
4115        // The *other* mode is still refused. Moving between boot and login
4116        // moves the registration between two service managers and changes the
4117        // account and the secret store with it; `set_start_mode` does that,
4118        // carefully, and an install is not the place for it.
4119        let (replacing, previous_was_running) = match self.find_registration()? {
4120            Some((existing, registration)) if existing == request.start_mode() => {
4121                (true, registration.running)
4122            }
4123            Some((existing, _)) => {
4124                return Err(ServiceError::AlreadyInstalled {
4125                    name: self.identity.name().to_string(),
4126                    existing,
4127                    requested: request.start_mode(),
4128                });
4129            }
4130            None => (false, false),
4131        };
4132
4133        let plan = InstallPlan::resolve(
4134            self.identity.clone(),
4135            request,
4136            ServiceDirectories::of(&self.paths),
4137        )?;
4138
4139        // Resolved before the runner root, and used after it: every step from
4140        // here on has to be able to undo the directory, and a `?` between the
4141        // preparation and the first fallible use would leave one behind with
4142        // nothing to say so.
4143        let control = self.controls.control(plan.start_mode())?;
4144
4145        // Item 8, and the one step that can refuse an otherwise valid install.
4146        // Before the platform is asked to register anything, because a
4147        // registration whose workspaces would land in a directory ordinary local
4148        // users can write is one that should never have existed — and because
4149        // undoing a directory is cheaper than undoing a service.
4150        let root = self.prepare_runner_root(plan.start_mode())?;
4151
4152        // Read before the registration is dropped, so that a replace which then
4153        // fails to take can put back what was there. A record this account
4154        // cannot read leaves nothing to reinstate, which is reported rather
4155        // than pretended: see `reinstate`.
4156        let previous = if replacing {
4157            InstallRecord::read(&self.paths).ok().flatten()
4158        } else {
4159            None
4160        };
4161        if replacing && let Err(cause) = control.uninstall(&self.identity) {
4162            return Err(undo_runner_root(&root, "install", &self.identity, cause));
4163        }
4164
4165        let definition = match control.install(&plan) {
4166            Ok(definition) => definition,
4167            Err(cause) => {
4168                // Nothing was deregistered on a first install, so there is
4169                // nothing to put back and `reinstate` says so with `Ok(())`.
4170                let restored =
4171                    self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running);
4172                return Err(rolled_back(
4173                    retained_runner_root(&root),
4174                    restored,
4175                    "install",
4176                    &self.identity,
4177                    cause,
4178                ));
4179            }
4180        };
4181        let review = review_least_privilege(&definition, &plan);
4182        let record = InstallRecord::of(&plan, &definition, Utc::now());
4183        if let Err(cause) = record.write(&self.paths) {
4184            let rollback = control
4185                .uninstall(&self.identity)
4186                .map(|_| ())
4187                .and_then(|()| {
4188                    self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4189                });
4190            return Err(rolled_back(
4191                retained_runner_root(&root),
4192                rollback,
4193                "install",
4194                &self.identity,
4195                cause,
4196            ));
4197        }
4198
4199        // Registration is not the postcondition of an install: a service the
4200        // current session still has to start by hand is only half-installed.
4201        // This matters most for a logon-triggered Windows task because its
4202        // trigger has already passed by the time an operator reinstalls it.
4203        // Start through the same control that registered the definition and
4204        // roll the whole replacement back if the manager refuses it.
4205        if let Err(cause) = control.start(&self.identity) {
4206            let remove_new = control.uninstall(&self.identity).map(|_| ());
4207            let restore_registration = remove_new.and_then(|()| {
4208                self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4209            });
4210            let restore_record = restore_registration.and_then(|()| match &previous {
4211                Some(previous) => previous.write(&self.paths),
4212                None => InstallRecord::remove(&self.paths).map(|_| ()),
4213            });
4214            return Err(rolled_back(
4215                retained_runner_root(&root),
4216                restore_record,
4217                "install and start",
4218                &self.identity,
4219                cause,
4220            ));
4221        }
4222        Ok(Installed {
4223            plan,
4224            definition,
4225            record,
4226            review,
4227            runner_root: root.summary().clone(),
4228            replaced_existing: replacing,
4229        })
4230    }
4231
4232    /// Puts back a registration this call deregistered in order to replace it.
4233    ///
4234    /// `Ok(())` when there was nothing to put back, which is the ordinary case:
4235    /// a first install deregisters nothing. When there *was* a record, the plan
4236    /// is rebuilt from it rather than from the request, because the request is
4237    /// the one that just failed.
4238    ///
4239    /// Best effort by construction — the caller passes the result to
4240    /// [`rolled_back`], which reports a failed rollback alongside the original
4241    /// cause rather than replacing it.
4242    fn reinstate(
4243        &self,
4244        control: &dyn ServiceControl,
4245        previous: Option<&InstallRecord>,
4246        was_running: bool,
4247    ) -> Result<(), ServiceError> {
4248        let Some(record) = previous else {
4249            return Ok(());
4250        };
4251        control.install(&record.plan(self.identity.clone()))?;
4252        if was_running {
4253            control.start(&self.identity)?;
4254        }
4255        Ok(())
4256    }
4257
4258    /// Deregisters, and deletes nothing else.
4259    ///
4260    /// # Errors
4261    ///
4262    /// Whatever the platform reports. A missing registration is **not** an
4263    /// error: `service uninstall` on a host that has none should say so and
4264    /// exit cleanly, because an operator running it twice has not made a
4265    /// mistake.
4266    pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
4267        let record = InstallRecord::read(&self.paths).ok().flatten();
4268        let removed_definition = record
4269            .as_ref()
4270            .and_then(|record| record.definition_path.clone());
4271
4272        let mut removed_registration = false;
4273        // Both domains, not only the recorded one. A record can be lost while a
4274        // registration survives, and leaving a registration behind because a
4275        // TOML file was deleted is exactly the state `uninstall` exists to end.
4276        for mode in [StartMode::Boot, StartMode::Login] {
4277            let control = self.controls.control(mode)?;
4278            if control.uninstall(&self.identity)? {
4279                removed_registration = true;
4280            }
4281        }
4282        let removed_record = InstallRecord::remove(&self.paths)?;
4283        Ok(Uninstalled {
4284            removed_registration,
4285            removed_record,
4286            removed_definition: removed_definition.filter(|_| removed_registration),
4287            preserved: self
4288                .paths
4289                .all()
4290                .iter()
4291                .map(|(_, path)| (*path).to_path_buf())
4292                .collect(),
4293        })
4294    }
4295
4296    /// Switches between `boot` and `login` **without re-resolving anything**.
4297    ///
4298    /// `05-infrastructure.md` item 7. Everything the new registration carries —
4299    /// the absolute binary path, the arguments, the restart policy, the four
4300    /// directories — comes from the existing record, so the operator is not
4301    /// asked to reinstall the product and a binary that has since moved is not
4302    /// silently swapped for whichever one happens to be running this command.
4303    ///
4304    /// On Windows the registration necessarily moves between two different
4305    /// Windows facilities, because Windows has no service that starts at logon;
4306    /// see this module's documentation. That is still not a reinstall: no file
4307    /// is downloaded, replaced, or re-resolved.
4308    ///
4309    /// The caller must persist the new mode onto this host's `Host` record so
4310    /// that `host show` reports it — [`StartModeChange::to`] is the value, and
4311    /// `f1` already emits `host.service_start_mode` from that field.
4312    ///
4313    /// # Errors
4314    ///
4315    /// [`ServiceError::NotInstalled`] when there is no record to switch, and
4316    /// whatever the platform reports.
4317    pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
4318        let Some(record) = InstallRecord::read(&self.paths)? else {
4319            return Err(ServiceError::NotInstalled {
4320                name: self.identity.name().to_string(),
4321                operation: "switch the start mode of",
4322            });
4323        };
4324        let from = record.start_mode;
4325        if from == to {
4326            // Nothing moves, so nothing about the root's access control has to.
4327            // Reconciling it here would turn a no-op command into one that can
4328            // fail on a permission it does not need.
4329            return Ok(StartModeChange {
4330                from,
4331                to,
4332                changed: false,
4333                store_scope: crate::secrets::SecretScope::for_start_mode(to),
4334                runner_root: RootAccessSummary::NotApplicable,
4335            });
4336        }
4337
4338        #[cfg(windows)]
4339        let arguments = {
4340            let mut arguments = record.arguments.clone();
4341            arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
4342            if to == StartMode::Boot {
4343                arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
4344            }
4345            arguments
4346        };
4347        #[cfg(not(windows))]
4348        let arguments = record.arguments.clone();
4349        let plan = InstallPlan::unchecked(
4350            self.identity.clone(),
4351            to,
4352            record.binary.clone(),
4353            record.directories.clone(),
4354        )
4355        .with_arguments(arguments)
4356        .with_restart(record.restart());
4357        let plan = if record.starts_on_demand {
4358            plan.started_on_demand()
4359        } else {
4360            plan
4361        };
4362        let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
4363            Ok(store) => plan.with_secret_guard(store.guard()),
4364            Err(_) => plan,
4365        };
4366
4367        // Install the target domain before touching the live one. This makes a
4368        // failed target install a no-op from the operator's point of view and,
4369        // unlike uninstall-first ordering, never trades a working service for
4370        // an error message. Resolved before the runner root and used after it,
4371        // so that no `?` sits between preparing the directory and the first
4372        // step that knows how to undo it.
4373        let target = self.controls.control(to)?;
4374        // The domain being left, resolved here rather than where it is used for
4375        // the same reason: the only step that removes it runs after the runner
4376        // root has been prepared, and a `?` there would abandon the target
4377        // registration, the record and the directory without a word.
4378        let previous = self.controls.control(from)?;
4379
4380        // The account changes with the mode, and so must the account the runner
4381        // root admits: `04-security-recovery.md` requires the selected identity
4382        // to be *reconciled* when service mode changes, which means adding the
4383        // operator's on the way to login and dropping it again on the way back.
4384        // Before the target install, for the same reason `install` does it
4385        // first — a root that cannot be made safe must not produce a working
4386        // registration.
4387        let root = self.prepare_runner_root(to)?;
4388
4389        let definition = match target.install(&plan) {
4390            Ok(definition) => definition,
4391            Err(cause) => {
4392                return Err(undo_runner_root(
4393                    &root,
4394                    "switch start mode",
4395                    &self.identity,
4396                    cause,
4397                ));
4398            }
4399        };
4400        let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
4401        if let Err(cause) = next_record.write(&self.paths) {
4402            return Err(rolled_back(
4403                retained_runner_root(&root),
4404                target.uninstall(&self.identity),
4405                "switch start mode",
4406                &self.identity,
4407                cause,
4408            ));
4409        }
4410
4411        // Only after the target registration and its durable record exist is
4412        // it safe to remove the old domain. If that last step fails, remove the
4413        // target and put the old record back so status and reality agree.
4414        if let Err(cause) = previous.uninstall(&self.identity) {
4415            let target_rollback = target.uninstall(&self.identity);
4416            let record_rollback = record.write(&self.paths);
4417            return Err(rolled_back(
4418                retained_runner_root(&root),
4419                target_rollback.and(record_rollback),
4420                "switch start mode",
4421                &self.identity,
4422                cause,
4423            ));
4424        }
4425        Ok(StartModeChange {
4426            from,
4427            to,
4428            changed: true,
4429            store_scope: crate::secrets::SecretScope::for_start_mode(to),
4430            runner_root: root.summary().clone(),
4431        })
4432    }
4433
4434    /// Starts the registration now.
4435    ///
4436    /// # Errors
4437    ///
4438    /// [`ServiceError::NotInstalled`], or whatever the platform reports.
4439    pub fn start(&self) -> Result<(), ServiceError> {
4440        let Some((mode, _)) = self.find_registration()? else {
4441            return Err(ServiceError::NotInstalled {
4442                name: self.identity.name().to_string(),
4443                operation: "start",
4444            });
4445        };
4446        self.controls.control(mode)?.start(&self.identity)
4447    }
4448
4449    /// Stops the registration. Returns whether it was running.
4450    ///
4451    /// # Errors
4452    ///
4453    /// As [`ServiceOperations::start`].
4454    pub fn stop(&self) -> Result<bool, ServiceError> {
4455        let Some((mode, _)) = self.find_registration()? else {
4456            return Err(ServiceError::NotInstalled {
4457                name: self.identity.name().to_string(),
4458                operation: "stop",
4459            });
4460        };
4461        self.controls.control(mode)?.stop(&self.identity)
4462    }
4463
4464    /// Everything Journey 5 step 4 asks `service status` to report.
4465    ///
4466    /// # Errors
4467    ///
4468    /// [`ServiceError::Record`] when local state cannot be read, and whatever
4469    /// the platform reports. A *stale binary path* is deliberately **not** an
4470    /// error here: it is a reported state, because a status command that
4471    /// refused to print anything else would hide the very facts an operator
4472    /// needs in order to fix it.
4473    ///
4474    /// A record **this account may not read** is reported for the same reason
4475    /// and is the second exception. On a boot-mode host the record was written
4476    /// by `sudo service install`, and a version before this one wrote it `0600`
4477    /// — so the operator's own `service status` ended with `Permission denied`
4478    /// and printed nothing at all, on a host whose registration the service
4479    /// manager would have described perfectly well. What launchd, systemd or
4480    /// the SCM says is a separate fact from the record, and it is still worth
4481    /// having.
4482    pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
4483        let (record, record_refused) = match InstallRecord::read(&self.paths) {
4484            Ok(record) => (record, None),
4485            Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
4486            Err(error) => return Err(error),
4487        };
4488        let found = self.find_registration()?;
4489        let last_github_contact = last_github_contact(&self.paths)?;
4490        let drift = record
4491            .as_ref()
4492            .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, record));
4493        let credential_rejected_since =
4494            github_credential_rejected_since(&self.paths).ok().flatten();
4495        let credential_unreadable_since = credential_unreadable_since(&self.paths).ok().flatten();
4496        let probed = self.controls.unattended_login();
4497        let unattended = record.as_ref().and_then(|record| {
4498            // The account is only read where something was probed to judge
4499            // it against.
4500            let account = probed
4501                .as_ref()
4502                .map_or_else(String::new, |_| service_account_name(&record.account));
4503            unattended_gap(record.start_mode, &account, probed.as_ref())
4504        });
4505        let now = Utc::now();
4506        let installed = record.is_some() || found.is_some();
4507        Ok(ServiceStatus::compose(
4508            self.identity.clone(),
4509            record,
4510            record_refused.as_ref(),
4511            found.map(|(_, registration)| registration),
4512            last_github_contact,
4513            &self.paths,
4514        )
4515        .with_definition_drift(drift)
4516        .with_credential_rejection(credential_rejected_since)
4517        .with_credential_unreadable(credential_unreadable_since)
4518        .with_launches_blocked(crate::launch_health::launches_blocked(&self.paths, now))
4519        .with_unattended_gap(unattended)
4520        .with_host_unfit(
4521            crate::host_fitness::host_unfit_in_force(&self.paths, now).filter(|_| installed),
4522        )
4523        .with_stall(crate::daemon_heartbeat::liveness(&self.paths, now).stall(last_github_contact)))
4524    }
4525
4526    /// The installed definition, when it is not what this build renders for
4527    /// the recorded installation. See [`definition_drift`].
4528    ///
4529    /// # Errors
4530    ///
4531    /// Whatever reading the install record reports.
4532    pub fn definition_drift(&self) -> Result<Option<DefinitionDrift>, ServiceError> {
4533        Ok(InstallRecord::read(&self.paths)?
4534            .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, &record)))
4535    }
4536
4537    /// Writes the plist this build renders over an outdated one, then has a
4538    /// detached helper boot the job out and bootstrap it again from the new
4539    /// file.
4540    ///
4541    /// launchd reads a plist only when the job is bootstrapped. Rewriting the
4542    /// file changes nothing for a job that is already loaded, and a `KeepAlive`
4543    /// restart reuses the loaded copy. So the job has to be booted out, and
4544    /// that terminates the daemon that asked for it. The helper runs in a
4545    /// session of its own, which launchd does not kill with the job, and
4546    /// retries the bootstrap: launchd refuses it for a few seconds while the
4547    /// old job is still exiting (`5: Input/output error`, measured).
4548    ///
4549    /// Call this only from the daemon the job runs, and only while it holds no
4550    /// runner. It is an operation on the registration the daemon itself runs
4551    /// under, and it never changes the start mode.
4552    ///
4553    /// # Errors
4554    ///
4555    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`] when the
4556    /// plist cannot be written or the helper cannot be started. The loaded job
4557    /// is untouched in both cases.
4558    #[cfg(target_os = "macos")]
4559    pub fn reload_launchd_definition(&self, drift: &DefinitionDrift) -> Result<(), ServiceError> {
4560        // The record names the start mode; the account this process runs as has
4561        // to agree, or the record describes a registration other than the one
4562        // running, and root would end up rewriting an operator's LaunchAgent.
4563        // SAFETY: `getuid` reads the calling process's real user id and cannot
4564        // fail.
4565        let root = unsafe { libc::getuid() } == 0;
4566        if root != (drift.start_mode == StartMode::Boot) {
4567            return Err(ServiceError::Control {
4568                operation: "rewrite",
4569                name: self.identity.name().to_string(),
4570                manager: "launchd",
4571                detail: format!(
4572                    "the install record says --start-at {}, but this daemon runs as {}; the plist \
4573                     is left as it is",
4574                    drift.start_mode,
4575                    if root { "root" } else { "the operator" }
4576                ),
4577            });
4578        }
4579        replace_definition(self.identity.name(), drift)?;
4580        // The definition is now this build's, as after the WSL unit rewrite.
4581        // Best effort: the record says who wrote the plist and nothing reads it
4582        // to decide whether to repair.
4583        if let Ok(Some(mut record)) = InstallRecord::read(&self.paths) {
4584            record.installed_by_version = env!("CARGO_PKG_VERSION").to_string();
4585            let _ = record.write(&self.paths);
4586        }
4587        sys::reload_detached(&self.identity, drift.start_mode, &drift.path)
4588    }
4589
4590    /// Creates or reconciles the runner root this start mode's account needs.
4591    ///
4592    /// The account is not an argument: it is [`ServiceAccount::for_start_mode`],
4593    /// the same function the registration's own principal comes from, so the
4594    /// directory admits exactly the identity the definition registers and a mode
4595    /// change reconciles both together or neither.
4596    ///
4597    /// On macOS and Linux this is a no-op that returns
4598    /// [`RootAccessSummary::NotApplicable`]; see [`crate::runner_root_access`].
4599    fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
4600        #[cfg(not(windows))]
4601        {
4602            // `runner_root` is consumed here as well as in the Windows arm: a
4603            // field only one platform reads is a dead field on the other.
4604            let _ = (mode, &self.runner_root);
4605            Ok(RootAccessChange::not_applicable())
4606        }
4607        #[cfg(windows)]
4608        {
4609            let wrap = |source| ServiceError::RunnerRoot {
4610                source: Box::new(source),
4611            };
4612            // The test seam, and the two things allowed through it. `cfg!(test)`
4613            // is false in every shipped binary, so what a released build honours
4614            // is the fixture name alone — and a fixture name cannot be the
4615            // product's, which is what keeps a released `service install`
4616            // pointed at the platform default whatever a caller passes.
4617            //
4618            // An overridden root is always reconciled **for this account**,
4619            // which is the foreground admission — a real mode of the product
4620            // rather than a concession invented here. It has to be: a boot-mode
4621            // root admits `SY` and `BA` only, and a test process holding an
4622            // ordinary filtered token is neither, so it could not inspect the
4623            // temporary directory it just supplied nor delete it afterwards.
4624            // What that costs is that the *boot* descriptor is not proved
4625            // through this path; it is proved purely, by this module's
4626            // `the_runner_root_a_boot_registration_needs_admits_only_the_service`
4627            // and by `runner_root_access`'s own tests, and for real by the
4628            // privileged installer test, which runs elevated.
4629            if let Some(root) = self
4630                .runner_root
4631                .as_ref()
4632                .filter(|_| self.identity.is_fixture() || cfg!(test))
4633            {
4634                let admission = RootAdmission::of_this_account().map_err(wrap)?;
4635                return crate::runner_root_access::reconcile(&self.paths, root, &admission)
4636                    .map_err(wrap);
4637            }
4638
4639            let admission = match ServiceAccount::for_start_mode(mode) {
4640                // A boot registration runs as LocalSystem, which the constant
4641                // `SY` ace already names.
4642                ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
4643                // A login registration runs as this account, under a filtered
4644                // token in which Administrators is deny-only — so without an
4645                // ace of its own it would be admitted by nothing.
4646                ServiceAccount::InvokingUser | ServiceAccount::Root => {
4647                    RootAdmission::of_this_account().map_err(wrap)?
4648                }
4649            };
4650            crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
4651        }
4652    }
4653
4654    /// Takes the single-instance lock, or refuses with `d1`'s own message.
4655    fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
4656        HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
4657            |source| match source {
4658                held @ LockError::Held { .. } => ServiceError::LockHeld {
4659                    source: Box::new(held),
4660                },
4661                other => ServiceError::LockUnreadable {
4662                    source: Box::new(other),
4663                },
4664            },
4665        )
4666    }
4667
4668    /// The registration, in whichever domain holds it.
4669    fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
4670        for mode in [StartMode::Boot, StartMode::Login] {
4671            let control = self.controls.control(mode)?;
4672            if let Some(registration) = control.query(&self.identity)? {
4673                return Ok(Some((registration.start_mode, registration)));
4674            }
4675        }
4676        Ok(None)
4677    }
4678}
4679
4680// ---------------------------------------------------------------------------
4681// Status
4682// ---------------------------------------------------------------------------
4683
4684/// One thing `service status` has to report as wrong.
4685#[derive(Debug, Clone, PartialEq, Eq)]
4686pub struct StatusProblem {
4687    /// What it is about.
4688    pub subject: &'static str,
4689    /// What is wrong, and what to do.
4690    pub detail: String,
4691}
4692
4693impl fmt::Display for StatusProblem {
4694    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4695        write!(f, "{}: {}", self.subject, self.detail)
4696    }
4697}
4698
4699/// Everything `service status` reports.
4700///
4701/// Journey 5 step 4 names four of these — the start mode, the resolved binary
4702/// path, the diagnostic log path, and the last successful GitHub contact — and
4703/// the release gate adds the fifth: *"`service status` reports a stale binary
4704/// path as an error rather than appearing healthy"*. [`Self::is_healthy`] is
4705/// what an exit code should be derived from, and it is false whenever
4706/// [`Self::problems`] is non-empty.
4707#[derive(Debug, Clone)]
4708pub struct ServiceStatus {
4709    identity: ServiceIdentity,
4710    record: Option<InstallRecord>,
4711    registration: Option<Registration>,
4712    binary: Option<BinaryPath>,
4713    log_file: PathBuf,
4714    store: Option<crate::secrets::ActiveStore>,
4715    last_github_contact: Option<DateTime<Utc>>,
4716    runner_root: Option<(PathBuf, RootAccessReport)>,
4717    credential_rejected_since: Option<DateTime<Utc>>,
4718    launches_blocked: Option<crate::launch_health::LaunchesBlocked>,
4719    unattended_gap: Option<UnattendedGap>,
4720    problems: Vec<StatusProblem>,
4721    notes: Vec<String>,
4722}
4723
4724/// Why a registration does not come back by itself after an unattended
4725/// restart, and what an operator can do about it.
4726#[derive(Debug, Clone, PartialEq, Eq)]
4727pub struct UnattendedGap {
4728    /// One or more sentences, the way `service status` prints them.
4729    pub reason: String,
4730    /// What changes it. Never something this product does by itself.
4731    pub remedy: String,
4732}
4733
4734/// The name of the account a registration runs as: the invoking user's own
4735/// for a login-mode one.
4736#[must_use]
4737pub fn service_account_name(account: &ServiceAccount) -> String {
4738    match account {
4739        // Under `sudo` the invoking user is the one sudo names, not root.
4740        ServiceAccount::InvokingUser => std::env::var("SUDO_USER")
4741            .ok()
4742            .filter(|user| !user.is_empty() && crate::host_fitness::is_elevated())
4743            .or_else(crate::unattended_login::current_account)
4744            .unwrap_or_else(|| "the operator".to_owned()),
4745        other => other.as_str().to_owned(),
4746    }
4747}
4748
4749/// Works out the gap for a registration in `mode` running as `account`.
4750///
4751/// `found` is `None` where nothing could be probed (every platform but macOS):
4752/// a login registration then keeps the general statement that it waits for a
4753/// sign-in. On a Mac the statement depends on automatic login and FileVault,
4754/// and a login registration that automatic login brings back has no gap. The
4755/// earlier statement was printed for every login registration, so a Mac set to
4756/// sign its runner account in automatically was told it would not resume.
4757#[must_use]
4758pub fn unattended_gap(
4759    mode: StartMode,
4760    account: &str,
4761    found: Option<&crate::unattended_login::UnattendedLogin>,
4762) -> Option<UnattendedGap> {
4763    let Some(found) = found else {
4764        return (mode == StartMode::Login).then(|| UnattendedGap {
4765            reason: "This registration starts at login, so the agent does not run until the \
4766                     operator signs in; this host does not resume work after an unattended \
4767                     reboot."
4768                .to_owned(),
4769            remedy: "For machine-on operation, move the service and credential to `--start-at \
4770                     boot` from an elevated terminal."
4771                .to_owned(),
4772        });
4773    };
4774    let resume = crate::unattended_login::resume(found, mode, account);
4775    if matches!(resume, crate::unattended_login::Resume::Unknown(_)) {
4776        // `host doctor` says what could not be read; a note here would claim
4777        // a gap nobody has seen.
4778        return None;
4779    }
4780    Some(UnattendedGap {
4781        reason: resume.explain(account)?,
4782        remedy: resume.remedy().unwrap_or_else(|| {
4783            "Run `runner-manager host doctor` on the Mac's desktop, where automatic login and \
4784             FileVault can be read."
4785                .to_owned()
4786        }),
4787    })
4788}
4789
4790impl ServiceStatus {
4791    /// `record_refused` carries a [`ServiceError::RecordNotPermitted`] when
4792    /// there **is** a record and this account may not read it, which is not the
4793    /// same state as having none and must not be reported as one. See
4794    /// [`Operations::status`].
4795    fn compose(
4796        identity: ServiceIdentity,
4797        record: Option<InstallRecord>,
4798        record_refused: Option<&ServiceError>,
4799        registration: Option<Registration>,
4800        last_github_contact: Option<DateTime<Utc>>,
4801        paths: &AppPaths,
4802    ) -> Self {
4803        let mut problems = Vec::new();
4804        let mut notes = Vec::new();
4805
4806        if let Some(refusal) = record_refused {
4807            // A problem rather than a note: `is_healthy` stays false, because
4808            // an operator who cannot read their own install record does have
4809            // something to fix. It is just not a reason to withhold everything
4810            // the service manager knows.
4811            problems.push(StatusProblem {
4812                subject: "install record",
4813                detail: refusal.to_string(),
4814            });
4815        }
4816
4817        let log_file = record.as_ref().map_or_else(
4818            || ServiceDirectories::of(paths).log_file(),
4819            |record| record.log_file.clone(),
4820        );
4821
4822        let binary = record.as_ref().map(|record| {
4823            inspect_binary(
4824                &record.binary,
4825                registration
4826                    .as_ref()
4827                    .and_then(Registration::binary)
4828                    .as_deref(),
4829            )
4830        });
4831        if let Some(state) = &binary
4832            && state.is_error()
4833        {
4834            problems.push(StatusProblem {
4835                subject: "binary",
4836                detail: state.to_string(),
4837            });
4838        }
4839
4840        match (&record, &registration) {
4841            (Some(_), None) => problems.push(StatusProblem {
4842                subject: "registration",
4843                detail: "this host has a service record but no service manager knows the \
4844                         registration. Run `service install` again; it deletes nothing."
4845                    .to_string(),
4846            }),
4847            // Not reported when the record was refused rather than absent: the
4848            // problem above already names the real state, and "there is no
4849            // install record" would be a second, wrong diagnosis of the same
4850            // file, with a `service uninstall` in its remedy.
4851            (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
4852                subject: "record",
4853                detail: format!(
4854                    "{} holds a registration for this service but there is no install record, so \
4855                     the path it was installed from and the directories it was installed against \
4856                     are unknown. Run `service uninstall` and `service install`.",
4857                    found.manager
4858                ),
4859            }),
4860            (None, Some(_)) => {}
4861            (Some(record), Some(found)) => {
4862                if record.start_mode != found.start_mode {
4863                    problems.push(StatusProblem {
4864                        subject: "start mode",
4865                        detail: format!(
4866                            "the record says {} and {} holds a {} registration. Switch the start \
4867                             mode again to make them agree.",
4868                            record.start_mode, found.manager, found.start_mode
4869                        ),
4870                    });
4871                }
4872                if record.start_mode == StartMode::Boot && !found.starts_automatically {
4873                    problems.push(StatusProblem {
4874                        subject: "start mode",
4875                        detail: format!(
4876                            "{} holds the registration but will not start it by itself, so this \
4877                             host does not resume work after a reboot.",
4878                            found.manager
4879                        ),
4880                    });
4881                }
4882                if let Some(actual) = found.restart_delay {
4883                    let expected = record.restart().effective_delay(found.manager);
4884                    if actual != expected {
4885                        problems.push(StatusProblem {
4886                            subject: "restart policy",
4887                            detail: format!(
4888                                "the record says the service restarts after {}s and {} reports \
4889                                 {}s. Something has edited the registration since it was \
4890                                 installed.",
4891                                expected.as_secs(),
4892                                found.manager,
4893                                actual.as_secs()
4894                            ),
4895                        });
4896                    } else if expected != record.restart().delay() {
4897                        // Not a fault. Task Scheduler expresses this in whole
4898                        // minutes, so the delay in force is longer than the one
4899                        // asked for -- never shorter, which is the direction
4900                        // the requirement cares about.
4901                        notes.push(format!(
4902                            "{} expresses the restart delay in whole minutes, so the {}s asked \
4903                             for is enforced as {}s. The service therefore never restarts faster \
4904                             than the configured bound.",
4905                            found.manager,
4906                            record.restart().delay().as_secs(),
4907                            expected.as_secs()
4908                        ));
4909                    }
4910                }
4911                if found.starts_automatically && !found.running {
4912                    problems.push(StatusProblem {
4913                        subject: "runtime",
4914                        detail: format!(
4915                            "{} holds an automatic registration, but the daemon is stopped. Run \
4916                             `runner-manager service install --start-at {}`; if the service \
4917                             manager denies replacement, retry from an elevated terminal. If it \
4918                             stops again, inspect {}.",
4919                            found.manager,
4920                            found.start_mode,
4921                            log_file.display()
4922                        ),
4923                    });
4924                }
4925            }
4926            (None, None) => {}
4927        }
4928
4929        // The two facts here really are independent, which is what `f1`'s
4930        // `status.rs` says this check needs and could not have there: the scope
4931        // is derived from the mode the **record** carries, and it is compared
4932        // against the mode the **service manager** actually holds.
4933        let store = record.as_ref().and_then(|record| {
4934            let registered_mode = registration
4935                .as_ref()
4936                .map_or(record.start_mode, |found| found.start_mode);
4937            crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
4938                .ok()
4939                .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
4940        });
4941        if let Some(store) = &store
4942            && !store.agrees_with_start_mode()
4943        {
4944            problems.push(StatusProblem {
4945                subject: "secret store",
4946                detail: format!(
4947                    "{store}. Run `auth login` again so the token is stored where the registered \
4948                     start mode can read it."
4949                ),
4950            });
4951        }
4952
4953        if last_github_contact.is_none() {
4954            notes.push(
4955                "GitHub has not been reached successfully since this host's state directory was \
4956                 created."
4957                    .to_string(),
4958            );
4959        }
4960
4961        // The privileged inspection output. Read-only, tolerant of an account
4962        // that may not read the descriptor at all, and never a `problem`: a
4963        // root this account cannot inspect is a true statement about this
4964        // account's rights, not a fault in the registration. What refuses an
4965        // install is the preflight in `runner_root_access`, which runs as the
4966        // installing account and has the authority to know.
4967        let runner_root = crate::runner_root::default_runner_root(paths)
4968            .ok()
4969            .map(|root| {
4970                let path = root.as_path().to_path_buf();
4971                let report = crate::runner_root_access::report(&path);
4972                (path, report)
4973            });
4974        // Reported, and deliberately not a `problem`. The Definition of Done
4975        // asks for a broad root to be "reported and fail the security
4976        // preflight", and the security preflight is `install`'s -- which runs
4977        // as the installing account and refuses outright. `service status` runs
4978        // as whoever typed it, is expected to be readable on a host with
4979        // nothing installed at all, and drives an exit code; turning a
4980        // directory that predates this feature into a non-zero exit for a
4981        // machine that has never installed the service would report a fault
4982        // that is not this registration's.
4983        if let Some((
4984            path,
4985            RootAccessReport::Present {
4986                broad_write: true, ..
4987            },
4988        )) = &runner_root
4989        {
4990            notes.push(format!(
4991                "the platform default runner root {} can be written by ordinary local users, so \
4992                 it is not a safe place to run jobs. `service install` refuses it rather than \
4993                 tightening it, because the contents of a directory anybody could write cannot \
4994                 be trusted: remove or empty it, or choose another root with `runner-manager \
4995                 host set-runtime-root --path <PATH>`.",
4996                path.display()
4997            ));
4998        }
4999
5000        // The surface the agent has nowhere else to reach. A root that refuses
5001        // a launch does so before any attempt row exists, and the daemon's log
5002        // redacts the paths out of the sentence, so without this the operator
5003        // sees `runner_start_failed reason=other` once per poll and nothing
5004        // that names the directory or the fix.
5005        //
5006        // A **note** and not a problem, by the same rule the broad-write root
5007        // above obeys: `service status` runs as whoever typed it, is expected
5008        // to be readable on a host with nothing installed at all, and drives an
5009        // exit code. A problem here would fail that exit code on three hosts
5010        // that are not broken -- one whose root was fixed but which has had no
5011        // queued job since, one whose service was uninstalled without clearing
5012        // `state/`, and one that only ever ran the agent in the foreground --
5013        // and the printed remedy (`service uninstall && service install`) does
5014        // not touch `state/`, so following it would not clear the error either.
5015        // A note is read by the same operator at the same moment and traps
5016        // nobody in a loop.
5017        //
5018        // A read failure is reported and not swallowed, for the reason
5019        // `runner_root_refusals` documents: absence means "every policy is
5020        // placing runners", and answering a parse failure with that would be
5021        // the wrong answer to the question this row exists for.
5022        match runner_root_refusals(paths) {
5023            Ok(refusals) => {
5024                for refusal in refusals {
5025                    notes.push(format!(
5026                        "policy {} started no runner: its runner root {} refused the launch \
5027                         ({}), last at {}. {} This clears when that policy next places a \
5028                         runner.",
5029                        refusal.policy,
5030                        refusal.root,
5031                        refusal.kind,
5032                        refusal.at.to_rfc3339(),
5033                        refusal.detail,
5034                    ));
5035                }
5036            }
5037            Err(error) => notes.push(format!(
5038                "whether the agent could use its runner roots could not be read: {error}"
5039            )),
5040        }
5041
5042        Self {
5043            identity,
5044            record,
5045            registration,
5046            binary,
5047            log_file,
5048            store,
5049            last_github_contact,
5050            runner_root,
5051            credential_rejected_since: None,
5052            launches_blocked: None,
5053            unattended_gap: None,
5054            problems,
5055            notes,
5056        }
5057    }
5058
5059    /// Reports a definition this build would render differently.
5060    #[must_use]
5061    pub fn with_definition_drift(mut self, drift: Option<DefinitionDrift>) -> Self {
5062        if let Some(drift) = drift {
5063            if drift.manager == DefinitionKind::LaunchdPlist {
5064                // A restart, not `service install`: the running daemon holds the
5065                // single-instance lock, which refuses an install, and a daemon
5066                // that starts with no runner repairs the plist itself.
5067                let restart = match drift.start_mode {
5068                    StartMode::Boot => format!(
5069                        "sudo launchctl kickstart -k system/{}",
5070                        self.identity.launchd_label()
5071                    ),
5072                    StartMode::Login => format!(
5073                        "launchctl kickstart -k gui/$(id -u)/{}",
5074                        self.identity.launchd_label()
5075                    ),
5076                };
5077                self.problems.push(StatusProblem {
5078                    subject: "definition",
5079                    detail: format!(
5080                        "{} was written by runner-manager {} with a `ProcessType` other than \
5081                         `{LAUNCHD_PROCESS_TYPE}`, which launchd applies to every runner the \
5082                         daemon starts, so their jobs run below normal priority. The daemon \
5083                         rewrites it and reloads itself when it starts with no runner, which it \
5084                         does after every upgrade; to do it now, restart the service while no \
5085                         job is running: `{restart}`. Configuration and credentials are kept.",
5086                        drift.path.display(),
5087                        drift.installed_by_version,
5088                    ),
5089                });
5090                return self;
5091            }
5092            let repair = match drift.start_mode {
5093                StartMode::Boot => {
5094                    "`sudo systemctl stop runner-manager && sudo runner-manager service install \
5095                     --start-at boot` (for a managed WSL host, run `runner-manager wsl install \
5096                     --distribution <name>` from Windows)"
5097                }
5098                StartMode::Login => {
5099                    "`systemctl --user stop runner-manager && runner-manager service install \
5100                     --start-at login`"
5101                }
5102            };
5103            self.problems.push(StatusProblem {
5104                subject: "definition",
5105                detail: format!(
5106                    "{} was written by runner-manager {} and differs from what {} renders, so \
5107                     the daemon runs under outdated settings (for example a credential frozen \
5108                     at service start). Re-register it with {repair}; configuration and \
5109                     credentials are kept.",
5110                    drift.path.display(),
5111                    drift.installed_by_version,
5112                    env!("CARGO_PKG_VERSION"),
5113                ),
5114            });
5115        }
5116        self
5117    }
5118
5119    /// Reports that GitHub is rejecting the daemon's credential.
5120    #[must_use]
5121    pub fn with_credential_rejection(mut self, since: Option<DateTime<Utc>>) -> Self {
5122        if let Some(since) = since {
5123            self.credential_rejected_since = Some(since);
5124            self.problems.push(StatusProblem {
5125                subject: "github credential",
5126                detail: format!(
5127                    "GitHub has rejected the service's credential since {}, so the service \
5128                     cannot see queued jobs or start runners. Run `runner-manager auth login` \
5129                     (for a managed WSL host, `runner-manager --host wsl:<name> auth login` \
5130                     from Windows).",
5131                    since.to_rfc3339()
5132                ),
5133            });
5134        }
5135        self
5136    }
5137
5138    /// Since when GitHub has rejected the service's credential, if it has.
5139    #[must_use]
5140    pub const fn credential_rejected_since(&self) -> Option<DateTime<Utc>> {
5141        self.credential_rejected_since
5142    }
5143
5144    /// Reports that the daemon starts no runner because its launches are
5145    /// blocked. See [`crate::launch_health`].
5146    #[must_use]
5147    pub fn with_launches_blocked(
5148        mut self,
5149        blocked: Option<crate::launch_health::LaunchesBlocked>,
5150    ) -> Self {
5151        if let Some(blocked) = &blocked {
5152            self.problems.push(StatusProblem {
5153                subject: LAUNCHES_BLOCKED_SUBJECT,
5154                detail: format!(
5155                    "the service has started no runner {blocked}. Fix: {}.",
5156                    blocked.remedy
5157                ),
5158            });
5159        }
5160        self.launches_blocked = blocked;
5161        self
5162    }
5163
5164    /// Why the service starts no runner, when its launches are blocked.
5165    #[must_use]
5166    pub const fn launches_blocked(&self) -> Option<&crate::launch_health::LaunchesBlocked> {
5167        self.launches_blocked.as_ref()
5168    }
5169
5170    /// Reports that the daemon cannot read its own stored credential, and the
5171    /// exact command that ends it. See [`record_credential_unreadable`].
5172    #[must_use]
5173    pub fn with_credential_unreadable(mut self, since: Option<DateTime<Utc>>) -> Self {
5174        if let Some(since) = since {
5175            let mode = self.start_mode().unwrap_or_default();
5176            self.problems.push(StatusProblem {
5177                subject: CREDENTIAL_UNREADABLE_SUBJECT,
5178                detail: format!(
5179                    "the service has been unable to read its stored GitHub credential since {}, \
5180                     so it starts no runner; the diagnostic log has the store's own answer.{} \
5181                     Sign in again: {}.",
5182                    since.to_rfc3339(),
5183                    if cfg!(target_os = "macos") {
5184                        " That is what a keychain item written by a different build looks like."
5185                    } else {
5186                        ""
5187                    },
5188                    sign_in_instruction(mode)
5189                ),
5190            });
5191        }
5192        self
5193    }
5194
5195    /// What this host's default runner root grants, and to whom.
5196    ///
5197    /// `None` when the platform default could not even be resolved. The
5198    /// descriptor inside has been through
5199    /// [`crate::runner_root_access::redact`], so it names the well-known
5200    /// trustees and says "an account" for everything else — no more identity
5201    /// than the `account` line above it already prints.
5202    #[must_use]
5203    pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
5204        self.runner_root
5205            .as_ref()
5206            .map(|(path, report)| (path.as_path(), report))
5207    }
5208
5209    /// Whether a registration exists at all.
5210    #[must_use]
5211    pub const fn is_installed(&self) -> bool {
5212        self.registration.is_some() || self.record.is_some()
5213    }
5214
5215    /// Whether the daemon is running now.
5216    #[must_use]
5217    pub fn is_running(&self) -> bool {
5218        self.registration
5219            .as_ref()
5220            .is_some_and(|registration| registration.running)
5221    }
5222
5223    /// **False whenever anything is wrong**, including a stale binary path.
5224    #[must_use]
5225    pub fn is_healthy(&self) -> bool {
5226        self.problems.is_empty()
5227    }
5228
5229    /// Everything that is wrong.
5230    #[must_use]
5231    pub fn problems(&self) -> &[StatusProblem] {
5232        &self.problems
5233    }
5234
5235    /// True statements that are not faults — a login-mode registration not
5236    /// running unattended, or an agent that has not yet reached GitHub.
5237    #[must_use]
5238    pub fn notes(&self) -> &[String] {
5239        &self.notes
5240    }
5241
5242    /// Reports that the daemon starts no runner because a required host check
5243    /// fails from its own session. That is the service not doing its job, so
5244    /// it is a problem: a host whose daemon refused every runner used to read
5245    /// `verdict healthy`.
5246    #[must_use]
5247    pub fn with_host_unfit(mut self, unfit: Option<crate::host_fitness::HostUnfitRecord>) -> Self {
5248        if let Some(unfit) = unfit {
5249            self.problems.push(StatusProblem {
5250                subject: HOST_UNFIT_SUBJECT,
5251                detail: format!(
5252                    "the service has started no runner since {}, because a required host check \
5253                     fails from its own session: {}",
5254                    unfit.since.to_rfc3339(),
5255                    unfit.describe()
5256                ),
5257            });
5258        }
5259        self
5260    }
5261
5262    /// Reports a daemon that is running and has stopped making progress. See
5263    /// [`crate::daemon_heartbeat`]: launchd, systemd and the SCM all report
5264    /// such a daemon running, so this used to read `verdict healthy`.
5265    #[must_use]
5266    pub fn with_stall(mut self, stall: Option<String>) -> Self {
5267        if let Some(detail) = stall {
5268            self.problems.push(StatusProblem {
5269                subject: DAEMON_STALLED_SUBJECT,
5270                detail,
5271            });
5272        }
5273        self
5274    }
5275
5276    /// Records whether this registration comes back by itself after an
5277    /// unattended restart, as a note. See [`unattended_gap`].
5278    #[must_use]
5279    pub fn with_unattended_gap(mut self, gap: Option<UnattendedGap>) -> Self {
5280        if let Some(gap) = &gap {
5281            self.notes.push(gap.reason.clone());
5282        }
5283        self.unattended_gap = gap;
5284        self
5285    }
5286
5287    /// Why this registration does not come back by itself after an
5288    /// unattended restart, when it does not.
5289    #[must_use]
5290    pub const fn unattended_gap(&self) -> Option<&UnattendedGap> {
5291        self.unattended_gap.as_ref()
5292    }
5293
5294    /// The recorded start mode. Journey 5 step 4.
5295    #[must_use]
5296    pub fn start_mode(&self) -> Option<StartMode> {
5297        self.record.as_ref().map(|record| record.start_mode)
5298    }
5299
5300    /// The resolved absolute binary path and what became of it. Journey 5
5301    /// step 4 and `05-infrastructure.md` item 6.
5302    #[must_use]
5303    pub const fn binary(&self) -> Option<&BinaryPath> {
5304        self.binary.as_ref()
5305    }
5306
5307    /// The diagnostic log path. `05-infrastructure.md` item 4.
5308    #[must_use]
5309    pub fn log_file(&self) -> &Path {
5310        &self.log_file
5311    }
5312
5313    /// The last successful GitHub contact. Journey 5 step 4.
5314    #[must_use]
5315    pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
5316        self.last_github_contact
5317    }
5318
5319    /// Which store the daemon reads, and whether that agrees with the
5320    /// registration.
5321    #[must_use]
5322    pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
5323        self.store.as_ref()
5324    }
5325
5326    /// The record `install` wrote.
5327    #[must_use]
5328    pub const fn record(&self) -> Option<&InstallRecord> {
5329        self.record.as_ref()
5330    }
5331
5332    /// What the service manager says.
5333    #[must_use]
5334    pub const fn registration(&self) -> Option<&Registration> {
5335        self.registration.as_ref()
5336    }
5337}
5338
5339impl fmt::Display for ServiceStatus {
5340    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5341        writeln!(f, "Service: {}", self.identity)?;
5342        match (&self.record, &self.registration) {
5343            (None, None) => {
5344                writeln!(
5345                    f,
5346                    "  installed                 no. `service install` registers `{} {}`.",
5347                    SERVICE_NAME,
5348                    DAEMON_ARGUMENTS.join(" ")
5349                )?;
5350            }
5351            _ => {
5352                let manager = self
5353                    .registration
5354                    .as_ref()
5355                    .map(|registration| registration.manager.manager());
5356                writeln!(
5357                    f,
5358                    "  installed                 {}",
5359                    manager.unwrap_or("yes, but no service manager knows it")
5360                )?;
5361                writeln!(
5362                    f,
5363                    "  state                     {}",
5364                    if self.is_running() {
5365                        "running"
5366                    } else {
5367                        "not running"
5368                    }
5369                )?;
5370            }
5371        }
5372        if let Some(record) = &self.record {
5373            writeln!(f, "  start mode                {}", record.start_mode)?;
5374            writeln!(f, "  account                   {}", record.account)?;
5375            writeln!(f, "  restart on failure        {}", record.restart())?;
5376            writeln!(
5377                f,
5378                "  arguments                 {}",
5379                record.arguments.join(" ")
5380            )?;
5381        }
5382        if let Some(binary) = &self.binary {
5383            writeln!(f, "  binary                    {binary}")?;
5384        }
5385        writeln!(f, "  diagnostic log            {}", self.log_file.display())?;
5386        if let Some((path, report)) = &self.runner_root {
5387            // Named as the *default* rather than as "the runner root", because
5388            // it is only the effective one until an operator runs
5389            // `host set-runtime-root`. This crate cannot see that setting — it
5390            // lives in the application's store — so an unqualified label here
5391            // would contradict the `runner root … (host-configured)` row that
5392            // `status` and `host show` print from the value that is in force.
5393            writeln!(f, "  default runner root       {}", path.display())?;
5394            if *report != RootAccessReport::NotApplicable {
5395                writeln!(f, "  default root access       {report}")?;
5396            }
5397        }
5398        if let Some(store) = &self.store {
5399            writeln!(f, "  secret store              {store}")?;
5400        }
5401        writeln!(
5402            f,
5403            "  last GitHub contact       {}",
5404            match self.last_github_contact {
5405                Some(at) => at.to_rfc3339(),
5406                None => "never".to_string(),
5407            }
5408        )?;
5409        for note in &self.notes {
5410            writeln!(f, "  note                      {note}")?;
5411        }
5412        for problem in &self.problems {
5413            writeln!(f, "  ERROR                     {problem}")?;
5414        }
5415        write!(
5416            f,
5417            "  verdict                   {}",
5418            if self.is_healthy() {
5419                "healthy"
5420            } else {
5421                "NOT healthy"
5422            }
5423        )
5424    }
5425}
5426
5427// ---------------------------------------------------------------------------
5428// The in-memory double
5429// ---------------------------------------------------------------------------
5430
5431/// A [`ControlFactory`] that registers nothing and remembers everything.
5432///
5433/// Public rather than `#[cfg(test)]` on purpose. `f3` builds three commands on
5434/// [`ServiceOperations`] and has to be able to test them without a service
5435/// manager, and a double that lived behind `#[cfg(test)]` here would be
5436/// invisible from another crate — which would leave `f3` either untested or
5437/// writing a second double that drifts from this one.
5438///
5439/// It is not a simulator. It stores what it was asked to register and reports it
5440/// back, which is exactly enough to exercise the logic that sits *above* a
5441/// service manager: the lock refusal, the record, the stale-path detection, the
5442/// start-mode switch, and the promise that uninstall deletes nothing else.
5443#[derive(Debug, Clone, Default)]
5444pub struct RecordingControls {
5445    state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
5446}
5447
5448#[derive(Debug, Default)]
5449struct RecordingState {
5450    registrations: BTreeMap<(StartMode, String), Registration>,
5451    definitions: BTreeMap<String, ServiceDefinition>,
5452    calls: Vec<String>,
5453    #[cfg(test)]
5454    install_failures: BTreeMap<StartMode, String>,
5455    #[cfg(test)]
5456    after_install: BTreeMap<StartMode, TestInstallSideEffect>,
5457}
5458
5459#[cfg(test)]
5460#[derive(Debug, Clone)]
5461enum TestInstallSideEffect {
5462    HideDirectory { directory: PathBuf, hidden: PathBuf },
5463}
5464
5465impl RecordingControls {
5466    /// A factory holding no registrations.
5467    #[must_use]
5468    pub fn new() -> Self {
5469        Self::default()
5470    }
5471
5472    /// Every call made through this factory, in order, as
5473    /// `"<operation> <name> (<mode>)"`.
5474    #[must_use]
5475    pub fn calls(&self) -> Vec<String> {
5476        self.state.lock().expect("not poisoned").calls.clone()
5477    }
5478
5479    /// Every registration currently held, with the domain it is in.
5480    #[must_use]
5481    pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
5482        self.state
5483            .lock()
5484            .expect("not poisoned")
5485            .registrations
5486            .iter()
5487            .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
5488            .collect()
5489    }
5490
5491    /// The definition applied for a registration, if it is held.
5492    #[must_use]
5493    pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
5494        self.state
5495            .lock()
5496            .expect("not poisoned")
5497            .definitions
5498            .get(name)
5499            .cloned()
5500    }
5501
5502    /// Edits a held registration, as something outside this product would.
5503    ///
5504    /// This is how the divergence and start-type problems are made reachable
5505    /// from a test: `sc config`, `launchctl` and `systemctl` can all change a
5506    /// registration after installation, and a status command that could not be
5507    /// shown detecting that would be a status command nobody had tested against
5508    /// the case it exists for.
5509    ///
5510    /// Does nothing when no registration of that name is held.
5511    pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
5512        let mut state = self.state.lock().expect("not poisoned");
5513        if let Some((_, registration)) = state
5514            .registrations
5515            .iter_mut()
5516            .find(|((_, held), _)| held == name)
5517        {
5518            edit(registration);
5519        }
5520    }
5521
5522    #[cfg(test)]
5523    fn fail_next_install(&self, mode: StartMode, detail: &str) {
5524        self.state
5525            .lock()
5526            .expect("not poisoned")
5527            .install_failures
5528            .insert(mode, detail.to_string());
5529    }
5530
5531    #[cfg(test)]
5532    fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
5533        self.state
5534            .lock()
5535            .expect("not poisoned")
5536            .after_install
5537            .insert(
5538                mode,
5539                TestInstallSideEffect::HideDirectory { directory, hidden },
5540            );
5541    }
5542}
5543
5544impl ControlFactory for RecordingControls {
5545    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5546        Ok(Box::new(RecordingControl {
5547            mode,
5548            state: std::sync::Arc::clone(&self.state),
5549        }))
5550    }
5551}
5552
5553#[derive(Debug)]
5554struct RecordingControl {
5555    mode: StartMode,
5556    state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
5557}
5558
5559impl RecordingControl {
5560    fn note(&self, operation: &str, name: &str) {
5561        self.state
5562            .lock()
5563            .expect("not poisoned")
5564            .calls
5565            .push(format!("{operation} {name} ({})", self.mode));
5566    }
5567}
5568
5569impl ServiceControl for RecordingControl {
5570    fn manager(&self) -> DefinitionKind {
5571        // The double reports the kind this host's real backend would, so a test
5572        // that asserts on the manager name asserts something true of the
5573        // platform it is running on.
5574        host_definition_kind(self.mode)
5575    }
5576
5577    fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5578        self.note("install", plan.identity().name());
5579        #[cfg(test)]
5580        if let Some(detail) = self
5581            .state
5582            .lock()
5583            .expect("not poisoned")
5584            .install_failures
5585            .remove(&self.mode)
5586        {
5587            return Err(ServiceError::Control {
5588                operation: "install",
5589                name: plan.identity().name().to_string(),
5590                manager: "recording control",
5591                detail,
5592            });
5593        }
5594        // The real definition, not a stub. Rendering is pure, so the double can
5595        // afford it -- and a stub would make `Installed::review` report that the
5596        // definition confirms nothing, which is exactly the shape of false
5597        // assurance a test double must never hand back.
5598        let definition = ServiceDefinition::for_host(plan)?;
5599        let mut state = self.state.lock().expect("not poisoned");
5600        state.registrations.insert(
5601            (self.mode, plan.identity().name().to_string()),
5602            Registration {
5603                manager: host_definition_kind(self.mode),
5604                start_mode: self.mode,
5605                command_line: plan.command_line(),
5606                account: Some(plan.account().as_str().to_string()),
5607                running: false,
5608                starts_automatically: true,
5609                restart_delay: Some(plan.restart().delay()),
5610            },
5611        );
5612        state
5613            .definitions
5614            .insert(plan.identity().name().to_string(), definition.clone());
5615        #[cfg(test)]
5616        let side_effect = state.after_install.remove(&self.mode);
5617        drop(state);
5618        #[cfg(test)]
5619        if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
5620            std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
5621            std::fs::write(&directory, b"blocks recreation")
5622                .expect("test fault can block record directory recreation");
5623        }
5624        Ok(definition)
5625    }
5626
5627    fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5628        self.note("uninstall", identity.name());
5629        let mut state = self.state.lock().expect("not poisoned");
5630        state.definitions.remove(identity.name());
5631        Ok(state
5632            .registrations
5633            .remove(&(self.mode, identity.name().to_string()))
5634            .is_some())
5635    }
5636
5637    fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
5638        self.note("query", identity.name());
5639        Ok(self
5640            .state
5641            .lock()
5642            .expect("not poisoned")
5643            .registrations
5644            .get(&(self.mode, identity.name().to_string()))
5645            .cloned())
5646    }
5647
5648    fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
5649        self.note("start", identity.name());
5650        let mut state = self.state.lock().expect("not poisoned");
5651        match state
5652            .registrations
5653            .get_mut(&(self.mode, identity.name().to_string()))
5654        {
5655            Some(registration) => {
5656                registration.running = true;
5657                Ok(())
5658            }
5659            None => Err(ServiceError::NotInstalled {
5660                name: identity.name().to_string(),
5661                operation: "start",
5662            }),
5663        }
5664    }
5665
5666    fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5667        self.note("stop", identity.name());
5668        let mut state = self.state.lock().expect("not poisoned");
5669        match state
5670            .registrations
5671            .get_mut(&(self.mode, identity.name().to_string()))
5672        {
5673            Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
5674            None => Err(ServiceError::NotInstalled {
5675                name: identity.name().to_string(),
5676                operation: "stop",
5677            }),
5678        }
5679    }
5680}
5681
5682impl ControlFactory for HostControls {
5683    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5684        sys::control(mode)
5685    }
5686
5687    fn installed_definition(&self, path: &Path) -> Option<String> {
5688        std::fs::read_to_string(path).ok()
5689    }
5690
5691    fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
5692        crate::unattended_login::probe()
5693    }
5694}
5695
5696/// The operator's home directory, when the platform reports one.
5697///
5698/// Only login-mode registrations need it — a LaunchAgent and a systemd user
5699/// unit both live under it — and it is resolved lazily so that a boot-mode
5700/// operation on an account with no profile is not refused for wanting something
5701/// it never uses.
5702#[must_use]
5703pub fn host_home() -> Option<PathBuf> {
5704    directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
5705}
5706
5707/// The Unix backends' name for the same thing.
5708#[cfg(unix)]
5709fn home_directory() -> Option<PathBuf> {
5710    host_home()
5711}
5712
5713/// Runs a command and returns its exit status with both streams as text.
5714///
5715/// The three Unix backends and the Windows Task Scheduler backend all drive a
5716/// stock command-line tool, and all four want the same three things back. The
5717/// tools are chosen for machine-readable, locale-independent output wherever one
5718/// exists — `systemctl is-active`, `launchctl print`, `schtasks /XML` — because
5719/// parsing a localised human-facing table is how a status command starts lying
5720/// on somebody else's machine.
5721fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
5722    let output = std::process::Command::new(program)
5723        .args(arguments)
5724        .output()?;
5725    Ok((
5726        output.status.success(),
5727        String::from_utf8_lossy(&output.stdout).into_owned(),
5728        String::from_utf8_lossy(&output.stderr).into_owned(),
5729    ))
5730}
5731
5732/// Which manager holds a given start-mode domain on the platform this binary
5733/// was built for.
5734#[must_use]
5735pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
5736    if cfg!(windows) {
5737        match mode {
5738            StartMode::Boot => DefinitionKind::WindowsService,
5739            StartMode::Login => DefinitionKind::WindowsScheduledTask,
5740        }
5741    } else if cfg!(target_os = "macos") {
5742        DefinitionKind::LaunchdPlist
5743    } else {
5744        DefinitionKind::SystemdUnit
5745    }
5746}
5747
5748/// Enables a launchd label after it has been bootstrapped.
5749///
5750/// `launchctl bootstrap` deliberately preserves a label's disabled bit. An
5751/// `enable` failure therefore means the new registration cannot satisfy its
5752/// start policy. Compensate by booting it out and removing the plist so a
5753/// failed install cannot look installed to `service status`.
5754#[cfg(any(target_os = "macos", test))]
5755fn enable_launchd_registration(
5756    mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
5757    domain: &str,
5758    service_target: &str,
5759    plist: &Path,
5760    name: &str,
5761    elevation_remedy: &'static str,
5762) -> Result<(), ServiceError> {
5763    let (enabled, cause) = launchctl(&[
5764        std::ffi::OsStr::new("enable"),
5765        std::ffi::OsStr::new(service_target),
5766    ]);
5767    if enabled {
5768        return Ok(());
5769    }
5770
5771    let (booted_out, bootout_detail) = launchctl(&[
5772        std::ffi::OsStr::new("bootout"),
5773        std::ffi::OsStr::new(service_target),
5774    ]);
5775    let removed = std::fs::remove_file(plist);
5776    if !booted_out || removed.is_err() {
5777        return Err(ServiceError::Rollback {
5778            operation: "enable launchd registration",
5779            name: name.to_string(),
5780            cause,
5781            rollback: format!(
5782                "launchctl bootout {domain}: {}; remove {}: {}",
5783                if booted_out {
5784                    "succeeded".to_string()
5785                } else {
5786                    bootout_detail
5787                },
5788                plist.display(),
5789                removed
5790                    .err()
5791                    .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
5792            ),
5793        });
5794    }
5795
5796    if cause.to_ascii_lowercase().contains("permission denied") {
5797        Err(ServiceError::NeedsElevation {
5798            operation: "enable",
5799            name: name.to_string(),
5800            detail: cause,
5801            remedy: elevation_remedy,
5802        })
5803    } else {
5804        Err(ServiceError::Control {
5805            operation: "enable",
5806            name: name.to_string(),
5807            manager: "launchd",
5808            detail: cause,
5809        })
5810    }
5811}
5812
5813// ---------------------------------------------------------------------------
5814// Windows
5815// ---------------------------------------------------------------------------
5816
5817/// A stop request delivered by the Windows Service Control Manager.
5818///
5819/// The application owns the drain policy; this platform boundary only turns
5820/// `SERVICE_CONTROL_STOP`/`SHUTDOWN` into an awaitable notification.
5821#[derive(Debug, Clone)]
5822pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
5823
5824impl ServiceShutdown {
5825    /// Waits until SCM asks the service to stop or shut down.
5826    pub async fn wait(mut self) {
5827        if !*self.0.borrow() {
5828            let _ = self.0.changed().await;
5829        }
5830    }
5831}
5832
5833/// Runs the production process as a real Windows service host.
5834///
5835/// `StartServiceCtrlDispatcher` must run in the service process's main thread,
5836/// so the callback is handed through a process-global slot to the entrypoint
5837/// invoked by SCM. The slot is single-use by design: one process hosts exactly
5838/// one own-process service.
5839#[cfg(windows)]
5840pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
5841where
5842    F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
5843{
5844    windows_host::run(Box::new(run))
5845}
5846
5847#[cfg(windows)]
5848mod windows_host {
5849    use std::ffi::OsString;
5850    use std::sync::{Arc, Mutex, OnceLock, mpsc};
5851    use std::time::Duration;
5852
5853    use windows_service::service::{
5854        ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
5855        ServiceType,
5856    };
5857    use windows_service::service_control_handler::{
5858        self, ServiceControlHandlerResult, ServiceStatusHandle,
5859    };
5860
5861    use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
5862
5863    type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
5864
5865    struct Invocation {
5866        run: Runner,
5867        result: mpsc::SyncSender<Result<u8, String>>,
5868    }
5869
5870    static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
5871
5872    windows_service::define_windows_service!(ffi_service_main, service_main);
5873
5874    pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
5875        let (result_tx, result_rx) = mpsc::sync_channel(1);
5876        let slot = INVOCATION.get_or_init(|| Mutex::new(None));
5877        let mut invocation = slot
5878            .lock()
5879            .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
5880        if invocation.is_some() {
5881            return Err(host_error(
5882                "prepare",
5883                "the service-host slot was already used",
5884            ));
5885        }
5886        *invocation = Some(Invocation {
5887            run,
5888            result: result_tx,
5889        });
5890        drop(invocation);
5891
5892        windows_service::service_dispatcher::start("", ffi_service_main)
5893            .map_err(|error| host_error("connect", &error.to_string()))?;
5894        result_rx
5895            .recv()
5896            .map_err(|error| host_error("finish", &error.to_string()))?
5897            .map_err(|detail| host_error("run", &detail))
5898    }
5899
5900    fn service_main(_arguments: Vec<OsString>) {
5901        let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
5902            return;
5903        };
5904        let result = run_service(invocation.run);
5905        let _ = invocation.result.send(result);
5906    }
5907
5908    fn run_service(run: Runner) -> Result<u8, String> {
5909        let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
5910        let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
5911        let handler_status = Arc::clone(&status);
5912        let handler = move |control| match control {
5913            ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
5914            ServiceControl::Stop | ServiceControl::Shutdown => {
5915                if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
5916                    let _ = handle.set_service_status(service_status(
5917                        ServiceState::StopPending,
5918                        ServiceControlAccept::empty(),
5919                        1,
5920                        Duration::from_secs(300),
5921                        0,
5922                    ));
5923                }
5924                let _ = stop_tx.send(true);
5925                ServiceControlHandlerResult::NoError
5926            }
5927            _ => ServiceControlHandlerResult::NotImplemented,
5928        };
5929        let handle = service_control_handler::register("", handler)
5930            .map_err(|error| format!("cannot register the service control handler: {error}"))?;
5931        *status
5932            .lock()
5933            .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
5934        handle
5935            .set_service_status(service_status(
5936                ServiceState::Running,
5937                ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
5938                0,
5939                Duration::default(),
5940                0,
5941            ))
5942            .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
5943
5944        let exit = run(ServiceShutdown(stop_rx));
5945        handle
5946            .set_service_status(service_status(
5947                ServiceState::Stopped,
5948                ServiceControlAccept::empty(),
5949                0,
5950                Duration::default(),
5951                u32::from(exit),
5952            ))
5953            .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
5954        Ok(exit)
5955    }
5956
5957    fn service_status(
5958        state: ServiceState,
5959        accepted: ServiceControlAccept,
5960        checkpoint: u32,
5961        wait_hint: Duration,
5962        exit: u32,
5963    ) -> ServiceStatus {
5964        ServiceStatus {
5965            service_type: ServiceType::OWN_PROCESS,
5966            current_state: state,
5967            controls_accepted: accepted,
5968            exit_code: if exit == 0 {
5969                ServiceExitCode::Win32(0)
5970            } else {
5971                ServiceExitCode::ServiceSpecific(exit)
5972            },
5973            checkpoint,
5974            wait_hint,
5975            process_id: None,
5976        }
5977    }
5978
5979    fn host_error(operation: &'static str, detail: &str) -> ServiceError {
5980        ServiceError::Control {
5981            operation,
5982            name: SERVICE_NAME.to_string(),
5983            manager: "the Windows Service Control Manager",
5984            detail: detail.to_string(),
5985        }
5986    }
5987}
5988
5989#[cfg(windows)]
5990mod sys {
5991    //! Two managers, because Windows has two answers.
5992    //!
5993    //! `--start-at boot` is a service in the Service Control Manager, which is
5994    //! the only Windows facility that starts something before anybody logs in.
5995    //! `--start-at login` is a Task Scheduler task with a logon trigger, which
5996    //! is the only Windows facility that starts something when somebody does.
5997    //! There is no single mechanism that does both: service trigger-start has no
5998    //! logon trigger, and a scheduled task cannot run before a session exists.
5999
6000    use std::ffi::{OsStr, OsString};
6001    use std::time::{Duration, Instant};
6002
6003    use runner_manager_domain::model::StartMode;
6004    use windows_service::service::{
6005        ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
6006        ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
6007        ServiceState, ServiceType,
6008    };
6009    use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
6010
6011    use super::{
6012        DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
6013        ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
6014    };
6015
6016    /// `ERROR_SERVICE_DOES_NOT_EXIST`. Not an error here: it is the answer to
6017    /// "is this registered".
6018    const SERVICE_DOES_NOT_EXIST: i32 = 1060;
6019    /// `ERROR_SERVICE_MARKED_FOR_DELETE`. `DeleteService` is asynchronous: the
6020    /// registration remains in this state until every open service handle is
6021    /// closed, and callers must not mistake that short window for a leak.
6022    const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
6023    /// `ERROR_ACCESS_DENIED`.
6024    const ACCESS_DENIED: i32 = 5;
6025    const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
6026    const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
6027
6028    const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
6029         choose \"Run as administrator\".";
6030
6031    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6032        Ok(match mode {
6033            StartMode::Boot => Box::new(ScmControl),
6034            StartMode::Login => Box::new(TaskControl),
6035        })
6036    }
6037
6038    // -- the Service Control Manager -----------------------------------------
6039
6040    #[derive(Debug)]
6041    struct ScmControl;
6042
6043    /// Turns a `windows-service` failure into this module's error, keeping the
6044    /// "needs elevation" case separate: an operator told "access is denied" and
6045    /// an operator told "run this elevated" are not equally well served.
6046    fn scm_error(
6047        operation: &'static str,
6048        name: &str,
6049        error: &windows_service::Error,
6050    ) -> ServiceError {
6051        let raw = match error {
6052            windows_service::Error::Winapi(io) => io.raw_os_error(),
6053            _ => None,
6054        };
6055        let detail = match error {
6056            windows_service::Error::Winapi(io) => io.to_string(),
6057            other => other.to_string(),
6058        };
6059        if raw == Some(ACCESS_DENIED) {
6060            return ServiceError::NeedsElevation {
6061                operation,
6062                name: name.to_string(),
6063                detail,
6064                remedy: ELEVATION_REMEDY,
6065            };
6066        }
6067        ServiceError::Control {
6068            operation,
6069            name: name.to_string(),
6070            manager: "the Windows Service Control Manager",
6071            detail,
6072        }
6073    }
6074
6075    fn open_manager(
6076        access: ServiceManagerAccess,
6077        operation: &'static str,
6078        name: &str,
6079    ) -> Result<ServiceManager, ServiceError> {
6080        ServiceManager::local_computer(None::<&OsStr>, access)
6081            .map_err(|error| scm_error(operation, name, &error))
6082    }
6083
6084    impl ServiceControl for ScmControl {
6085        fn manager(&self) -> DefinitionKind {
6086            DefinitionKind::WindowsService
6087        }
6088
6089        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6090            let spec = windows_service_spec(plan);
6091            let manager = open_manager(
6092                ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
6093                "install",
6094                &spec.name,
6095            )?;
6096            let info = ServiceInfo {
6097                name: OsString::from(&spec.name),
6098                display_name: OsString::from(&spec.display_name),
6099                // Never INTERACTIVE_PROCESS: see `windows_service_descriptor`.
6100                service_type: ServiceType::OWN_PROCESS,
6101                start_type: if spec.automatic_start {
6102                    ServiceStartType::AutoStart
6103                } else {
6104                    ServiceStartType::OnDemand
6105                },
6106                error_control: ServiceErrorControl::Normal,
6107                executable_path: plan.binary().to_path_buf(),
6108                launch_arguments: plan.arguments().to_vec(),
6109                dependencies: Vec::new(),
6110                // `None` is LocalSystem, which is the account `d2`'s DACL admits.
6111                account_name: spec.account.as_ref().map(OsString::from),
6112                // No password is ever taken, asked for, or stored. An account
6113                // that needed one would be an account this installer refuses.
6114                account_password: None,
6115            };
6116            let service = manager
6117                .create_service(
6118                    &info,
6119                    ServiceAccess::CHANGE_CONFIG
6120                        | ServiceAccess::QUERY_CONFIG
6121                        | ServiceAccess::QUERY_STATUS
6122                        | ServiceAccess::START
6123                        | ServiceAccess::STOP
6124                        | ServiceAccess::DELETE,
6125                )
6126                .map_err(|error| scm_error("install", &spec.name, &error))?;
6127            service
6128                .set_description(&spec.description)
6129                .map_err(|error| scm_error("describe", &spec.name, &error))?;
6130            service
6131                .update_failure_actions(ServiceFailureActions {
6132                    reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
6133                    reboot_msg: None,
6134                    command: None,
6135                    // Three identical restart actions rather than one: the
6136                    // Service Control Manager applies the first action to the
6137                    // first failure, the second to the second, and the last to
6138                    // every failure after that. One action would leave the
6139                    // second and later failures unhandled, which is a service
6140                    // that comes back once and then stays down.
6141                    actions: Some(vec![
6142                        ServiceAction {
6143                            action_type: ServiceActionType::Restart,
6144                            delay: spec.restart.delay(),
6145                        };
6146                        3
6147                    ]),
6148                })
6149                .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
6150            // Without this the failure actions apply only to a crash, and a
6151            // daemon that exits non-zero is not a crash.
6152            service
6153                .set_failure_actions_on_non_crash_failures(true)
6154                .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
6155            Ok(ServiceDefinition::windows_service(plan))
6156        }
6157
6158        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6159            let manager =
6160                open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
6161            let service = match manager.open_service(
6162                identity.name(),
6163                ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
6164            ) {
6165                Ok(service) => Some(service),
6166                Err(error) if is_missing(&error) => return Ok(false),
6167                // A previous caller has already asked SCM to delete this
6168                // registration. That is the desired state, not an uninstall
6169                // failure. Wait for SCM to release the name so a following
6170                // `service install` can use it immediately.
6171                Err(error) if is_marked_for_delete(&error) => None,
6172                Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
6173            };
6174            if let Some(service) = service {
6175                // A running service can be deleted, but it lingers until it stops.
6176                // Stopping first is what makes `uninstall` followed by `install`
6177                // work in one sitting, which is what the start-mode switch needs.
6178                if let Ok(status) = service.query_status()
6179                    && status.current_state != ServiceState::Stopped
6180                {
6181                    let _ = service.stop();
6182                }
6183                match service.delete() {
6184                    Ok(()) => {}
6185                    // DeleteService is idempotent from the operator's point of
6186                    // view. Another process can mark the service between our
6187                    // OpenService and DeleteService calls.
6188                    Err(error) if is_marked_for_delete(&error) => {}
6189                    Err(error) => {
6190                        return Err(scm_error("uninstall", identity.name(), &error));
6191                    }
6192                }
6193                drop(service);
6194            }
6195
6196            // `DeleteService` marks a registration for deletion and returns;
6197            // SCM removes it only after the last service handle closes. Drop
6198            // ours before polling, then wait for the observable postcondition
6199            // promised by `uninstall`: an immediate status check must not see
6200            // a registration that is merely on its way out. This also keeps a
6201            // stop/uninstall/install sequence deterministic on busy hosts.
6202            let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
6203                match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
6204                    Ok(service) => {
6205                        drop(service);
6206                        Ok(false)
6207                    }
6208                    Err(error) if is_missing(&error) => Ok(true),
6209                    Err(error) if is_marked_for_delete(&error) => Ok(false),
6210                    Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
6211                }
6212            })?;
6213            if !absent {
6214                return Err(ServiceError::Control {
6215                    operation: "verify uninstall of",
6216                    name: identity.name().to_string(),
6217                    manager: "the Windows Service Control Manager",
6218                    detail: format!(
6219                        "the registration was still visible {} seconds after DeleteService; \
6220                         retry `service uninstall` from an elevated prompt",
6221                        DELETE_TIMEOUT.as_secs()
6222                    ),
6223                });
6224            }
6225            Ok(true)
6226        }
6227
6228        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6229            let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
6230            let service = match manager.open_service(
6231                identity.name(),
6232                ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
6233            ) {
6234                Ok(service) => service,
6235                Err(error) if is_missing(&error) => return Ok(None),
6236                Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
6237            };
6238            let config = service
6239                .query_config()
6240                .map_err(|error| scm_error("inspect", identity.name(), &error))?;
6241            let status = service
6242                .query_status()
6243                .map_err(|error| scm_error("inspect", identity.name(), &error))?;
6244            let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
6245                actions
6246                    .actions
6247                    .and_then(|actions| actions.into_iter().next())
6248                    .filter(|action| action.action_type == ServiceActionType::Restart)
6249                    .map(|action| action.delay)
6250            });
6251            Ok(Some(Registration {
6252                manager: DefinitionKind::WindowsService,
6253                start_mode: StartMode::Boot,
6254                // `lpBinaryPathName` holds the executable *and* its arguments
6255                // as one string, which is why this module carries its own
6256                // parser rather than trusting `executable_path`'s name.
6257                command_line: config.executable_path.to_string_lossy().into_owned(),
6258                account: config
6259                    .account_name
6260                    .map(|account| account.to_string_lossy().into_owned()),
6261                running: status.current_state == ServiceState::Running,
6262                starts_automatically: config.start_type == ServiceStartType::AutoStart,
6263                restart_delay,
6264            }))
6265        }
6266
6267        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6268            let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
6269            let service = manager
6270                .open_service(identity.name(), ServiceAccess::START)
6271                .map_err(|error| scm_error("start", identity.name(), &error))?;
6272            service
6273                .start::<&OsStr>(&[])
6274                .map_err(|error| scm_error("start", identity.name(), &error))
6275        }
6276
6277        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6278            let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
6279            let service = manager
6280                .open_service(
6281                    identity.name(),
6282                    ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
6283                )
6284                .map_err(|error| scm_error("stop", identity.name(), &error))?;
6285            let status = service
6286                .query_status()
6287                .map_err(|error| scm_error("stop", identity.name(), &error))?;
6288            if status.current_state == ServiceState::Stopped {
6289                return Ok(false);
6290            }
6291            service
6292                .stop()
6293                .map_err(|error| scm_error("stop", identity.name(), &error))?;
6294            Ok(true)
6295        }
6296    }
6297
6298    fn is_missing(error: &windows_service::Error) -> bool {
6299        matches!(error, windows_service::Error::Winapi(io)
6300            if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
6301    }
6302
6303    pub(super) fn is_marked_for_delete(error: &windows_service::Error) -> bool {
6304        matches!(error, windows_service::Error::Winapi(io)
6305            if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
6306    }
6307
6308    pub(super) fn wait_until_scm_absent(
6309        timeout: Duration,
6310        poll_interval: Duration,
6311        mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
6312    ) -> Result<bool, ServiceError> {
6313        let deadline = Instant::now() + timeout;
6314        loop {
6315            if probe_absent()? {
6316                return Ok(true);
6317            }
6318            if Instant::now() >= deadline {
6319                return Ok(false);
6320            }
6321            std::thread::sleep(poll_interval);
6322        }
6323    }
6324
6325    // -- Task Scheduler ------------------------------------------------------
6326
6327    #[derive(Debug)]
6328    struct TaskControl;
6329
6330    fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
6331        if detail.to_ascii_lowercase().contains("access is denied") {
6332            return ServiceError::NeedsElevation {
6333                operation,
6334                name: name.to_string(),
6335                detail,
6336                remedy: ELEVATION_REMEDY,
6337            };
6338        }
6339        ServiceError::Control {
6340            operation,
6341            name: name.to_string(),
6342            manager: "Windows Task Scheduler",
6343            detail,
6344        }
6345    }
6346
6347    fn schtasks(
6348        operation: &'static str,
6349        name: &str,
6350        arguments: &[&OsStr],
6351    ) -> Result<(bool, String), ServiceError> {
6352        match run("schtasks.exe", arguments) {
6353            Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
6354            Err(error) => Err(task_error(operation, name, error.to_string())),
6355        }
6356    }
6357
6358    /// Task Scheduler reads `/XML` files as UTF-16, so the document is written
6359    /// as UTF-16 little-endian with a byte-order mark rather than as UTF-8.
6360    fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
6361        let mut bytes = vec![0xFF, 0xFE];
6362        for unit in text.encode_utf16() {
6363            bytes.extend_from_slice(&unit.to_le_bytes());
6364        }
6365        std::fs::write(path, bytes)
6366    }
6367
6368    impl ServiceControl for TaskControl {
6369        fn manager(&self) -> DefinitionKind {
6370            DefinitionKind::WindowsScheduledTask
6371        }
6372
6373        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6374            let name = plan.identity().name().to_string();
6375            let principal = TaskPrincipal::current()?;
6376            let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
6377            let directory = tempfile::tempdir()
6378                .map_err(|error| task_error("install", &name, error.to_string()))?;
6379            let document = directory.path().join("task.xml");
6380            write_utf16(&document, definition.text())
6381                .map_err(|error| task_error("install", &name, error.to_string()))?;
6382            let (ok, message) = schtasks(
6383                "install",
6384                &name,
6385                &[
6386                    OsStr::new("/Create"),
6387                    OsStr::new("/TN"),
6388                    OsStr::new(&name),
6389                    OsStr::new("/XML"),
6390                    document.as_os_str(),
6391                ],
6392            )?;
6393            if !ok {
6394                return Err(task_error("install", &name, message));
6395            }
6396            Ok(definition)
6397        }
6398
6399        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6400            if self.query(identity)?.is_none() {
6401                return Ok(false);
6402            }
6403            let name = identity.name().to_string();
6404            let (ok, message) = schtasks(
6405                "uninstall",
6406                &name,
6407                &[
6408                    OsStr::new("/Delete"),
6409                    OsStr::new("/TN"),
6410                    OsStr::new(&name),
6411                    OsStr::new("/F"),
6412                ],
6413            )?;
6414            if !ok {
6415                return Err(task_error("uninstall", &name, message));
6416            }
6417            Ok(true)
6418        }
6419
6420        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6421            let name = identity.name().to_string();
6422            let (ok, document) = schtasks(
6423                "inspect",
6424                &name,
6425                &[
6426                    OsStr::new("/Query"),
6427                    OsStr::new("/TN"),
6428                    OsStr::new(&name),
6429                    OsStr::new("/XML"),
6430                    OsStr::new("ONE"),
6431                ],
6432            )?;
6433            if !ok {
6434                // `schtasks` reports a missing task and a broken Task Scheduler
6435                // the same way, with a non-zero exit; there is no distinct code.
6436                // Treating it as absence is the safe reading: the caller either
6437                // installs, which will then fail loudly, or reports "not
6438                // installed", which is what an operator with no task sees.
6439                return Ok(None);
6440            }
6441            let command = xml_value(&document, "Command").unwrap_or_default();
6442            let arguments = xml_value(&document, "Arguments").unwrap_or_default();
6443            let command_line = if arguments.is_empty() {
6444                super::quote_argument(&command)
6445            } else {
6446                format!("{} {arguments}", super::quote_argument(&command))
6447            };
6448            Ok(Some(Registration {
6449                manager: DefinitionKind::WindowsScheduledTask,
6450                start_mode: StartMode::Login,
6451                command_line,
6452                account: xml_value(&document, "UserId"),
6453                running: task_is_running(&name),
6454                starts_automatically: super::windows_login_task_starts_automatically(&document),
6455                restart_delay: xml_value(&document, "Interval")
6456                    .as_deref()
6457                    .and_then(parse_iso8601),
6458            }))
6459        }
6460
6461        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6462            let name = identity.name().to_string();
6463            let (ok, message) = schtasks(
6464                "start",
6465                &name,
6466                &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
6467            )?;
6468            if ok {
6469                Ok(())
6470            } else {
6471                Err(task_error("start", &name, message))
6472            }
6473        }
6474
6475        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6476            let running = self
6477                .query(identity)?
6478                .is_some_and(|registration| registration.running);
6479            if !running {
6480                return Ok(false);
6481            }
6482            let name = identity.name().to_string();
6483            let (ok, message) = schtasks(
6484                "stop",
6485                &name,
6486                &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
6487            )?;
6488            if ok {
6489                Ok(true)
6490            } else {
6491                Err(task_error("stop", &name, message))
6492            }
6493        }
6494    }
6495
6496    /// Whether Task Scheduler reports the task as running.
6497    ///
6498    /// **This is the one value in this module read from localised output.**
6499    /// `schtasks /Query /FO CSV` prints a `Status` column in the machine's
6500    /// display language, and Task Scheduler exposes no locale-independent
6501    /// equivalent short of COM. On a non-English Windows this therefore reports
6502    /// `false` for a task that is in fact running.
6503    ///
6504    /// That is stated rather than hidden because of what does *not* depend on
6505    /// it: Journey 5's liveness question is answered by the last successful
6506    /// GitHub contact, which is a timestamp this product writes itself and no
6507    /// locale can move. Nothing in the Definition of Done rests on this
6508    /// boolean.
6509    fn task_is_running(name: &str) -> bool {
6510        let Ok((true, stdout, _)) = run(
6511            "schtasks.exe",
6512            &[
6513                OsStr::new("/Query"),
6514                OsStr::new("/TN"),
6515                OsStr::new(name),
6516                OsStr::new("/FO"),
6517                OsStr::new("CSV"),
6518                OsStr::new("/NH"),
6519            ],
6520        ) else {
6521            return false;
6522        };
6523        stdout
6524            .lines()
6525            .filter_map(|line| line.rsplit(',').next())
6526            .any(|status| {
6527                status
6528                    .trim()
6529                    .trim_matches('"')
6530                    .eq_ignore_ascii_case("running")
6531            })
6532    }
6533
6534    /// Parses the `PT<n>M` shape this module writes, and the `PT<n>S` shape
6535    /// Task Scheduler would accept if it took seconds. Anything richer -- days,
6536    /// hours, a combination -- is `None` rather than a guess.
6537    fn parse_iso8601(value: &str) -> Option<Duration> {
6538        let rest = value.strip_prefix("PT")?;
6539        if let Some(minutes) = rest.strip_suffix('M') {
6540            return minutes
6541                .parse::<u64>()
6542                .ok()
6543                .map(|minutes| Duration::from_secs(minutes * 60));
6544        }
6545        rest.strip_suffix('S')?
6546            .parse::<u64>()
6547            .ok()
6548            .map(Duration::from_secs)
6549    }
6550}
6551
6552// ---------------------------------------------------------------------------
6553// Unix: shared plumbing
6554// ---------------------------------------------------------------------------
6555
6556/// Writes a definition file, creating its directory, and says when the refusal
6557/// was a permissions one.
6558///
6559/// Both Unix backends write a file into a directory only `root` can write —
6560/// `/Library/LaunchDaemons` and `/etc/systemd/system` — and an operator who ran
6561/// `service install` without `sudo` deserves to be told that rather than handed
6562/// an `EACCES`.
6563#[cfg(unix)]
6564fn write_definition(
6565    operation: &'static str,
6566    name: &str,
6567    path: &Path,
6568    text: &str,
6569    remedy: &'static str,
6570) -> Result<(), ServiceError> {
6571    if let Some(parent) = path.parent()
6572        && let Err(error) = std::fs::create_dir_all(parent)
6573        && error.kind() != std::io::ErrorKind::AlreadyExists
6574    {
6575        return Err(definition_error(operation, name, error, remedy, parent));
6576    }
6577    std::fs::write(path, text)
6578        .map_err(|error| definition_error(operation, name, error, remedy, path))
6579}
6580
6581/// Replaces an installed definition with [`DefinitionDrift::rendered`].
6582///
6583/// Through a temporary file in the same directory and a rename, so a failure
6584/// at any point leaves the old definition in place rather than a truncated
6585/// one. The mode is set to `0644` before the rename, as `service install`
6586/// leaves it: launchd refuses a plist that others can write.
6587#[cfg(all(unix, any(target_os = "macos", test)))]
6588fn replace_definition(name: &str, drift: &DefinitionDrift) -> Result<(), ServiceError> {
6589    use std::io::Write as _;
6590
6591    let path = drift.path.as_path();
6592    let directory = path.parent().unwrap_or_else(|| Path::new("/"));
6593    let failed = |error: std::io::Error, at: &Path| {
6594        definition_error("rewrite", name, error, SUDO_REMEDY, at)
6595    };
6596    let mut staged =
6597        tempfile::NamedTempFile::new_in(directory).map_err(|error| failed(error, directory))?;
6598    staged
6599        .write_all(drift.rendered.as_bytes())
6600        .and_then(|()| staged.as_file().sync_all())
6601        .map_err(|error| failed(error, staged.path()))?;
6602    std::fs::set_permissions(
6603        staged.path(),
6604        <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o644),
6605    )
6606    .map_err(|error| failed(error, path))?;
6607    staged
6608        .persist(path)
6609        .map(drop)
6610        .map_err(|error| failed(error.error, path))
6611}
6612
6613#[cfg(unix)]
6614fn definition_error(
6615    operation: &'static str,
6616    name: &str,
6617    error: std::io::Error,
6618    remedy: &'static str,
6619    path: &Path,
6620) -> ServiceError {
6621    let detail = format!("{}: {error}", path.display());
6622    if error.kind() == std::io::ErrorKind::PermissionDenied {
6623        ServiceError::NeedsElevation {
6624            operation,
6625            name: name.to_string(),
6626            detail,
6627            remedy,
6628        }
6629    } else {
6630        ServiceError::Control {
6631            operation,
6632            name: name.to_string(),
6633            manager: "the local service manager",
6634            detail,
6635        }
6636    }
6637}
6638
6639/// `sudo` is the remedy on both Unixes, and the message says which command.
6640#[cfg(unix)]
6641const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
6642     `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
6643     agent not running until you sign in.";
6644
6645// ---------------------------------------------------------------------------
6646// macOS
6647// ---------------------------------------------------------------------------
6648
6649#[cfg(target_os = "macos")]
6650mod sys {
6651    //! One manager, two domains. `--start-at boot` is a LaunchDaemon in
6652    //! `/Library/LaunchDaemons`, loaded into launchd's `system` domain;
6653    //! `--start-at login` is a LaunchAgent in the operator's own
6654    //! `~/Library/LaunchAgents`, loaded into `gui/<uid>`.
6655    //!
6656    //! `launchctl print` is the only inspection command here whose output is
6657    //! parsed, and it is English-only on every macOS release — launchd has no
6658    //! localisation for it. The registration's *existence* is decided by the
6659    //! plist file rather than by that output, so a launchd that changed its
6660    //! wording would cost this module the running/not-running line and nothing
6661    //! else.
6662
6663    use std::ffi::OsStr;
6664    use std::path::PathBuf;
6665    use std::time::Duration;
6666
6667    use runner_manager_domain::model::StartMode;
6668
6669    use super::{
6670        DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
6671        SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
6672        enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
6673        write_definition, xml_value,
6674    };
6675
6676    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6677        Ok(Box::new(LaunchdControl { mode }))
6678    }
6679
6680    #[derive(Debug)]
6681    struct LaunchdControl {
6682        mode: StartMode,
6683    }
6684
6685    impl LaunchdControl {
6686        /// launchd's domain target for this start mode.
6687        fn domain(&self) -> String {
6688            match self.mode {
6689                StartMode::Boot => "system".to_string(),
6690                // SAFETY: `getuid` reads the calling process's real user id and
6691                // cannot fail; it takes no arguments and touches no memory.
6692                StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
6693            }
6694        }
6695
6696        fn service_target(&self, identity: &ServiceIdentity) -> String {
6697            format!("{}/{}", self.domain(), identity.launchd_label())
6698        }
6699
6700        /// Where this domain's plist lives, when the domain has a home to put
6701        /// it in.
6702        fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
6703            let file = format!("{}.plist", identity.launchd_label());
6704            match self.mode {
6705                StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
6706                StartMode::Login => {
6707                    home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
6708                }
6709            }
6710        }
6711
6712        fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
6713            ServiceError::Control {
6714                operation,
6715                name: name.to_string(),
6716                manager: "launchd",
6717                detail,
6718            }
6719        }
6720
6721        fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
6722            match run("launchctl", arguments) {
6723                Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
6724                Err(error) => (false, error.to_string()),
6725            }
6726        }
6727    }
6728
6729    impl ServiceControl for LaunchdControl {
6730        fn manager(&self) -> DefinitionKind {
6731            DefinitionKind::LaunchdPlist
6732        }
6733
6734        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6735            let name = plan.identity().name().to_string();
6736            let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
6737            let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
6738                return Err(self.failed(
6739                    "install",
6740                    &name,
6741                    "this account has no home directory, so there is nowhere to put a \
6742                     LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
6743                     /Library/LaunchDaemons."
6744                        .to_string(),
6745                ));
6746            };
6747            write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
6748            let target = self.domain();
6749            let (ok, message) = self.launchctl(&[
6750                OsStr::new("bootstrap"),
6751                OsStr::new(&target),
6752                path.as_os_str(),
6753            ]);
6754            if !ok {
6755                // Leave nothing behind: a plist launchd refused is a file that
6756                // would make the next `service status` claim a registration
6757                // that does not exist.
6758                let _ = std::fs::remove_file(&path);
6759                if message.to_ascii_lowercase().contains("permission denied") {
6760                    return Err(ServiceError::NeedsElevation {
6761                        operation: "install",
6762                        name,
6763                        detail: message,
6764                        remedy: SUDO_REMEDY,
6765                    });
6766                }
6767                return Err(self.failed("install", &name, message));
6768            }
6769            // A previously disabled label stays disabled through a bootstrap,
6770            // which is a service that is installed and will never start.
6771            let service_target = self.service_target(plan.identity());
6772            enable_launchd_registration(
6773                |arguments| self.launchctl(arguments),
6774                &target,
6775                &service_target,
6776                &path,
6777                &name,
6778                SUDO_REMEDY,
6779            )?;
6780            Ok(definition)
6781        }
6782
6783        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6784            let Some(path) = self.plist_path(identity) else {
6785                return Ok(false);
6786            };
6787            if !path.exists() {
6788                return Ok(false);
6789            }
6790            let target = self.service_target(identity);
6791            let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
6792            if !ok
6793                && !message.to_ascii_lowercase().contains("no such process")
6794                && !message.contains("113")
6795            {
6796                if message.to_ascii_lowercase().contains("permission denied") {
6797                    return Err(ServiceError::NeedsElevation {
6798                        operation: "uninstall",
6799                        name: identity.name().to_string(),
6800                        detail: message,
6801                        remedy: SUDO_REMEDY,
6802                    });
6803                }
6804                return Err(self.failed("uninstall", identity.name(), message));
6805            }
6806            // Only the definition. `05-infrastructure.md` item 5: nothing else
6807            // on this host is touched here or anywhere below it.
6808            std::fs::remove_file(&path).map_err(|error| {
6809                super::definition_error(
6810                    "uninstall",
6811                    identity.name(),
6812                    error,
6813                    SUDO_REMEDY,
6814                    path.as_path(),
6815                )
6816            })?;
6817            Ok(true)
6818        }
6819
6820        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6821            let Some(path) = self.plist_path(identity) else {
6822                return Ok(None);
6823            };
6824            let Ok(document) = std::fs::read_to_string(&path) else {
6825                return Ok(None);
6826            };
6827            let target = self.service_target(identity);
6828            let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
6829            Ok(Some(Registration {
6830                manager: DefinitionKind::LaunchdPlist,
6831                start_mode: self.mode,
6832                command_line: program_arguments(&document),
6833                account: plist_string_value(&document, "UserName")
6834                    .or_else(|| Some("the invoking user".to_string())),
6835                running: loaded && printed.contains("state = running"),
6836                starts_automatically: document.contains("<key>RunAtLoad</key>")
6837                    && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
6838                restart_delay: xml_value(
6839                    super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
6840                    "integer",
6841                )
6842                .and_then(|value| value.parse::<u64>().ok())
6843                .map(Duration::from_secs),
6844            }))
6845        }
6846
6847        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6848            let target = self.service_target(identity);
6849            let (ok, message) = self.launchctl(&[
6850                OsStr::new("kickstart"),
6851                OsStr::new("-k"),
6852                OsStr::new(&target),
6853            ]);
6854            if ok {
6855                Ok(())
6856            } else {
6857                Err(self.failed("start", identity.name(), message))
6858            }
6859        }
6860
6861        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6862            let running = self
6863                .query(identity)?
6864                .is_some_and(|registration| registration.running);
6865            if !running {
6866                return Ok(false);
6867            }
6868            let target = self.service_target(identity);
6869            let (ok, message) = self.launchctl(&[
6870                OsStr::new("kill"),
6871                OsStr::new("SIGTERM"),
6872                OsStr::new(&target),
6873            ]);
6874            if ok {
6875                Ok(true)
6876            } else {
6877                Err(self.failed("stop", identity.name(), message))
6878            }
6879        }
6880    }
6881
6882    /// Boots the job out and bootstraps it again from `plist`, from a shell
6883    /// that outlives this process.
6884    ///
6885    /// `setsid` puts the shell in a session and process group of its own. On a
6886    /// bootout launchd sends SIGTERM to the job's whole process group, the
6887    /// process that asked for the bootout included, and spares a child in
6888    /// another session (measured on macOS 27). Without `setsid` the helper
6889    /// survived that only by luck: one run in four left the job unloaded. The
6890    /// work runs in a background subshell, so the outer shell exits at once and
6891    /// is reaped here rather than left behind. `bootout` blocks until the daemon
6892    /// has exited; the bootstrap is retried for a minute because launchd
6893    /// refuses it while the old job is still being torn down. When the job is
6894    /// still loaded after a failed bootout, every bootstrap fails and the
6895    /// running daemon carries on as it was.
6896    pub(super) fn reload_detached(
6897        identity: &ServiceIdentity,
6898        mode: StartMode,
6899        plist: &std::path::Path,
6900    ) -> Result<(), ServiceError> {
6901        use std::os::unix::process::CommandExt;
6902
6903        const SCRIPT: &str = r#"(
6904  sleep 1
6905  /bin/launchctl bootout "$1"
6906  tries=0
6907  until /bin/launchctl bootstrap "$2" "$3"; do
6908    tries=$((tries + 1))
6909    if [ "$tries" -ge 60 ]; then
6910      echo "runner-manager: launchd did not load $3 again; run: $4" >&2
6911      exit 1
6912    fi
6913    sleep 1
6914  done
6915) </dev/null &"#;
6916
6917        let control = LaunchdControl { mode };
6918        // Once the job is booted out nothing holds the single-instance lock,
6919        // so a reinstall in the same start mode is the remedy.
6920        let reinstall = match mode {
6921            StartMode::Boot => "sudo runner-manager service install --start-at boot",
6922            StartMode::Login => "runner-manager service install --start-at login",
6923        };
6924        let mut command = std::process::Command::new("/bin/sh");
6925        command
6926            .args(["-c", SCRIPT, "runner-manager-reload"])
6927            .arg(control.service_target(identity))
6928            .arg(control.domain())
6929            .arg(plist)
6930            .arg(reinstall)
6931            .stdin(std::process::Stdio::null());
6932        // SAFETY: one async-signal-safe system call between `fork` and `exec`,
6933        // touching no memory, allocator or lock.
6934        unsafe {
6935            command.pre_exec(|| {
6936                if libc::setsid() == -1 {
6937                    return Err(std::io::Error::last_os_error());
6938                }
6939                Ok(())
6940            });
6941        }
6942        let status = command.status().map_err(|error| {
6943            control.failed("reload", identity.name(), format!("/bin/sh: {error}"))
6944        })?;
6945        if status.success() {
6946            Ok(())
6947        } else {
6948            Err(control.failed(
6949                "reload",
6950                identity.name(),
6951                format!("the reload helper exited with {status}"),
6952            ))
6953        }
6954    }
6955
6956    /// Rebuilds the command line from a plist's `ProgramArguments` array.
6957    fn program_arguments(document: &str) -> String {
6958        let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
6959            return String::new();
6960        };
6961        let Some(end) = rest.find("</array>") else {
6962            return String::new();
6963        };
6964        let mut out = Vec::new();
6965        let mut cursor = &rest[..end];
6966        while let Some(open) = cursor.find("<string>") {
6967            let after = &cursor[open + "<string>".len()..];
6968            let Some(close) = after.find("</string>") else {
6969                break;
6970            };
6971            out.push(quote_argument(&super::xml_unescape(&after[..close])));
6972            cursor = &after[close..];
6973        }
6974        out.join(" ")
6975    }
6976}
6977
6978// ---------------------------------------------------------------------------
6979// Linux and the other Unixes
6980// ---------------------------------------------------------------------------
6981
6982#[cfg(all(unix, not(target_os = "macos")))]
6983mod sys {
6984    //! One manager, two domains. `--start-at boot` is a system unit in
6985    //! `/etc/systemd/system`, wanted by `multi-user.target`; `--start-at login`
6986    //! is a user unit in `~/.config/systemd/user`, wanted by `default.target`.
6987    //!
6988    //! Every inspection here goes through `systemctl is-active` and
6989    //! `systemctl is-enabled`, whose output is a fixed machine-readable word
6990    //! rather than a sentence, so nothing in this module depends on the
6991    //! machine's display language.
6992
6993    use std::ffi::OsStr;
6994    use std::path::PathBuf;
6995    use std::time::Duration;
6996
6997    use runner_manager_domain::model::StartMode;
6998
6999    use super::{
7000        DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
7001        SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
7002        home_directory, ini_directives, run, write_definition,
7003    };
7004
7005    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7006        Ok(Box::new(SystemdControl { mode }))
7007    }
7008
7009    #[derive(Debug)]
7010    struct SystemdControl {
7011        mode: StartMode,
7012    }
7013
7014    impl SystemdControl {
7015        fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
7016            let file = identity.systemd_unit();
7017            match self.mode {
7018                StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
7019                StartMode::Login => {
7020                    home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
7021                }
7022            }
7023        }
7024
7025        /// `systemctl`, with `--user` for the login domain.
7026        fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
7027            let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
7028            if self.mode == StartMode::Login {
7029                all.push(OsStr::new("--user"));
7030            }
7031            all.extend(arguments.iter().map(OsStr::new));
7032            match run("systemctl", &all) {
7033                Ok((ok, stdout, stderr)) => (
7034                    ok,
7035                    if stdout.trim().is_empty() {
7036                        stderr
7037                    } else {
7038                        stdout
7039                    },
7040                ),
7041                Err(error) => (false, error.to_string()),
7042            }
7043        }
7044
7045        fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
7046            ServiceError::Control {
7047                operation,
7048                name: name.to_string(),
7049                manager: "systemd",
7050                detail,
7051            }
7052        }
7053    }
7054
7055    impl ServiceControl for SystemdControl {
7056        fn manager(&self) -> DefinitionKind {
7057            DefinitionKind::SystemdUnit
7058        }
7059
7060        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7061            let name = plan.identity().name().to_string();
7062            let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
7063            let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
7064                return Err(self.failed(
7065                    "install",
7066                    &name,
7067                    "this account has no home directory, so there is nowhere to put a systemd \
7068                     user unit. Use --start-at boot, which installs a system unit under \
7069                     /etc/systemd/system."
7070                        .to_string(),
7071                ));
7072            };
7073            write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
7074            let unit = plan.identity().systemd_unit();
7075            let (reloaded, message) = self.systemctl(&["daemon-reload"]);
7076            if !reloaded {
7077                let _ = std::fs::remove_file(&path);
7078                return Err(self.failed("install", &name, message));
7079            }
7080            let (enabled, message) = self.systemctl(&["enable", &unit]);
7081            if !enabled {
7082                let _ = std::fs::remove_file(&path);
7083                let _ = self.systemctl(&["daemon-reload"]);
7084                return Err(self.failed("install", &name, message));
7085            }
7086            Ok(definition)
7087        }
7088
7089        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7090            let Some(path) = self.unit_path(identity) else {
7091                return Ok(false);
7092            };
7093            if !path.exists() {
7094                return Ok(false);
7095            }
7096            let unit = identity.systemd_unit();
7097            // `--now` stops it as well as disabling it. A failure here is not
7098            // fatal: the unit file is about to go, and refusing to remove it
7099            // because `systemctl` disliked something would leave the host with
7100            // a unit nothing manages.
7101            let _ = self.systemctl(&["disable", "--now", &unit]);
7102            std::fs::remove_file(&path).map_err(|error| {
7103                super::definition_error(
7104                    "uninstall",
7105                    identity.name(),
7106                    error,
7107                    SUDO_REMEDY,
7108                    path.as_path(),
7109                )
7110            })?;
7111            let _ = self.systemctl(&["daemon-reload"]);
7112            Ok(true)
7113        }
7114
7115        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7116            let Some(path) = self.unit_path(identity) else {
7117                return Ok(None);
7118            };
7119            let Ok(document) = std::fs::read_to_string(&path) else {
7120                return Ok(None);
7121            };
7122            let unit = identity.systemd_unit();
7123            let directives = ini_directives(&document, "Service");
7124            let (_, active) = self.systemctl(&["is-active", &unit]);
7125            let (_, enabled) = self.systemctl(&["is-enabled", &unit]);
7126            Ok(Some(Registration {
7127                manager: DefinitionKind::SystemdUnit,
7128                start_mode: self.mode,
7129                command_line: directives.get("ExecStart").cloned().unwrap_or_default(),
7130                account: directives.get("User").cloned().or_else(|| {
7131                    Some(match self.mode {
7132                        StartMode::Boot => "root".to_string(),
7133                        StartMode::Login => "the invoking user".to_string(),
7134                    })
7135                }),
7136                running: active.trim() == "active",
7137                starts_automatically: enabled.trim() == "enabled",
7138                restart_delay: directives
7139                    .get("RestartSec")
7140                    .and_then(|value| value.trim().trim_end_matches('s').parse::<u64>().ok())
7141                    .map(Duration::from_secs),
7142            }))
7143        }
7144
7145        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7146            let unit = identity.systemd_unit();
7147            let (ok, message) = self.systemctl(&["start", &unit]);
7148            if ok {
7149                Ok(())
7150            } else {
7151                Err(self.failed("start", identity.name(), message))
7152            }
7153        }
7154
7155        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7156            let running = self
7157                .query(identity)?
7158                .is_some_and(|registration| registration.running);
7159            if !running {
7160                return Ok(false);
7161            }
7162            let unit = identity.systemd_unit();
7163            let (ok, message) = self.systemctl(&["stop", &unit]);
7164            if ok {
7165                Ok(true)
7166            } else {
7167                Err(self.failed("stop", identity.name(), message))
7168            }
7169        }
7170    }
7171}
7172
7173/// What the macOS tests that load real launch agents share.
7174#[cfg(all(test, target_os = "macos"))]
7175pub(crate) mod launchd_fixture {
7176    use std::path::{Path, PathBuf};
7177
7178    /// Opts a macOS host into the tests that load launch agents into the
7179    /// caller's GUI session. CI's macOS leg sets it, and
7180    /// `privileged_tests_are_wired_into_ci.rs` asserts that it still does.
7181    const ACCEPTANCE: &str = "RUNNER_MANAGER_LAUNCHD_ACCEPTANCE";
7182
7183    /// Whether this run may load launch agents. Says so when it may not, since
7184    /// the test then passes having measured nothing.
7185    pub(crate) fn enabled() -> bool {
7186        let enabled = std::env::var_os(ACCEPTANCE).is_some_and(|value| value == "1");
7187        if !enabled {
7188            eprintln!("skipped: set {ACCEPTANCE}=1 to load test launch agents");
7189        }
7190        enabled
7191    }
7192
7193    /// A copy of the running test binary in `directory`, for a launchd job to
7194    /// run as its program.
7195    ///
7196    /// A copy, because a launchd job whose program lives on an external volume
7197    /// waits on a privacy approval nobody gives before `main` runs (measured
7198    /// with a target directory under `/Volumes`), and the temporary directory
7199    /// is on the boot volume.
7200    pub(crate) fn job_binary(directory: &Path) -> PathBuf {
7201        let binary = directory.join("launch-agent-job");
7202        std::fs::copy(std::env::current_exe().expect("test binary"), &binary)
7203            .expect("test binary copied");
7204        binary
7205    }
7206
7207    /// A launch agent loaded into the caller's GUI domain, booted out on drop.
7208    pub(crate) struct LoadedAgent(String);
7209
7210    impl LoadedAgent {
7211        /// Bootstraps `plist`, whose `Label` is `label`, and fails the test when
7212        /// launchd refuses it.
7213        pub(crate) fn bootstrap(label: &str, plist: &Path) -> Self {
7214            // SAFETY: `getuid` reads the calling process's real user id and
7215            // cannot fail.
7216            let domain = format!("gui/{}", unsafe { libc::getuid() });
7217            // Armed before the bootstrap, so a half-loaded job is removed too.
7218            let loaded = Self(format!("{domain}/{label}"));
7219            let bootstrap = std::process::Command::new("launchctl")
7220                .arg("bootstrap")
7221                .arg(&domain)
7222                .arg(plist)
7223                .output()
7224                .expect("launchctl runs");
7225            assert!(
7226                bootstrap.status.success(),
7227                "launchctl bootstrap {domain} refused {label}, so nothing was measured: {}",
7228                String::from_utf8_lossy(&bootstrap.stderr)
7229            );
7230            loaded
7231        }
7232
7233        /// `gui/<uid>/<label>`.
7234        pub(crate) fn target(&self) -> &str {
7235            &self.0
7236        }
7237    }
7238
7239    impl Drop for LoadedAgent {
7240        fn drop(&mut self) {
7241            let _ = std::process::Command::new("launchctl")
7242                .args(["bootout", &self.0])
7243                .output();
7244        }
7245    }
7246}
7247
7248#[cfg(test)]
7249mod tests {
7250    use super::*;
7251
7252    use std::collections::BTreeMap;
7253
7254    #[test]
7255    fn task_scheduler_omitting_default_enabled_still_means_automatic() {
7256        assert!(windows_login_task_starts_automatically(
7257            "<Task><Triggers><LogonTrigger></LogonTrigger></Triggers></Task>"
7258        ));
7259        assert!(windows_login_task_starts_automatically(
7260            "<Task><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Task>"
7261        ));
7262        assert!(!windows_login_task_starts_automatically(
7263            "<Task><LogonTrigger><Enabled>false</Enabled></LogonTrigger></Task>"
7264        ));
7265        assert!(!windows_login_task_starts_automatically(
7266            "<Task><BootTrigger></BootTrigger></Task>"
7267        ));
7268    }
7269
7270    // -----------------------------------------------------------------------
7271    // Fixtures
7272    // -----------------------------------------------------------------------
7273
7274    /// A plan against paths that exist on no platform, so that every renderer
7275    /// can be asserted on every leg of the CI matrix.
7276    fn linux_plan(mode: StartMode) -> InstallPlan {
7277        InstallPlan::unchecked(
7278            ServiceIdentity::product(),
7279            mode,
7280            "/opt/runner-manager/bin/runner-manager",
7281            ServiceDirectories {
7282                config: PathBuf::from("/var/lib/runner-manager/config"),
7283                state: PathBuf::from("/var/lib/runner-manager/state"),
7284                runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
7285                logs: PathBuf::from("/var/log/runner-manager"),
7286            },
7287        )
7288        .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
7289    }
7290
7291    fn windows_plan(mode: StartMode) -> InstallPlan {
7292        InstallPlan::unchecked(
7293            ServiceIdentity::product(),
7294            mode,
7295            "C:\\Program Files\\runner-manager\\runner-manager.exe",
7296            ServiceDirectories {
7297                config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
7298                state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
7299                runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
7300                logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
7301            },
7302        )
7303    }
7304
7305    /// Replaces one fragment of a rendered definition, and **fails the test if
7306    /// the fragment was not there**.
7307    ///
7308    /// Without the assertion a renderer change would quietly turn every
7309    /// "widened definition is rejected" test below into a test that reviews the
7310    /// unmodified definition and passes for the wrong reason. This is the
7311    /// single guard that keeps that class of vacuity out of this file.
7312    fn edited(text: &str, from: &str, to: &str) -> String {
7313        assert!(
7314            text.contains(from),
7315            "the rendered definition does not contain `{from}`, so this test would assert \
7316             nothing about a widened one"
7317        );
7318        text.replace(from, to)
7319    }
7320
7321    /// Every file under `root`, keyed by its path relative to `root`.
7322    fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
7323        fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
7324            let Ok(entries) = std::fs::read_dir(directory) else {
7325                return;
7326            };
7327            for entry in entries.flatten() {
7328                let path = entry.path();
7329                if path.is_dir() {
7330                    walk(&path, out);
7331                } else if let Ok(bytes) = std::fs::read(&path) {
7332                    out.insert(path, bytes);
7333                }
7334            }
7335        }
7336        let mut out = BTreeMap::new();
7337        for root in roots {
7338            walk(root, &mut out);
7339        }
7340        out
7341    }
7342
7343    struct Host {
7344        _root: tempfile::TempDir,
7345        paths: AppPaths,
7346        binary: PathBuf,
7347        /// A runner root inside this host's own temporary tree.
7348        ///
7349        /// Without it every `install` in this module would create and
7350        /// re-permission the **real** `%SystemDrive%\rman` on the machine
7351        /// running the tests, and would fail outright on any machine where that
7352        /// directory already exists with the access control `C:\` gives it.
7353        /// A sibling of the four application-data directories rather than a
7354        /// child of one, so `b1`'s overlap check has nothing to object to.
7355        runner_root: LocalAbsolutePath,
7356        controls: RecordingControls,
7357    }
7358
7359    impl Host {
7360        fn new() -> Self {
7361            let root = tempfile::tempdir().expect("a temporary directory");
7362            let paths = AppPaths::rooted_at(root.path());
7363            paths.create_all().expect("the four directories");
7364            let binary = root.path().join(if cfg!(windows) {
7365                "runner-manager.exe"
7366            } else {
7367                "runner-manager"
7368            });
7369            std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
7370            let runner_root = LocalAbsolutePath::new(
7371                root.path()
7372                    .join("runner-root")
7373                    .to_str()
7374                    .expect("a unicode temporary path"),
7375            )
7376            .expect("a local absolute path");
7377            Self {
7378                _root: root,
7379                paths,
7380                binary,
7381                runner_root,
7382                controls: RecordingControls::new(),
7383            }
7384        }
7385
7386        fn operations(&self) -> ServiceOperations {
7387            ServiceOperations::with_controls(
7388                self.paths.clone(),
7389                ServiceIdentity::product(),
7390                std::sync::Arc::new(self.controls.clone()),
7391            )
7392            .with_runner_root(self.runner_root.clone())
7393        }
7394
7395        fn request(&self, mode: StartMode) -> InstallRequest {
7396            InstallRequest::new(mode).for_binary(&self.binary)
7397        }
7398    }
7399
7400    #[cfg(windows)]
7401    #[test]
7402    fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
7403        let probes = std::cell::Cell::new(0);
7404        let absent =
7405            super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
7406                let next = probes.get() + 1;
7407                probes.set(next);
7408                Ok(next == 3)
7409            })
7410            .expect("the simulated SCM probe succeeds");
7411
7412        assert!(absent);
7413        assert_eq!(
7414            probes.get(),
7415            3,
7416            "uninstall must recheck after transient presence instead of treating it as a leak"
7417        );
7418    }
7419
7420    #[cfg(windows)]
7421    #[test]
7422    fn windows_recognises_an_already_pending_service_deletion() {
7423        let error = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(1072));
7424
7425        assert!(super::sys::is_marked_for_delete(&error));
7426    }
7427
7428    #[test]
7429    fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
7430        let root = tempfile::tempdir().expect("a temporary directory");
7431        let plist = root.path().join("fixture.plist");
7432        std::fs::write(&plist, b"fixture").expect("a plist fixture");
7433        let calls = std::cell::RefCell::new(Vec::new());
7434
7435        let error = enable_launchd_registration(
7436            |arguments| {
7437                let call = arguments
7438                    .iter()
7439                    .map(|argument| argument.to_string_lossy().into_owned())
7440                    .collect::<Vec<_>>();
7441                let operation = call[0].clone();
7442                calls.borrow_mut().push(call);
7443                if operation == "enable" {
7444                    (false, "label remains disabled".to_string())
7445                } else {
7446                    (true, String::new())
7447                }
7448            },
7449            "system",
7450            "system/com.openai.runner-manager-selftest",
7451            &plist,
7452            "runner-manager-selftest",
7453            "rerun with administrative rights",
7454        )
7455        .expect_err("enable failure must fail the install");
7456
7457        assert!(
7458            matches!(
7459                error,
7460                ServiceError::Control {
7461                    operation: "enable",
7462                    ..
7463                }
7464            ),
7465            "{error}"
7466        );
7467        assert_eq!(calls.borrow().len(), 2);
7468        assert_eq!(calls.borrow()[0][0], "enable");
7469        assert_eq!(calls.borrow()[1][0], "bootout");
7470        assert!(!plist.exists(), "rollback must remove the plist");
7471    }
7472
7473    // -----------------------------------------------------------------------
7474    // Quoting, and reading a command line back
7475    // -----------------------------------------------------------------------
7476
7477    #[test]
7478    fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
7479        let plan = windows_plan(StartMode::Boot);
7480        let command_line = plan.command_line();
7481        assert!(
7482            command_line.starts_with('"'),
7483            "a path with a space must be quoted, got {command_line}"
7484        );
7485        assert_eq!(
7486            executable_from_command_line(&command_line).as_deref(),
7487            Some(plan.binary())
7488        );
7489    }
7490
7491    #[test]
7492    fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
7493        let plan = linux_plan(StartMode::Boot);
7494        let command_line = plan.command_line();
7495        assert!(!command_line.starts_with('"'), "got {command_line}");
7496        assert_eq!(
7497            executable_from_command_line(&command_line).as_deref(),
7498            Some(plan.binary())
7499        );
7500    }
7501
7502    #[test]
7503    fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
7504        // Not a path anybody has, but it is the input that separates a real
7505        // implementation of Windows' quoting rules from a `split_whitespace`.
7506        let awkward = r#"C:\odd "name"\rm.exe"#;
7507        let quoted = quote_argument(awkward);
7508        assert_eq!(
7509            executable_from_command_line(&format!("{quoted} daemon run"))
7510                .as_deref()
7511                .map(Path::to_string_lossy)
7512                .as_deref(),
7513            Some(awkward)
7514        );
7515    }
7516
7517    #[test]
7518    fn an_empty_command_line_has_no_executable() {
7519        assert_eq!(executable_from_command_line("   "), None);
7520        assert_eq!(executable_from_command_line(""), None);
7521    }
7522
7523    #[test]
7524    fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
7525        let awkward = r#"DOMAIN\R&D <team> "ops""#;
7526        assert_eq!(
7527            xml_escape(awkward),
7528            "DOMAIN\\R&amp;D &lt;team&gt; &quot;ops&quot;"
7529        );
7530        assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
7531    }
7532
7533    // -----------------------------------------------------------------------
7534    // The restart policy is bounded at both ends
7535    // -----------------------------------------------------------------------
7536
7537    #[test]
7538    fn a_restart_delay_under_the_floor_is_refused() {
7539        let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
7540            .expect_err("half a second is under the one-second floor");
7541        assert!(
7542            matches!(error, ServiceError::RestartDelay { .. }),
7543            "{error}"
7544        );
7545    }
7546
7547    #[test]
7548    fn a_restart_delay_over_the_ceiling_is_refused() {
7549        let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
7550            .expect_err("an hour is over the five-minute ceiling");
7551        assert!(
7552            matches!(error, ServiceError::RestartDelay { .. }),
7553            "{error}"
7554        );
7555    }
7556
7557    #[test]
7558    fn a_reset_window_no_longer_than_the_delay_is_refused() {
7559        let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
7560            .expect_err("a window equal to the delay can never elapse between restarts");
7561        assert!(
7562            matches!(error, ServiceError::RestartResetWindow { .. }),
7563            "{error}"
7564        );
7565    }
7566
7567    #[test]
7568    fn a_delay_inside_the_bound_is_accepted() {
7569        let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
7570            .expect("twenty seconds is inside the bound");
7571        assert_eq!(policy.delay(), Duration::from_secs(20));
7572        assert_eq!(policy.reset_after(), Duration::from_secs(300));
7573    }
7574
7575    // -----------------------------------------------------------------------
7576    // Identity
7577    // -----------------------------------------------------------------------
7578
7579    #[test]
7580    fn a_fixture_identity_can_never_be_the_product_identity() {
7581        let fixture = ServiceIdentity::fixture("abc123");
7582        assert!(fixture.is_fixture());
7583        assert!(!ServiceIdentity::product().is_fixture());
7584        assert_ne!(fixture.name(), ServiceIdentity::product().name());
7585        assert_ne!(
7586            fixture.launchd_label(),
7587            ServiceIdentity::product().launchd_label()
7588        );
7589        assert_ne!(
7590            fixture.systemd_unit(),
7591            ServiceIdentity::product().systemd_unit()
7592        );
7593    }
7594
7595    #[test]
7596    fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
7597        let fixture = ServiceIdentity::fixture("A b/c:\\d");
7598        assert!(
7599            fixture
7600                .name()
7601                .chars()
7602                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
7603            "got {}",
7604            fixture.name()
7605        );
7606    }
7607
7608    // -----------------------------------------------------------------------
7609    // The systemd unit
7610    // -----------------------------------------------------------------------
7611
7612    #[test]
7613    fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
7614        let unit = systemd_unit(&linux_plan(StartMode::Boot));
7615        assert!(unit.contains("KillMode=process\n"), "{unit}");
7616        assert!(unit.contains("Restart=on-failure\n"), "{unit}");
7617        assert!(unit.contains("RestartSec=15\n"), "{unit}");
7618        assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
7619        assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
7620        assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
7621    }
7622
7623    #[test]
7624    fn the_boot_unit_reads_the_live_store_instead_of_a_frozen_systemd_copy() {
7625        let unit = systemd_unit(&linux_plan(StartMode::Boot));
7626        assert!(
7627            !unit.contains("LoadCredential="),
7628            "a startup snapshot would shadow every rotated credential until restart:\n{unit}"
7629        );
7630    }
7631
7632    #[test]
7633    fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
7634        let unit = systemd_unit(&linux_plan(StartMode::Login));
7635        assert!(
7636            !unit.contains("LoadCredential="),
7637            "a user unit must not name a root-owned credential file, got:\n{unit}"
7638        );
7639        assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
7640    }
7641
7642    #[test]
7643    fn the_unit_can_atomically_replace_the_credential_and_write_only_required_directories() {
7644        let plan = linux_plan(StartMode::Boot);
7645        let unit = systemd_unit(&plan);
7646        let directives = ini_directives(&unit, "Service");
7647        let listed = split_quoted(
7648            directives
7649                .get("ReadWritePaths")
7650                .expect("the unit names its writable paths"),
7651        );
7652        assert_eq!(listed.len(), 5, "{listed:?}");
7653        for path in plan.directories().all() {
7654            assert!(
7655                listed.iter().any(|entry| entry == &path.to_string_lossy()),
7656                "{} is missing from {listed:?}",
7657                path.display()
7658            );
7659        }
7660        assert!(
7661            listed
7662                .iter()
7663                .any(|entry| entry == "/var/lib/runner-manager/secrets"),
7664            "atomic credential replacement needs its parent directory in {listed:?}"
7665        );
7666    }
7667
7668    #[test]
7669    fn the_unit_records_the_absolute_binary_path() {
7670        let plan = linux_plan(StartMode::Boot);
7671        let unit = systemd_unit(&plan);
7672        assert!(
7673            unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
7674            "{unit}"
7675        );
7676    }
7677
7678    // -----------------------------------------------------------------------
7679    // The launchd property list
7680    // -----------------------------------------------------------------------
7681
7682    #[test]
7683    fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
7684        let plist = launchd_plist(&linux_plan(StartMode::Boot));
7685        // A bare `KeepAlive` would also restart a job the operator stopped
7686        // deliberately, which turns `service stop` into a fight with launchd.
7687        assert!(
7688            plist.contains(
7689                "<key>KeepAlive</key>\n  <dict>\n    <key>SuccessfulExit</key>\n    <false/>\n"
7690            ),
7691            "{plist}"
7692        );
7693        assert!(
7694            plist.contains("<key>ThrottleInterval</key>\n  <integer>15</integer>"),
7695            "{plist}"
7696        );
7697    }
7698
7699    #[test]
7700    fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
7701        let daemon = launchd_plist(&linux_plan(StartMode::Boot));
7702        assert_eq!(
7703            plist_string_value(&daemon, "UserName").as_deref(),
7704            Some("root")
7705        );
7706        assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
7707
7708        let agent = launchd_plist(&linux_plan(StartMode::Login));
7709        assert_eq!(
7710            plist_string_value(&agent, "UserName"),
7711            None,
7712            "a LaunchAgent already runs as the operator:\n{agent}"
7713        );
7714    }
7715
7716    #[test]
7717    fn the_plist_runs_the_daemon_and_its_runners_as_an_interactive_job() {
7718        for mode in [StartMode::Boot, StartMode::Login] {
7719            let plan = linux_plan(mode);
7720            let plist = launchd_plist(&plan);
7721            assert_eq!(
7722                plist_string_value(&plist, "ProcessType").as_deref(),
7723                Some("Interactive"),
7724                "launchd applies ProcessType to every runner the daemon starts:\n{plist}"
7725            );
7726            let review = review_least_privilege(
7727                &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, plist),
7728                &plan,
7729            );
7730            assert!(
7731                review
7732                    .controls()
7733                    .iter()
7734                    .any(|control| control == "ProcessType=Interactive"),
7735                "{review}"
7736            );
7737            assert!(
7738                !review
7739                    .findings()
7740                    .iter()
7741                    .any(|finding| finding.subject == "ProcessType"),
7742                "{review}"
7743            );
7744        }
7745    }
7746
7747    /// The value 0.4.31 and earlier installed, and launchd's default, both
7748    /// throttle every runner. That is a shortfall the update repairs, not an
7749    /// excess: a scheduling class grants no authority.
7750    #[test]
7751    fn a_plist_that_throttles_its_runners_is_a_shortfall() {
7752        let plan = linux_plan(StartMode::Login);
7753        let rendered = launchd_plist(&plan);
7754        let interactive = "<key>ProcessType</key>\n  <string>Interactive</string>\n";
7755        for (label, throttled) in [
7756            (
7757                "Background",
7758                edited(
7759                    &rendered,
7760                    interactive,
7761                    "<key>ProcessType</key>\n  <string>Background</string>\n",
7762                ),
7763            ),
7764            ("absent", edited(&rendered, interactive, "")),
7765        ] {
7766            let review = review_least_privilege(
7767                &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, throttled),
7768                &plan,
7769            );
7770            assert!(review.is_least_privilege(), "{label}: {review}");
7771            assert!(
7772                review.findings().iter().any(|finding| {
7773                    finding.kind == FindingKind::Shortfall
7774                        && finding.subject == "ProcessType"
7775                        && finding.detail.contains(label)
7776                }),
7777                "{label}: {review}"
7778            );
7779        }
7780    }
7781
7782    #[test]
7783    fn a_plist_with_an_old_process_type_is_drift_and_an_operator_edit_is_not() {
7784        let host = Host::new();
7785        let identity = ServiceIdentity::product();
7786        let plan = InstallPlan::resolve(
7787            identity.clone(),
7788            &host.request(StartMode::Login),
7789            ServiceDirectories::of(&host.paths),
7790        )
7791        .expect("a resolvable plan");
7792        let installed = launchd_plist(&plan);
7793        let definition =
7794            ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, installed.clone());
7795        let mut record = InstallRecord::of(&plan, &definition, Utc::now());
7796        record.installed_by_version = "0.4.31".into();
7797        let path = PathBuf::from("/Users/op/Library/LaunchAgents/runner-manager.plist");
7798
7799        assert_eq!(
7800            launchd_definition_drift(&identity, &record, path.clone(), &installed),
7801            None
7802        );
7803        let edited_by_operator = edited(
7804            &installed,
7805            "</dict>\n</plist>\n",
7806            "  <key>Nice</key>\n  <integer>0</integer>\n</dict>\n</plist>\n",
7807        );
7808        assert_eq!(
7809            launchd_definition_drift(&identity, &record, path.clone(), &edited_by_operator),
7810            None,
7811            "the daemon repairs this drift itself, so it must not key on edits it did not cause"
7812        );
7813
7814        let written_by_0_4_31 = edited(
7815            &installed,
7816            "<string>Interactive</string>",
7817            "<string>Background</string>",
7818        );
7819        let drift = launchd_definition_drift(&identity, &record, path, &written_by_0_4_31)
7820            .expect("a Background plist is drift");
7821        assert_eq!(drift.manager, DefinitionKind::LaunchdPlist);
7822        assert_eq!(drift.start_mode, StartMode::Login);
7823        assert_eq!(
7824            drift.rendered, installed,
7825            "the repair must write exactly what `service install` writes"
7826        );
7827
7828        let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
7829            .with_definition_drift(Some(drift));
7830        let problem = status
7831            .problems()
7832            .iter()
7833            .find(|problem| problem.subject == "definition")
7834            .expect("the drift is reported");
7835        // The remedy is a restart: a running daemon holds the lock that refuses
7836        // `service install`.
7837        assert!(
7838            problem.detail.contains("0.4.31")
7839                && problem.detail.contains(
7840                    "launchctl kickstart -k gui/$(id -u)/io.github.IvanMurzak.runner-manager`"
7841                )
7842                && !problem.detail.contains("service install")
7843                && !problem.detail.contains("systemctl")
7844                && !problem.detail.contains("sudo"),
7845            "{}",
7846            problem.detail
7847        );
7848    }
7849
7850    #[cfg(unix)]
7851    #[test]
7852    fn a_replaced_definition_is_the_rendering_and_readable_by_launchd() {
7853        use std::os::unix::fs::PermissionsExt as _;
7854
7855        let root = tempfile::tempdir().expect("a temporary directory");
7856        let path = root
7857            .path()
7858            .join("io.github.IvanMurzak.runner-manager.plist");
7859        std::fs::write(&path, "<string>Background</string>").expect("old plist");
7860        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
7861        let drift = DefinitionDrift {
7862            path: path.clone(),
7863            installed_by_version: "0.4.31".into(),
7864            rendered: launchd_plist(&linux_plan(StartMode::Login)),
7865            start_mode: StartMode::Login,
7866            manager: DefinitionKind::LaunchdPlist,
7867        };
7868
7869        replace_definition("runner-manager", &drift).expect("replaced");
7870
7871        assert_eq!(std::fs::read_to_string(&path).unwrap(), drift.rendered);
7872        assert_eq!(
7873            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
7874            0o644
7875        );
7876        assert_eq!(
7877            std::fs::read_dir(root.path()).unwrap().count(),
7878            1,
7879            "no staging file is left beside the plist"
7880        );
7881    }
7882
7883    /// The job of [`a_job_reloads_itself_from_its_rewritten_plist`], run by
7884    /// launchd and by nothing else.
7885    ///
7886    /// It does what the daemon does when it starts: when its own plist (kept
7887    /// in its working directory) does not carry [`LAUNCHD_PROCESS_TYPE`], it
7888    /// rewrites the plist and asks for the reload from inside the job that the
7889    /// reload boots out. Either way it then stays up, to be booted out by the
7890    /// helper or, once reloaded, by the test.
7891    #[cfg(target_os = "macos")]
7892    #[test]
7893    #[ignore = "run only by launchd, as the job of the detached-reload test"]
7894    fn detached_reload_job() {
7895        let label = std::env::var("XPC_SERVICE_NAME").unwrap_or_default();
7896        let tag = label
7897            .split_once("-selftest-")
7898            .map(|(_, tag)| tag.to_string())
7899            .unwrap_or_else(|| panic!("run only by launchd for the reload test, not {label:?}"));
7900        let identity = ServiceIdentity::fixture(&tag);
7901        assert_eq!(identity.launchd_label(), label);
7902        let path = std::env::current_dir()
7903            .expect("working directory")
7904            .join(format!("{label}.plist"));
7905        let installed = std::fs::read_to_string(&path).expect("own plist");
7906        if plist_string_value(&installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE) {
7907            let drift = DefinitionDrift {
7908                path: path.clone(),
7909                installed_by_version: "0.4.31".into(),
7910                rendered: installed.replace(
7911                    "<string>Background</string>",
7912                    &format!("<string>{LAUNCHD_PROCESS_TYPE}</string>"),
7913                ),
7914                start_mode: StartMode::Login,
7915                manager: DefinitionKind::LaunchdPlist,
7916            };
7917            replace_definition(identity.name(), &drift).expect("plist replaced");
7918            super::sys::reload_detached(&identity, StartMode::Login, &path)
7919                .expect("helper started");
7920        }
7921        std::thread::sleep(Duration::from_secs(120));
7922    }
7923
7924    /// A job that rewrites its own plist and asks for the reload comes back
7925    /// under the rewritten plist.
7926    ///
7927    /// The request comes from inside the job, as the daemon's does, so the
7928    /// helper has to outlive the bootout of the very job that started it.
7929    /// launchd sends SIGTERM to the job's whole process group, so a helper that
7930    /// stayed in it would sometimes die before its bootstrap and leave the job
7931    /// unloaded. Measured with `setsid` removed, that failed one run in four
7932    /// here: this test catches it, though not on every run.
7933    #[cfg(target_os = "macos")]
7934    #[test]
7935    fn a_job_reloads_itself_from_its_rewritten_plist() {
7936        if !super::launchd_fixture::enabled() {
7937            return;
7938        }
7939        let root = tempfile::tempdir().expect("a temporary directory");
7940        let identity = ServiceIdentity::fixture(&format!("reload-{}", std::process::id()));
7941        let label = identity.launchd_label();
7942        let path = root.path().join(format!("{label}.plist"));
7943        let binary = super::launchd_fixture::job_binary(root.path());
7944        let mut plist = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
7945        plist.push_str("<plist version=\"1.0\">\n<dict>\n");
7946        plist.push_str(&plist_string("Label", &label));
7947        plist.push_str("  <key>ProgramArguments</key>\n  <array>\n");
7948        for argument in [
7949            binary.to_string_lossy().as_ref(),
7950            "--ignored",
7951            "--exact",
7952            "service::tests::detached_reload_job",
7953            "--test-threads=1",
7954        ] {
7955            plist.push_str(&format!("    <string>{}</string>\n", xml_escape(argument)));
7956        }
7957        plist.push_str("  </array>\n  <key>RunAtLoad</key>\n  <true/>\n");
7958        plist.push_str(&plist_string(
7959            "WorkingDirectory",
7960            &root.path().to_string_lossy(),
7961        ));
7962        plist.push_str(&plist_string("ProcessType", "Background"));
7963        plist.push_str("</dict>\n</plist>\n");
7964        std::fs::write(&path, plist).expect("plist written");
7965
7966        let loaded = super::launchd_fixture::LoadedAgent::bootstrap(&label, &path);
7967        let spawn_type = || {
7968            let printed = std::process::Command::new("launchctl")
7969                .args(["print", loaded.target()])
7970                .output()
7971                .expect("launchctl runs");
7972            String::from_utf8_lossy(&printed.stdout)
7973                .lines()
7974                .find_map(|line| {
7975                    line.trim()
7976                        .strip_prefix("spawn type = ")
7977                        .map(str::to_string)
7978                })
7979        };
7980        assert_eq!(spawn_type().as_deref(), Some("background (5)"));
7981
7982        let deadline = std::time::Instant::now() + Duration::from_secs(90);
7983        while spawn_type().as_deref() != Some("interactive (4)") {
7984            assert!(
7985                std::time::Instant::now() < deadline,
7986                "the job did not come back under its rewritten plist; launchd reports {:?}",
7987                spawn_type()
7988            );
7989            std::thread::sleep(Duration::from_millis(250));
7990        }
7991        let rewritten = std::fs::read_to_string(&path).expect("plist readable");
7992        assert_eq!(
7993            plist_string_value(&rewritten, "ProcessType").as_deref(),
7994            Some(LAUNCHD_PROCESS_TYPE)
7995        );
7996    }
7997
7998    #[test]
7999    fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
8000        let plist = launchd_plist(&linux_plan(StartMode::Boot));
8001        assert!(
8002            plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
8003            "{plist}"
8004        );
8005        assert!(plist.contains("<string>daemon</string>"), "{plist}");
8006        assert!(plist.contains("<string>run</string>"), "{plist}");
8007    }
8008
8009    #[test]
8010    fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
8011        assert_eq!(
8012            ServiceIdentity::product().launchd_label(),
8013            "io.github.IvanMurzak.runner-manager"
8014        );
8015    }
8016
8017    // -----------------------------------------------------------------------
8018    // The Task Scheduler document
8019    // -----------------------------------------------------------------------
8020
8021    #[test]
8022    fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
8023        let xml = windows_scheduled_task_xml(
8024            &windows_plan(StartMode::Login),
8025            &TaskPrincipal::named("HOST\\operator"),
8026        );
8027        assert!(xml.contains("<LogonTrigger>"), "{xml}");
8028        assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
8029        assert!(
8030            xml.contains("<LogonType>InteractiveToken</LogonType>"),
8031            "{xml}"
8032        );
8033        assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
8034        assert!(
8035            xml.contains("<Interval>PT1M</Interval>"),
8036            "Task Scheduler takes whole minutes only, and rejects the registration outright \
8037             for anything finer:\n{xml}"
8038        );
8039    }
8040
8041    #[test]
8042    fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
8043        // Measured against the real Task Scheduler, which answers `PT15S` with
8044        // "The task XML contains a value which is incorrectly formatted or out
8045        // of range". Rounding *up* is what keeps "does not restart-loop faster
8046        // than that bound" true on this manager.
8047        for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
8048            let policy =
8049                RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
8050                    .expect("inside the supported range");
8051            assert_eq!(
8052                policy
8053                    .effective_delay(DefinitionKind::WindowsScheduledTask)
8054                    .as_secs(),
8055                enforced,
8056                "a {asked}s delay must be enforced as {enforced}s"
8057            );
8058            assert!(
8059                policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
8060                "rounding must never shorten the bound"
8061            );
8062        }
8063    }
8064
8065    #[test]
8066    fn every_other_manager_enforces_the_delay_exactly_as_configured() {
8067        let policy = RestartPolicy::default();
8068        for kind in [
8069            DefinitionKind::WindowsService,
8070            DefinitionKind::LaunchdPlist,
8071            DefinitionKind::SystemdUnit,
8072        ] {
8073            assert_eq!(
8074                policy.effective_delay(kind),
8075                policy.delay(),
8076                "{kind:?} takes seconds and enforces exactly what it is given"
8077            );
8078        }
8079    }
8080
8081    #[test]
8082    fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
8083        let host = Host::new();
8084        let operations = host.operations();
8085        operations
8086            .install(&host.request(StartMode::Login))
8087            .expect("an install at login");
8088        // What a real Task Scheduler reports back for a 15-second policy.
8089        host.controls.edit("runner-manager", |registration| {
8090            registration.manager = DefinitionKind::WindowsScheduledTask;
8091            registration.restart_delay = Some(Duration::from_secs(60));
8092        });
8093
8094        let status = operations.status().expect("a status");
8095        assert!(
8096            status.is_healthy(),
8097            "minute granularity is the manager's, not a mis-registration: {status}"
8098        );
8099        assert!(
8100            status
8101                .notes()
8102                .iter()
8103                .any(|note| note.contains("whole minutes")),
8104            "but the operator must be told why 15 became 60: {status}"
8105        );
8106
8107        // The discriminator: a delay that is neither the configured one nor its
8108        // rounding is still a fault.
8109        host.controls.edit("runner-manager", |registration| {
8110            registration.restart_delay = Some(Duration::from_secs(1));
8111        });
8112        assert!(
8113            !operations.status().expect("a status").is_healthy(),
8114            "a one-second delay is not what any manager was asked for"
8115        );
8116    }
8117
8118    #[test]
8119    fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
8120        let plan = windows_plan(StartMode::Login);
8121        let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
8122        assert_eq!(
8123            xml_value(&xml, "Command").as_deref(),
8124            Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
8125            "{xml}"
8126        );
8127        assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
8128    }
8129
8130    #[test]
8131    fn an_account_name_holding_xml_punctuation_is_escaped() {
8132        let xml = windows_scheduled_task_xml(
8133            &windows_plan(StartMode::Login),
8134            &TaskPrincipal::named("R&D\\ops"),
8135        );
8136        assert!(xml.contains("<UserId>R&amp;D\\ops</UserId>"), "{xml}");
8137        assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
8138    }
8139
8140    // -----------------------------------------------------------------------
8141    // The Windows service descriptor
8142    // -----------------------------------------------------------------------
8143
8144    #[test]
8145    fn the_service_starts_automatically_under_the_account_the_store_admits() {
8146        let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
8147        let directives = ini_directives(&text, "windows-service");
8148        assert_eq!(
8149            directives.get("StartType").map(String::as_str),
8150            Some("AutoStart")
8151        );
8152        assert_eq!(
8153            directives.get("Account").map(String::as_str),
8154            Some("NT AUTHORITY\\SYSTEM")
8155        );
8156        assert_eq!(
8157            directives.get("ServiceType").map(String::as_str),
8158            Some("OWN_PROCESS")
8159        );
8160        assert_eq!(
8161            directives
8162                .get("FailureActionRestartDelaySecs")
8163                .map(String::as_str),
8164            Some("15")
8165        );
8166        assert_eq!(
8167            directives
8168                .get("FailureActionsOnNonCrashFailures")
8169                .map(String::as_str),
8170            Some("true"),
8171            "without this flag a non-zero exit is not a failure the manager restarts"
8172        );
8173    }
8174
8175    #[test]
8176    fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
8177        let spec = windows_service_spec(&windows_plan(StartMode::Boot));
8178        assert_eq!(spec.account, None);
8179        assert!(spec.automatic_start);
8180        assert!(
8181            spec.command_line.contains("daemon run"),
8182            "{}",
8183            spec.command_line
8184        );
8185    }
8186
8187    // -----------------------------------------------------------------------
8188    // Where each definition is installed
8189    // -----------------------------------------------------------------------
8190
8191    #[test]
8192    fn each_definition_goes_where_its_platform_expects_it() {
8193        let home = PathBuf::from("/home/op");
8194        assert_eq!(
8195            ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
8196            Some(Path::new(
8197                "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
8198            ))
8199        );
8200        assert_eq!(
8201            ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
8202            Some(Path::new(
8203                "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
8204            ))
8205        );
8206        assert_eq!(
8207            ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
8208            Some(Path::new("/etc/systemd/system/runner-manager.service"))
8209        );
8210        assert_eq!(
8211            ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
8212            Some(Path::new(
8213                "/home/op/.config/systemd/user/runner-manager.service"
8214            ))
8215        );
8216        assert_eq!(
8217            ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
8218            None,
8219            "the Service Control Manager has no file"
8220        );
8221    }
8222
8223    #[test]
8224    fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
8225        assert_eq!(
8226            ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
8227            None
8228        );
8229        assert_eq!(
8230            ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
8231            None
8232        );
8233    }
8234
8235    // -----------------------------------------------------------------------
8236    // The least-privilege review: the passing case, then every failing one
8237    // -----------------------------------------------------------------------
8238
8239    #[test]
8240    fn the_rendered_definitions_are_all_least_privilege() {
8241        let linux = linux_plan(StartMode::Boot);
8242        let windows = windows_plan(StartMode::Boot);
8243        for (definition, plan) in [
8244            (ServiceDefinition::systemd(&linux, None), &linux),
8245            (ServiceDefinition::launchd(&linux, None), &linux),
8246            (ServiceDefinition::windows_service(&windows), &windows),
8247        ] {
8248            let review = review_least_privilege(&definition, plan);
8249            assert!(
8250                review.is_least_privilege(),
8251                "{:?} should be least privilege, got:\n{review}",
8252                definition.kind()
8253            );
8254            assert!(
8255                !review.controls().is_empty(),
8256                "a review that confirms nothing proves nothing: {:?}",
8257                definition.kind()
8258            );
8259        }
8260    }
8261
8262    #[test]
8263    fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
8264        let plan = windows_plan(StartMode::Login);
8265        let definition =
8266            ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
8267        let review = review_least_privilege(&definition, &plan);
8268        assert!(review.is_least_privilege(), "{review}");
8269        assert!(
8270            review
8271                .controls()
8272                .iter()
8273                .any(|control| control.contains("LeastPrivilege")),
8274            "{review}"
8275        );
8276    }
8277
8278    #[test]
8279    fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
8280        let plan = linux_plan(StartMode::Boot);
8281        let rendered = systemd_unit(&plan);
8282        let widened = edited(
8283            &rendered,
8284            "ReadWritePaths=/var/lib/runner-manager/config",
8285            "ReadWritePaths=/etc /var/lib/runner-manager/config",
8286        );
8287        let review = review_least_privilege(
8288            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
8289            &plan,
8290        );
8291        assert!(!review.is_least_privilege(), "{review}");
8292        assert!(
8293            review
8294                .excesses()
8295                .iter()
8296                .any(|finding| finding.detail.contains("/etc")),
8297            "the review must name the directory it objects to: {review}"
8298        );
8299    }
8300
8301    #[test]
8302    fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
8303        let plan = linux_plan(StartMode::Boot);
8304        let rendered = systemd_unit(&plan);
8305        let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
8306        let review = review_least_privilege(
8307            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
8308            &plan,
8309        );
8310        assert!(!review.is_least_privilege(), "{review}");
8311        assert!(
8312            review
8313                .excesses()
8314                .iter()
8315                .any(|finding| finding.subject == "NoNewPrivileges"),
8316            "{review}"
8317        );
8318    }
8319
8320    #[test]
8321    fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
8322        let plan = linux_plan(StartMode::Boot);
8323        let rendered = systemd_unit(&plan);
8324        let widened = edited(
8325            &rendered,
8326            "CapabilityBoundingSet=\n",
8327            "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
8328        );
8329        let review = review_least_privilege(
8330            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
8331            &plan,
8332        );
8333        assert!(!review.is_least_privilege(), "{review}");
8334        assert!(
8335            review
8336                .excesses()
8337                .iter()
8338                .any(|finding| finding.subject == "CapabilityBoundingSet"),
8339            "{review}"
8340        );
8341    }
8342
8343    #[test]
8344    fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
8345        let plan = linux_plan(StartMode::Boot);
8346        let rendered = systemd_unit(&plan);
8347        let widened = edited(
8348            &rendered,
8349            "[Install]",
8350            "ListenStream=127.0.0.1:9000\n\n[Install]",
8351        );
8352        let review = review_least_privilege(
8353            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
8354            &plan,
8355        );
8356        assert!(
8357            !review.is_least_privilege(),
8358            "07-security.md rule 2 forbids any inbound surface: {review}"
8359        );
8360    }
8361
8362    #[test]
8363    fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
8364        let plan = linux_plan(StartMode::Boot);
8365        let rendered = systemd_unit(&plan);
8366        let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
8367        let review = review_least_privilege(
8368            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
8369            &plan,
8370        );
8371        assert!(
8372            review.is_least_privilege(),
8373            "too little authority is not an excess: {review}"
8374        );
8375        assert!(
8376            review
8377                .findings()
8378                .iter()
8379                .any(|finding| finding.kind == FindingKind::Shortfall
8380                    && finding.detail.contains("runtime")),
8381            "{review}"
8382        );
8383    }
8384
8385    #[test]
8386    fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
8387        let plan = linux_plan(StartMode::Login);
8388        let rendered = launchd_plist(&plan);
8389        let widened = edited(
8390            &rendered,
8391            "<key>ProcessType</key>",
8392            "<key>UserName</key>\n  <string>root</string>\n  <key>ProcessType</key>",
8393        );
8394        let review = review_least_privilege(
8395            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
8396            &plan,
8397        );
8398        assert!(!review.is_least_privilege(), "{review}");
8399        assert!(
8400            review
8401                .excesses()
8402                .iter()
8403                .any(|finding| finding.subject == "UserName"),
8404            "{review}"
8405        );
8406    }
8407
8408    #[test]
8409    fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
8410        let plan = linux_plan(StartMode::Boot);
8411        let rendered = launchd_plist(&plan);
8412        let widened = edited(
8413            &rendered,
8414            "<key>SessionCreate</key>\n  <false/>",
8415            "<key>SessionCreate</key>\n  <true/>",
8416        );
8417        let review = review_least_privilege(
8418            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
8419            &plan,
8420        );
8421        assert!(!review.is_least_privilege(), "{review}");
8422        assert!(
8423            review
8424                .excesses()
8425                .iter()
8426                .any(|finding| finding.subject == "SessionCreate"),
8427            "{review}"
8428        );
8429    }
8430
8431    #[test]
8432    fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
8433        let plan = linux_plan(StartMode::Boot);
8434        let rendered = launchd_plist(&plan);
8435        let widened = edited(
8436            &rendered,
8437            "<key>ProcessType</key>",
8438            "<key>MachServices</key>\n  <dict/>\n  <key>ProcessType</key>",
8439        );
8440        let review = review_least_privilege(
8441            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
8442            &plan,
8443        );
8444        assert!(!review.is_least_privilege(), "{review}");
8445    }
8446
8447    #[test]
8448    fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
8449        let plan = windows_plan(StartMode::Login);
8450        let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
8451        let widened = edited(
8452            &rendered,
8453            "<RunLevel>LeastPrivilege</RunLevel>",
8454            "<RunLevel>HighestAvailable</RunLevel>",
8455        );
8456        let review = review_least_privilege(
8457            &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
8458            &plan,
8459        );
8460        assert!(!review.is_least_privilege(), "{review}");
8461        assert!(
8462            review
8463                .excesses()
8464                .iter()
8465                .any(|finding| finding.subject == "RunLevel"),
8466            "{review}"
8467        );
8468    }
8469
8470    #[test]
8471    fn a_task_that_would_store_a_password_is_not_least_privilege() {
8472        let plan = windows_plan(StartMode::Login);
8473        let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
8474        let widened = edited(
8475            &rendered,
8476            "<LogonType>InteractiveToken</LogonType>",
8477            "<LogonType>Password</LogonType>",
8478        );
8479        let review = review_least_privilege(
8480            &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
8481            &plan,
8482        );
8483        assert!(!review.is_least_privilege(), "{review}");
8484    }
8485
8486    #[test]
8487    fn an_interactive_windows_service_is_not_least_privilege() {
8488        let plan = windows_plan(StartMode::Boot);
8489        let rendered = windows_service_descriptor(&plan);
8490        let widened = edited(
8491            &rendered,
8492            "ServiceType=OWN_PROCESS",
8493            "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
8494        );
8495        let review = review_least_privilege(
8496            &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
8497            &plan,
8498        );
8499        assert!(!review.is_least_privilege(), "{review}");
8500        assert!(
8501            review
8502                .excesses()
8503                .iter()
8504                .any(|finding| finding.subject == "ServiceType"),
8505            "{review}"
8506        );
8507    }
8508
8509    #[test]
8510    fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
8511        let plan = windows_plan(StartMode::Boot);
8512        let rendered = windows_service_descriptor(&plan);
8513        let changed = edited(
8514            &rendered,
8515            "Account=NT AUTHORITY\\SYSTEM",
8516            "Account=NT AUTHORITY\\LocalService",
8517        );
8518        let review = review_least_privilege(
8519            &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
8520            &plan,
8521        );
8522        // LocalService is *less* privileged, so this is not an excess. It is
8523        // still wrong, because `d2`'s DACL names SY, BA and OW and nothing else:
8524        // the daemon would start and find no credential. Reporting it as a
8525        // shortfall rather than as an excess is the difference between a check
8526        // that understands the requirement and one that counts adjectives.
8527        assert!(review.is_least_privilege(), "{review}");
8528        assert!(
8529            review.findings().iter().any(|finding| {
8530                finding.kind == FindingKind::Shortfall && finding.subject == "Account"
8531            }),
8532            "{review}"
8533        );
8534    }
8535
8536    // -----------------------------------------------------------------------
8537    // The recorded absolute path, and what became of it
8538    // -----------------------------------------------------------------------
8539
8540    #[test]
8541    fn a_recorded_path_that_is_still_there_is_current() {
8542        let host = Host::new();
8543        let state = inspect_binary(&host.binary, Some(&host.binary));
8544        assert!(!state.is_error(), "{state}");
8545        assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
8546    }
8547
8548    #[test]
8549    fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
8550        let host = Host::new();
8551        // An `npm i -g` binary lives under the active Node installation's
8552        // global prefix. Switching Node versions moves the prefix, and the
8553        // recorded path stops existing while the registration survives.
8554        let recorded = host.binary.clone();
8555        let healthy = inspect_binary(&recorded, Some(&recorded));
8556        assert!(
8557            !healthy.is_error(),
8558            "the discriminator: before the binary moves, this must be healthy"
8559        );
8560
8561        std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
8562
8563        let state = inspect_binary(&recorded, Some(&recorded));
8564        assert!(state.is_error(), "{state}");
8565        assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
8566        assert!(
8567            state.to_string().contains("npm"),
8568            "the message must name the cause an operator will not otherwise connect: {state}"
8569        );
8570    }
8571
8572    #[test]
8573    fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
8574        let root = tempfile::tempdir().expect("a temporary directory");
8575        let state = inspect_binary(root.path(), None);
8576        assert!(state.is_error(), "{state}");
8577        assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
8578    }
8579
8580    #[test]
8581    fn a_registration_naming_a_different_binary_is_a_divergence() {
8582        let host = Host::new();
8583        let other = host.binary.with_file_name("something-else");
8584        let state = inspect_binary(&host.binary, Some(&other));
8585        assert!(state.is_error(), "{state}");
8586        assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
8587    }
8588
8589    #[test]
8590    fn absence_is_reported_before_divergence() {
8591        let host = Host::new();
8592        let recorded = host.binary.clone();
8593        std::fs::remove_file(&recorded).expect("removable");
8594        let other = recorded.with_file_name("something-else");
8595        // Both faults are true at once. The operator needs to hear about the
8596        // missing file, not about a disagreement between two paths of which one
8597        // does not exist.
8598        assert!(matches!(
8599            inspect_binary(&recorded, Some(&other)),
8600            BinaryPath::Missing { .. }
8601        ));
8602    }
8603
8604    // -----------------------------------------------------------------------
8605    // The record
8606    // -----------------------------------------------------------------------
8607
8608    #[test]
8609    fn the_record_round_trips_through_toml() {
8610        let host = Host::new();
8611        let plan = InstallPlan::resolve(
8612            ServiceIdentity::product(),
8613            &host.request(StartMode::Boot),
8614            ServiceDirectories::of(&host.paths),
8615        )
8616        .expect("a resolvable plan");
8617        let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
8618        let record = InstallRecord::of(&plan, &definition, Utc::now());
8619        record.write(&host.paths).expect("a writable record");
8620        let read = InstallRecord::read(&host.paths)
8621            .expect("a readable record")
8622            .expect("a record is there");
8623        assert_eq!(read, record);
8624        assert_eq!(read.binary, host.binary);
8625        assert!(read.binary.is_absolute());
8626    }
8627
8628    #[test]
8629    fn a_unit_written_by_an_older_build_is_reported_and_a_current_one_is_not() {
8630        let host = Host::new();
8631        let identity = ServiceIdentity::product();
8632        let plan = InstallPlan::resolve(
8633            identity.clone(),
8634            &host.request(StartMode::Boot),
8635            ServiceDirectories::of(&host.paths),
8636        )
8637        .expect("a resolvable plan");
8638        let installed = systemd_unit(&plan);
8639        let definition =
8640            ServiceDefinition::from_text(DefinitionKind::SystemdUnit, installed.clone());
8641        let mut record = InstallRecord::of(&plan, &definition, Utc::now());
8642        let path = PathBuf::from("/etc/systemd/system/runner-manager.service");
8643
8644        assert_eq!(
8645            systemd_definition_drift(&identity, &record, path.clone(), &installed),
8646            None,
8647            "the record must rebuild exactly the unit `service install` wrote"
8648        );
8649
8650        record.installed_by_version = "0.4.7".into();
8651        let frozen = installed.replace(
8652            "[Service]\n",
8653            "[Service]\nLoadCredential=runner-manager.user-access-token:/var/lib/runner-manager/secrets/user-access-token\n",
8654        );
8655        let drift = systemd_definition_drift(&identity, &record, path, &frozen)
8656            .expect("an outdated unit is drift");
8657        assert_eq!(drift.installed_by_version, "0.4.7");
8658        assert_eq!(drift.rendered, installed);
8659
8660        let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
8661            .with_definition_drift(Some(drift));
8662        assert!(!status.is_healthy());
8663        assert!(
8664            status
8665                .problems()
8666                .iter()
8667                .any(|problem| problem.subject == "definition" && problem.detail.contains("0.4.7")),
8668            "{status}"
8669        );
8670    }
8671
8672    #[test]
8673    fn a_rejected_credential_keeps_its_first_moment_and_makes_the_service_unhealthy() {
8674        let host = Host::new();
8675        assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
8676        let first = Utc::now() - chrono::Duration::hours(9);
8677        record_github_credential_rejected(&host.paths, first).unwrap();
8678        record_github_credential_rejected(&host.paths, Utc::now()).unwrap();
8679        let since = github_credential_rejected_since(&host.paths).unwrap();
8680        assert_eq!(since, Some(first));
8681
8682        let status = ServiceStatus::compose(
8683            ServiceIdentity::product(),
8684            None,
8685            None,
8686            None,
8687            None,
8688            &host.paths,
8689        )
8690        .with_credential_rejection(since);
8691        assert!(!status.is_healthy());
8692        assert_eq!(status.credential_rejected_since(), Some(first));
8693        assert!(status.to_string().contains("auth login"), "{status}");
8694
8695        clear_github_credential_rejection(&host.paths).unwrap();
8696        clear_github_credential_rejection(&host.paths).unwrap();
8697        assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
8698    }
8699
8700    /// A guest that started nothing for 13 days while this said `healthy`.
8701    #[test]
8702    fn blocked_launches_make_the_service_unhealthy_with_their_remedy() {
8703        use crate::launch_health::{
8704            BlockCause, Viewpoint, clear_launches_blocked, launches_blocked,
8705            record_launches_blocked,
8706        };
8707
8708        let host = Host::new();
8709        let compose = || {
8710            ServiceStatus::compose(
8711                ServiceIdentity::product(),
8712                None,
8713                None,
8714                None,
8715                None,
8716                &host.paths,
8717            )
8718            .with_launches_blocked(launches_blocked(&host.paths, Utc::now()))
8719        };
8720        assert!(
8721            !compose()
8722                .problems()
8723                .iter()
8724                .any(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
8725        );
8726        let blocked = BlockCause::FenceHeld {
8727            directory: PathBuf::from("launch-fence"),
8728            owner: None,
8729        }
8730        .blocked_since(Utc::now(), Viewpoint::Guest);
8731        record_launches_blocked(&host.paths, &blocked, Utc::now()).unwrap();
8732
8733        let status = compose();
8734        assert!(!status.is_healthy());
8735        assert_eq!(status.launches_blocked(), Some(&blocked));
8736        let problem = status
8737            .problems()
8738            .iter()
8739            .find(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
8740            .expect("reported under its own subject");
8741        assert!(problem.detail.contains("systemctl restart"), "{status}");
8742
8743        clear_launches_blocked(&host.paths).unwrap();
8744        assert_eq!(compose().launches_blocked(), None);
8745    }
8746
8747    /// The daemon's own "I cannot read my credential", which used to exist
8748    /// only in its log while `service status` said `healthy`.
8749    #[test]
8750    fn an_unreadable_credential_keeps_its_first_moment_and_names_the_exact_command() {
8751        let host = Host::new();
8752        assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
8753        let first = Utc::now() - chrono::Duration::minutes(40);
8754        record_credential_unreadable(&host.paths, first).unwrap();
8755        record_credential_unreadable(&host.paths, Utc::now()).unwrap();
8756        let since = credential_unreadable_since(&host.paths).unwrap();
8757        assert_eq!(since, Some(first), "a restart loop must not move the start");
8758
8759        let status = ServiceStatus::compose(
8760            ServiceIdentity::product(),
8761            None,
8762            None,
8763            None,
8764            None,
8765            &host.paths,
8766        )
8767        .with_credential_unreadable(since);
8768        assert!(!status.is_healthy());
8769        let problem = status
8770            .problems()
8771            .iter()
8772            .find(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT)
8773            .expect("reported under its own subject");
8774        // No record, so the default start mode, whose exact command is named.
8775        assert!(
8776            problem
8777                .detail
8778                .contains(&sign_in_instruction(StartMode::default())),
8779            "{status}"
8780        );
8781
8782        clear_credential_unreadable(&host.paths).unwrap();
8783        clear_credential_unreadable(&host.paths).unwrap();
8784        assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
8785        let healthy = ServiceStatus::compose(
8786            ServiceIdentity::product(),
8787            None,
8788            None,
8789            None,
8790            None,
8791            &host.paths,
8792        )
8793        .with_credential_unreadable(None);
8794        assert!(
8795            !healthy
8796                .problems()
8797                .iter()
8798                .any(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT),
8799            "{healthy}"
8800        );
8801    }
8802
8803    #[test]
8804    fn the_sign_in_instruction_names_the_start_mode_it_writes() {
8805        assert!(sign_in_instruction(StartMode::Login).contains("auth login --start-at login`"));
8806        assert!(sign_in_instruction(StartMode::Boot).contains("auth login --start-at boot`"));
8807        assert_eq!(
8808            sign_in_instruction(StartMode::Boot).starts_with("`sudo "),
8809            cfg!(unix),
8810            "a machine-scoped store needs root on Unix"
8811        );
8812        assert_eq!(
8813            sign_in_instruction(StartMode::Login).contains("SSH"),
8814            cfg!(target_os = "macos"),
8815            "only the macOS login keychain is out of an SSH session's reach"
8816        );
8817    }
8818
8819    /// The record is readable by the account whose directory it is in.
8820    ///
8821    /// A boot-mode `service install` runs under `sudo`, and `NamedTempFile`
8822    /// creates at `0600`, so the record landed `root`-owned and unreadable by
8823    /// the operator — `service status` then failed on their own host with
8824    /// `Permission denied`. Ownership cannot be reproduced in an unprivileged
8825    /// test, but the mode that made ownership fatal can, and it is the half
8826    /// this code controls.
8827    #[cfg(unix)]
8828    #[test]
8829    fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
8830        use std::os::unix::fs::PermissionsExt as _;
8831
8832        let host = Host::new();
8833        let plan = InstallPlan::resolve(
8834            ServiceIdentity::product(),
8835            &host.request(StartMode::Boot),
8836            ServiceDirectories::of(&host.paths),
8837        )
8838        .expect("a resolvable plan");
8839        let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
8840        InstallRecord::of(&plan, &definition, Utc::now())
8841            .write(&host.paths)
8842            .expect("a writable record");
8843
8844        let mode = std::fs::metadata(InstallRecord::path(&host.paths))
8845            .expect("the record is there")
8846            .permissions()
8847            .mode()
8848            & 0o777;
8849        assert_eq!(
8850            mode, 0o644,
8851            "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
8852             service install` wrote, and `service status` fails on their own host. It holds no \
8853             credential and sits in a 0700 directory, so 0644 discloses nothing"
8854        );
8855    }
8856
8857    /// A record written before the service ran a copy of its own must still
8858    /// load. It reads as `None`, which is the truth about it: that registration
8859    /// names the package manager's file and cannot be upgraded under itself.
8860    #[test]
8861    fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
8862        let host = Host::new();
8863        let path = InstallRecord::path(&host.paths);
8864        std::fs::write(
8865            &path,
8866            format!(
8867                "schema_version = {RECORD_SCHEMA_VERSION}
8868service_name = \"runner-manager\"
8869                 manager = \"systemd\"
8870start_mode = \"boot\"
8871account = \"root\"
8872                 binary = \"/x\"
8873arguments = []
8874restart_delay_secs = 15
8875                 restart_reset_secs = 600
8876log_file = \"/x\"
8877                 installed_at = \"2026-01-01T00:00:00Z\"
8878installed_by_version = \"0.1.0\"
8879                 [directories]
8880config = \"/a\"
8881state = \"/b\"
8882runtime = \"/c\"
8883logs = \"/d\"
8884"
8885            ),
8886        )
8887        .expect("a writable record");
8888        let read = InstallRecord::read(&host.paths)
8889            .expect("a record missing an optional field is still readable")
8890            .expect("a record is there");
8891        assert_eq!(
8892            read.source_binary, None,
8893            "the legacy layout has no source, and must not invent one"
8894        );
8895    }
8896
8897    /// The field that makes an upgrade possible survives the write.
8898    #[test]
8899    fn a_registration_remembers_the_file_it_was_copied_from() {
8900        let host = Host::new();
8901        let source = host.binary.with_file_name("npm-installed-runner-manager");
8902        std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
8903        let plan = InstallPlan::resolve(
8904            ServiceIdentity::product(),
8905            &host.request(StartMode::Boot).copied_from(&source),
8906            ServiceDirectories::of(&host.paths),
8907        )
8908        .expect("a resolvable plan");
8909        let definition = ServiceDefinition::from_text(
8910            DefinitionKind::SystemdUnit,
8911            "[Service]
8912",
8913        );
8914        let record = InstallRecord::of(&plan, &definition, Utc::now());
8915        record.write(&host.paths).expect("a writable record");
8916
8917        let read = InstallRecord::read(&host.paths)
8918            .expect("a readable record")
8919            .expect("a record is there");
8920        assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
8921        assert_ne!(
8922            read.source_binary.as_deref(),
8923            Some(read.binary.as_path()),
8924            "the whole point is that the two are different files: one the service holds open,              one the package manager is free to replace"
8925        );
8926    }
8927
8928    #[test]
8929    fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
8930        let host = Host::new();
8931        let path = InstallRecord::path(&host.paths);
8932        std::fs::write(
8933            &path,
8934            format!(
8935                "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
8936                 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
8937                 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
8938                 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
8939                 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
8940                RECORD_SCHEMA_VERSION + 1
8941            ),
8942        )
8943        .expect("a writable record");
8944        let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
8945        assert!(
8946            matches!(error, ServiceError::RecordUnreadable { .. }),
8947            "{error}"
8948        );
8949        assert!(
8950            error.to_string().contains("service uninstall"),
8951            "the message must say how to recover: {error}"
8952        );
8953    }
8954
8955    #[test]
8956    fn no_record_is_not_an_error() {
8957        let host = Host::new();
8958        assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
8959        assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
8960    }
8961
8962    // -----------------------------------------------------------------------
8963    // The last successful GitHub contact
8964    // -----------------------------------------------------------------------
8965
8966    #[test]
8967    fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
8968        let host = Host::new();
8969        assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
8970    }
8971
8972    #[test]
8973    fn the_heartbeat_round_trips_to_the_second() {
8974        let host = Host::new();
8975        let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
8976            .expect("a valid timestamp")
8977            .with_timezone(&Utc);
8978        record_github_contact(&host.paths, at).expect("a writable heartbeat");
8979        assert_eq!(
8980            last_github_contact(&host.paths).expect("readable"),
8981            Some(at)
8982        );
8983    }
8984
8985    #[test]
8986    fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
8987        let host = Host::new();
8988        std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
8989        let error = last_github_contact(&host.paths)
8990            .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
8991        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
8992    }
8993
8994    /// The surface the three-hour outage did not have.
8995    ///
8996    /// A root that refuses a launch does so before any attempt row exists, and
8997    /// the daemon's log scrubs the paths out of the sentence, so this file is
8998    /// the only place the directory and the remediation can reach the operator.
8999    #[test]
9000    fn a_runner_root_refusal_round_trips_for_service_status() {
9001        let host = Host::new();
9002        let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
9003
9004        assert!(
9005            runner_root_refusals(&host.paths)
9006                .expect("readable")
9007                .is_empty(),
9008            "no record means every policy is placing runners"
9009        );
9010
9011        record_runner_root_refusal(
9012            &host.paths,
9013            "policy-a",
9014            at,
9015            "denied_by_privacy_policy",
9016            "/Volumes/NVME/runners",
9017            "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
9018        )
9019        .expect("a writable record");
9020
9021        let refusals = runner_root_refusals(&host.paths).expect("readable");
9022        assert_eq!(refusals.len(), 1);
9023        assert_eq!(refusals[0].policy, "policy-a");
9024        assert_eq!(refusals[0].at, at);
9025        assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
9026        assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
9027        assert!(
9028            refusals[0].detail.contains("/Volumes/NVME/runners")
9029                && refusals[0].detail.contains("Full Disk Access"),
9030            "the path and the remediation are the whole point of this file: {refusals:?}"
9031        );
9032    }
9033
9034    /// The regression that would put the outage straight back.
9035    ///
9036    /// A host runs several policies and they do not share a fate. When the
9037    /// record was one host-wide slot, the policy that placed a runner deleted
9038    /// the record of the policy that could not — on the same reconcile pass —
9039    /// and `service status` reported a healthy host that had started zero
9040    /// runners for an entire target.
9041    #[test]
9042    fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
9043        let host = Host::new();
9044        let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
9045        record_runner_root_refusal(
9046            &host.paths,
9047            "broken",
9048            at,
9049            "denied_by_privacy_policy",
9050            "/Volumes/NVME/runners",
9051            "detail",
9052        )
9053        .expect("a writable record");
9054        record_runner_root_refusal(
9055            &host.paths,
9056            "also-broken",
9057            at,
9058            "not_writable",
9059            "/srv/other",
9060            "detail",
9061        )
9062        .expect("a writable record");
9063
9064        // The policy with its own working root succeeds and clears only itself.
9065        clear_runner_root_refusal(&host.paths, "healthy")
9066            .expect("clearing an absent policy is fine");
9067        clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
9068
9069        let refusals = runner_root_refusals(&host.paths).expect("readable");
9070        assert_eq!(
9071            refusals
9072                .iter()
9073                .map(|r| r.policy.as_str())
9074                .collect::<Vec<_>>(),
9075            vec!["broken"],
9076            "the policy that is still refused must keep its record"
9077        );
9078    }
9079
9080    /// Clearing is what keeps a fixed host from reporting a stale fault, and the
9081    /// file goes when the last entry does.
9082    #[test]
9083    fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
9084        let host = Host::new();
9085        record_runner_root_refusal(
9086            &host.paths,
9087            "p",
9088            Utc::now(),
9089            "not_writable",
9090            "/srv/x",
9091            "detail",
9092        )
9093        .expect("a writable record");
9094
9095        clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
9096        assert!(
9097            !root_refusal_path(&host.paths).exists(),
9098            "a host with nothing refused leaves nothing behind"
9099        );
9100        clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
9101    }
9102
9103    /// Same rule as the heartbeat: absence means "every policy is placing
9104    /// runners", so a record that cannot be parsed must not be reported as one.
9105    #[test]
9106    fn a_malformed_refusal_is_an_error_and_not_silently_none() {
9107        let host = Host::new();
9108        std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
9109        let error = runner_root_refusals(&host.paths)
9110            .expect_err("an unparseable record is not the same as no record");
9111        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
9112    }
9113
9114    /// The half of this feature that turns the file into a diagnosis, and the
9115    /// half that had no test: `service status` must actually read it.
9116    ///
9117    /// A **note** and not a problem, deliberately. `service status` runs as
9118    /// whoever typed it, must stay readable on a host with nothing installed,
9119    /// and drives an exit code -- and nothing an operator can type clears this
9120    /// record, so a problem would fail that exit code on a host whose root was
9121    /// fixed but which has had no queued job since, with a printed remedy
9122    /// (`service uninstall && service install`) that does not touch `state/`.
9123    #[test]
9124    fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
9125        let host = Host::new();
9126        record_runner_root_refusal(
9127            &host.paths,
9128            "policy-a",
9129            DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
9130            "denied_by_privacy_policy",
9131            "/Volumes/NVME/runners",
9132            "Grant Full Disk Access to the program that runs the service",
9133        )
9134        .expect("a writable record");
9135
9136        let status = host.operations().status().expect("a readable status");
9137        let notes = status.notes().join("\n");
9138
9139        assert!(
9140            notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
9141            "the directory and the remediation the log had to scrub must appear here: {notes}"
9142        );
9143        assert!(
9144            notes.contains("policy-a"),
9145            "the operator has to know which target placed no runner: {notes}"
9146        );
9147        assert!(
9148            !status
9149                .problems()
9150                .iter()
9151                .any(|problem| problem.subject == "runner root"),
9152            "a record nothing an operator types can clear must not drive the exit code"
9153        );
9154    }
9155
9156    // -----------------------------------------------------------------------
9157    // -----------------------------------------------------------------------
9158    // Install
9159    // -----------------------------------------------------------------------
9160
9161    #[test]
9162    fn install_records_the_absolute_binary_path_and_the_four_directories() {
9163        let host = Host::new();
9164        let installed = host
9165            .operations()
9166            .install(&host.request(StartMode::Boot))
9167            .expect("an install against the recording controls");
9168
9169        assert_eq!(installed.record.binary, host.binary);
9170        assert!(installed.record.binary.is_absolute());
9171        assert_eq!(installed.record.start_mode, StartMode::Boot);
9172        assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
9173        assert_eq!(
9174            installed.record.directories,
9175            ServiceDirectories::of(&host.paths)
9176        );
9177        assert_eq!(
9178            installed.record.log_file,
9179            host.paths.logs_dir().join(LOG_FILE_STEM)
9180        );
9181        assert_eq!(installed.record.restart_delay_secs, 15);
9182
9183        let registrations = host.controls.registrations();
9184        assert_eq!(registrations.len(), 1);
9185        assert_eq!(registrations[0].0, StartMode::Boot);
9186        assert_eq!(registrations[0].1, "runner-manager");
9187    }
9188
9189    #[test]
9190    fn install_is_refused_while_the_single_instance_lock_is_held() {
9191        let host = Host::new();
9192        // The discriminator: with the lock free, the same call succeeds.
9193        {
9194            let operations = host.operations();
9195            operations
9196                .install(&host.request(StartMode::Boot))
9197                .expect("an install with the lock free");
9198            operations.uninstall().expect("a clean slate");
9199        }
9200
9201        let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
9202            .expect("this process takes the lock first");
9203
9204        let error = host
9205            .operations()
9206            .install(&host.request(StartMode::Boot))
9207            .expect_err("a second agent must not be registered while one is running");
9208        assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
9209        assert!(
9210            error.to_string().contains("already running"),
9211            "the message must be actionable: {error}"
9212        );
9213        assert!(
9214            host.controls.registrations().is_empty(),
9215            "a refused install must register nothing"
9216        );
9217        assert_eq!(
9218            InstallRecord::read(&host.paths).expect("readable"),
9219            None,
9220            "a refused install must write no record"
9221        );
9222    }
9223
9224    /// Installing over the same start mode replaces the registration.
9225    ///
9226    /// It used to be refused, and the refusal is what broke a real host: the
9227    /// caller has already swapped the binary the service runs by the time this
9228    /// is reached — that is the documented way to upgrade by hand — so refusing
9229    /// left a daemon running an unregistered binary and, on macOS, one the
9230    /// stored credential's keychain grant did not name.
9231    #[test]
9232    fn installing_over_the_same_start_mode_replaces_the_registration() {
9233        let host = Host::new();
9234        let operations = host.operations();
9235        operations
9236            .install(&host.request(StartMode::Boot))
9237            .expect("the first install");
9238
9239        let again = operations
9240            .install(&host.request(StartMode::Boot))
9241            .expect("an install over the same mode replaces rather than refusing");
9242
9243        assert!(
9244            again.replaced_existing,
9245            "the operator is told this replaced something rather than made it"
9246        );
9247        assert_eq!(
9248            host.controls.registrations().len(),
9249            1,
9250            "replacing must not leave two registrations behind"
9251        );
9252        assert_eq!(
9253            InstallRecord::read(&host.paths)
9254                .expect("readable")
9255                .expect("a record")
9256                .start_mode,
9257            StartMode::Boot
9258        );
9259    }
9260
9261    /// The *other* mode is still refused, because changing it moves the
9262    /// registration between two service managers and changes the account and
9263    /// the secret store with it. `set_start_mode` is what does that.
9264    #[test]
9265    fn installing_over_the_other_start_mode_is_refused() {
9266        let host = Host::new();
9267        let operations = host.operations();
9268        operations
9269            .install(&host.request(StartMode::Boot))
9270            .expect("the first install");
9271
9272        let error = operations
9273            .install(&host.request(StartMode::Login))
9274            .expect_err("a mode change is not an install");
9275        assert!(
9276            matches!(
9277                error,
9278                ServiceError::AlreadyInstalled {
9279                    existing: StartMode::Boot,
9280                    requested: StartMode::Login,
9281                    ..
9282                }
9283            ),
9284            "{error}"
9285        );
9286        assert!(
9287            !error
9288                .to_string()
9289                .contains("switch the start mode in place,"),
9290            "the old remedy named a capability no command offers; the terminal UI is where \
9291             the start mode moves: {error}"
9292        );
9293        assert_eq!(host.controls.registrations().len(), 1);
9294    }
9295
9296    #[test]
9297    fn install_rolls_back_the_registration_when_record_persistence_fails() {
9298        let host = Host::new();
9299        let record_path = InstallRecord::path(&host.paths);
9300        std::fs::create_dir(&record_path).expect("a directory blocks the record file");
9301
9302        let error = host
9303            .operations()
9304            .install(&host.request(StartMode::Boot))
9305            .expect_err("record persistence must fail");
9306
9307        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
9308        assert!(
9309            host.controls.registrations().is_empty(),
9310            "a failed install must not leave a live unrecorded registration"
9311        );
9312        assert!(
9313            host.controls
9314                .calls()
9315                .iter()
9316                .any(|call| call == "uninstall runner-manager (boot)"),
9317            "the registration must be explicitly rolled back: {:?}",
9318            host.controls.calls()
9319        );
9320        assert!(
9321            !host.runner_root.as_path().exists(),
9322            "the rollback must take the runner root this install created with it; a directory \
9323             prepared for a registration that does not exist is litter, and on Windows it is \
9324             litter with a security descriptor"
9325        );
9326    }
9327
9328    // -----------------------------------------------------------------------
9329    // The runner root (b2)
9330    // -----------------------------------------------------------------------
9331
9332    #[test]
9333    fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
9334        use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
9335
9336        // The mapping `prepare_runner_root` reads, asserted against the same
9337        // function the registration's own principal comes from — which is the
9338        // point of deriving it there rather than restating it.
9339        assert_eq!(
9340            ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
9341            ServiceAccount::LocalSystem
9342        );
9343        assert_eq!(
9344            ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
9345            ServiceAccount::InvokingUser
9346        );
9347
9348        let boot = default_root_sddl(&RootAdmission::LocalSystem);
9349        assert!(!grants_broad_write(&boot), "{boot}");
9350        assert!(
9351            !boot.contains("S-1-5-21"),
9352            "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
9353        );
9354
9355        // A login task runs under a *filtered* token — `RunLevel` is
9356        // `LeastPrivilege`, see `windows_scheduled_task_xml` — in which
9357        // Administrators is deny-only. Without an ace of its own the account
9358        // the task runs as would be admitted by nothing at all.
9359        let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
9360        assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
9361        assert!(!grants_broad_write(&login), "{login}");
9362    }
9363
9364    #[test]
9365    fn an_install_reports_the_runner_root_it_prepared() {
9366        let host = Host::new();
9367        let installed = host
9368            .operations()
9369            .install(&host.request(StartMode::Boot))
9370            .expect("an install");
9371        let rendered = installed.runner_root.to_string();
9372        assert!(
9373            !rendered.contains("S-1-5-21"),
9374            "the report must add no identity to the output: {rendered}"
9375        );
9376        if cfg!(windows) {
9377            assert_eq!(
9378                installed.runner_root.path(),
9379                Some(host.runner_root.as_path())
9380            );
9381            assert!(
9382                host.runner_root.as_path().is_dir(),
9383                "the directory jobs would run in has to exist once the service is registered"
9384            );
9385        } else {
9386            assert_eq!(
9387                installed.runner_root,
9388                crate::runner_root_access::RootAccessSummary::NotApplicable,
9389                "macOS and Linux keep the runtime directory they have always used"
9390            );
9391        }
9392    }
9393
9394    #[test]
9395    fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
9396        let host = Host::new();
9397        let operations = host.operations();
9398        operations
9399            .install(&host.request(StartMode::Boot))
9400            .expect("an install at boot");
9401
9402        let change = operations
9403            .set_start_mode(StartMode::Login)
9404            .expect("a switch to login");
9405
9406        assert!(change.changed);
9407        if cfg!(windows) {
9408            assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
9409            assert!(
9410                host.runner_root.as_path().is_dir(),
9411                "the switch must not remove the directory it reconciled"
9412            );
9413        }
9414        // The operator is told, because the account that may write there has
9415        // moved with the mode.
9416        assert!(
9417            change.to_string().contains("runner root"),
9418            "{}",
9419            change.to_string()
9420        );
9421    }
9422
9423    #[test]
9424    fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
9425        let host = Host::new();
9426        let operations = host.operations();
9427        operations
9428            .install(&host.request(StartMode::Boot))
9429            .expect("an install at boot");
9430
9431        let change = operations
9432            .set_start_mode(StartMode::Boot)
9433            .expect("a switch to the mode already in force");
9434
9435        assert!(!change.changed);
9436        assert_eq!(
9437            change.runner_root,
9438            crate::runner_root_access::RootAccessSummary::NotApplicable,
9439            "nothing moves, so nothing about the root's access control has to; reconciling here \
9440             would turn a no-op command into one that can fail on a permission it does not need"
9441        );
9442    }
9443
9444    #[cfg(windows)]
9445    #[test]
9446    fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
9447        let host = Host::new();
9448        host.controls
9449            .fail_next_install(StartMode::Boot, "injected registration failure");
9450
9451        let error = host
9452            .operations()
9453            .install(&host.request(StartMode::Boot))
9454            .expect_err("the manager refuses the registration");
9455
9456        assert!(matches!(error, ServiceError::Control { .. }), "{error}");
9457        assert!(
9458            !host.runner_root.as_path().exists(),
9459            "the directory was created for a registration that does not exist"
9460        );
9461    }
9462
9463    #[cfg(windows)]
9464    #[test]
9465    fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
9466        let host = Host::new();
9467        // What a directory created below `C:\` with inheritance left on looks
9468        // like, built deliberately because a per-account `%TEMP%` never
9469        // produces one by accident.
9470        crate::runner_root_access::create_with_descriptor_for_tests(
9471            host.runner_root.as_path(),
9472            "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
9473        )
9474        .expect("a deliberately open runner root");
9475        let before = crate::runner_root_access::report(host.runner_root.as_path());
9476
9477        let error = host
9478            .operations()
9479            .install(&host.request(StartMode::Boot))
9480            .expect_err("an open runner root is refused");
9481
9482        assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
9483        assert!(
9484            error.to_string().contains("nothing was registered"),
9485            "{error}"
9486        );
9487        assert!(
9488            host.controls.registrations().is_empty(),
9489            "the refusal has to come before the platform is asked to register anything: {:?}",
9490            host.controls.calls()
9491        );
9492        assert_eq!(
9493            crate::runner_root_access::report(host.runner_root.as_path()),
9494            before,
9495            "an open directory is refused rather than tightened: its contents cannot be trusted, \
9496             so adopting it would be worse than declining it"
9497        );
9498    }
9499
9500    #[cfg(windows)]
9501    #[test]
9502    fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
9503        let host = Host::new();
9504        let operations = host.operations();
9505        operations
9506            .install(&host.request(StartMode::Boot))
9507            .expect("an install");
9508        assert!(host.runner_root.as_path().is_dir());
9509
9510        operations.uninstall().expect("an uninstall");
9511
9512        assert!(
9513            host.runner_root.as_path().is_dir(),
9514            "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
9515             runner root may hold an operator's retained workspaces."
9516        );
9517    }
9518
9519    #[test]
9520    fn install_reviews_what_it_registered() {
9521        let host = Host::new();
9522        let installed = host
9523            .operations()
9524            .install(&host.request(StartMode::Boot))
9525            .expect("an install");
9526        assert!(
9527            installed.review.is_least_privilege(),
9528            "{}",
9529            installed.review
9530        );
9531        assert!(
9532            !installed.review.controls().is_empty(),
9533            "a review that confirms nothing proves nothing: {}",
9534            installed.review
9535        );
9536        assert_eq!(
9537            installed.review.kind(),
9538            host_definition_kind(StartMode::Boot),
9539            "the review must be of the definition this host's manager was given"
9540        );
9541        assert!(
9542            !installed.review.account().justification().is_empty(),
9543            "a privileged account with no stated reason is an unreviewed one"
9544        );
9545    }
9546
9547    // -----------------------------------------------------------------------
9548    // Uninstall deletes nothing else
9549    // -----------------------------------------------------------------------
9550
9551    #[test]
9552    fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
9553        let host = Host::new();
9554        let operations = host.operations();
9555        operations
9556            .install(&host.request(StartMode::Boot))
9557            .expect("an install");
9558
9559        // The files `05-infrastructure.md` says must survive. Written *after*
9560        // the install so that the snapshot below is of a host in the state an
9561        // operator's would be in.
9562        let config = host.paths.config_dir();
9563        std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
9564        std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
9565        std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
9566        std::fs::write(
9567            host.paths
9568                .state_dir()
9569                .join("packages/2.330.0/runner.tar.gz"),
9570            b"cached package",
9571        )
9572        .expect("writable");
9573        std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
9574        std::fs::write(
9575            host.paths.state_dir().join("secrets/user-access-token"),
9576            b"a stand-in for the stored credential",
9577        )
9578        .expect("writable");
9579        std::fs::write(
9580            host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
9581            b"diagnostics",
9582        )
9583        .expect("writable");
9584
9585        let roots: Vec<PathBuf> = host
9586            .paths
9587            .all()
9588            .iter()
9589            .map(|(_, path)| (*path).to_path_buf())
9590            .collect();
9591        let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
9592        let before = snapshot(&roots);
9593
9594        // Non-vacuity: a comparison of two empty maps would pass whatever
9595        // `uninstall` did.
9596        assert!(
9597            before.len() >= 6,
9598            "the fixture must actually contain the files this test is about, got {before:#?}"
9599        );
9600        let record_path = InstallRecord::path(&host.paths);
9601        assert!(
9602            before.contains_key(&record_path),
9603            "the install record must be present before uninstall"
9604        );
9605
9606        let uninstalled = operations.uninstall().expect("an uninstall");
9607        assert!(uninstalled.removed_registration);
9608        assert!(uninstalled.removed_record);
9609
9610        let after = snapshot(&roots);
9611
9612        // Exactly one thing changed, and it is the registration's own record.
9613        let mut expected = before.clone();
9614        expected.remove(&record_path);
9615        assert_eq!(
9616            after, expected,
9617            "uninstall must remove its own record and nothing else"
9618        );
9619        assert!(
9620            !record_path.exists(),
9621            "the record itself must go, or `uninstall` did nothing at all"
9622        );
9623        assert!(
9624            uninstalled
9625                .preserved
9626                .iter()
9627                .all(|path| roots.contains(&path.as_path())),
9628            "the preserved list must name the four directories: {uninstalled}"
9629        );
9630    }
9631
9632    #[test]
9633    fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
9634        let host = Host::new();
9635        let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
9636        assert!(!uninstalled.removed_registration);
9637        assert!(!uninstalled.removed_record);
9638    }
9639
9640    #[test]
9641    fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
9642        let host = Host::new();
9643        let operations = host.operations();
9644        operations
9645            .install(&host.request(StartMode::Boot))
9646            .expect("an install");
9647        std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
9648
9649        let uninstalled = operations.uninstall().expect("an uninstall");
9650        assert!(
9651            uninstalled.removed_registration,
9652            "a lost record must not strand a registration"
9653        );
9654        assert!(host.controls.registrations().is_empty());
9655    }
9656
9657    // -----------------------------------------------------------------------
9658    // Switching start mode
9659    // -----------------------------------------------------------------------
9660
9661    #[test]
9662    fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
9663        let host = Host::new();
9664        let operations = host.operations();
9665        operations
9666            .install(&host.request(StartMode::Boot))
9667            .expect("an install at boot");
9668
9669        // The discriminator. If the switch re-resolved the binary the way
9670        // `install` does, it would either fail here or silently record the test
9671        // binary instead. Removing the file makes the difference visible.
9672        std::fs::remove_file(&host.binary).expect("the installed binary goes away");
9673
9674        let change = operations
9675            .set_start_mode(StartMode::Login)
9676            .expect("a switch that does not reinstall the product");
9677        assert!(change.changed);
9678        assert_eq!(change.from, StartMode::Boot);
9679        assert_eq!(change.to, StartMode::Login);
9680        assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
9681
9682        let record = InstallRecord::read(&host.paths)
9683            .expect("readable")
9684            .expect("a record");
9685        assert_eq!(record.start_mode, StartMode::Login);
9686        assert_eq!(
9687            record.binary, host.binary,
9688            "the recorded path must survive the switch untouched"
9689        );
9690
9691        let registrations = host.controls.registrations();
9692        assert_eq!(registrations.len(), 1, "{registrations:?}");
9693        assert_eq!(registrations[0].0, StartMode::Login);
9694        assert!(
9695            registrations[0]
9696                .2
9697                .command_line
9698                .contains(&host.binary.to_string_lossy().into_owned()),
9699            "{:?}",
9700            registrations[0].2
9701        );
9702    }
9703
9704    #[test]
9705    fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
9706        let host = Host::new();
9707        let operations = host.operations();
9708        operations
9709            .install(&host.request(StartMode::Boot))
9710            .expect("an install at boot");
9711        let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
9712        host.controls
9713            .fail_next_install(StartMode::Login, "injected target failure");
9714
9715        let error = operations
9716            .set_start_mode(StartMode::Login)
9717            .expect_err("the target manager refuses the install");
9718
9719        assert!(matches!(error, ServiceError::Control { .. }), "{error}");
9720        assert_eq!(
9721            std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
9722            record_before
9723        );
9724        let registrations = host.controls.registrations();
9725        assert_eq!(registrations.len(), 1, "{registrations:?}");
9726        assert_eq!(registrations[0].0, StartMode::Boot);
9727    }
9728
9729    #[test]
9730    fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
9731        let host = Host::new();
9732        let operations = host.operations();
9733        operations
9734            .install(&host.request(StartMode::Boot))
9735            .expect("an install at boot");
9736        let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
9737        let config = host.paths.config_dir().to_path_buf();
9738        let hidden = config.with_file_name("config-hidden-by-fault");
9739        host.controls.hide_directory_after_install(
9740            StartMode::Login,
9741            config.clone(),
9742            hidden.clone(),
9743        );
9744
9745        let error = operations
9746            .set_start_mode(StartMode::Login)
9747            .expect_err("the injected filesystem fault prevents persistence");
9748
9749        std::fs::remove_file(&config).expect("remove the injected blocker");
9750        std::fs::rename(&hidden, &config).expect("restore the record directory");
9751        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
9752        assert_eq!(
9753            std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
9754            record_before
9755        );
9756        let registrations = host.controls.registrations();
9757        assert_eq!(registrations.len(), 1, "{registrations:?}");
9758        assert_eq!(registrations[0].0, StartMode::Boot);
9759        assert!(
9760            host.controls
9761                .calls()
9762                .iter()
9763                .any(|call| call == "uninstall runner-manager (login)"),
9764            "the target must be rolled back: {:?}",
9765            host.controls.calls()
9766        );
9767    }
9768
9769    #[test]
9770    fn switching_to_the_mode_already_in_force_registers_nothing_again() {
9771        let host = Host::new();
9772        let operations = host.operations();
9773        operations
9774            .install(&host.request(StartMode::Boot))
9775            .expect("an install");
9776        let before = host.controls.calls().len();
9777
9778        let change = operations
9779            .set_start_mode(StartMode::Boot)
9780            .expect("a no-op switch");
9781        assert!(!change.changed);
9782        assert_eq!(
9783            host.controls.calls().len(),
9784            before,
9785            "a no-op switch must not touch the service manager"
9786        );
9787    }
9788
9789    #[test]
9790    fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
9791        let host = Host::new();
9792        let error = host
9793            .operations()
9794            .set_start_mode(StartMode::Login)
9795            .expect_err("there is nothing to switch");
9796        assert!(
9797            matches!(error, ServiceError::NotInstalled { .. }),
9798            "{error}"
9799        );
9800    }
9801
9802    // -----------------------------------------------------------------------
9803    // Status
9804    // -----------------------------------------------------------------------
9805
9806    #[test]
9807    fn status_reports_the_four_facts_journey_five_asks_for() {
9808        let host = Host::new();
9809        let operations = host.operations();
9810        operations
9811            .install(&host.request(StartMode::Boot))
9812            .expect("an install");
9813        let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
9814            .expect("a valid timestamp")
9815            .with_timezone(&Utc);
9816        record_github_contact(&host.paths, at).expect("a heartbeat");
9817
9818        let status = operations.status().expect("a status");
9819        assert_eq!(status.start_mode(), Some(StartMode::Boot));
9820        assert_eq!(
9821            status.binary().map(BinaryPath::recorded),
9822            Some(host.binary.as_path())
9823        );
9824        assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
9825        assert_eq!(status.last_github_contact(), Some(at));
9826        assert!(status.is_installed());
9827        assert!(status.is_healthy(), "{status}");
9828
9829        let printed = status.to_string();
9830        for fragment in [
9831            "start mode",
9832            "diagnostic log",
9833            "last GitHub contact",
9834            "binary",
9835        ] {
9836            assert!(printed.contains(fragment), "{printed}");
9837        }
9838    }
9839
9840    /// A record this account may not read is reported, and `status` keeps
9841    /// going.
9842    ///
9843    /// It used to end the command with `Permission denied`, which is exactly
9844    /// what an operator met on a boot-mode host: `sudo service install` wrote
9845    /// the record `0600` and `root`-owned, and `service status` — a command
9846    /// that asks no privilege of anybody — then printed nothing at all about a
9847    /// registration the service manager could describe perfectly well.
9848    ///
9849    /// Ownership cannot be reproduced without privileges; the mode that made it
9850    /// fatal can.
9851    #[cfg(unix)]
9852    #[test]
9853    fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
9854        use std::os::unix::fs::PermissionsExt as _;
9855
9856        // `root` is not subject to the mode bits and would read the record
9857        // regardless, leaving the assertions below testing nothing.
9858        // SAFETY: `geteuid` takes no argument and cannot fail.
9859        if unsafe { libc::geteuid() } == 0 {
9860            return;
9861        }
9862
9863        let host = Host::new();
9864        let operations = host.operations();
9865        operations
9866            .install(&host.request(StartMode::Boot))
9867            .expect("an install");
9868        assert!(
9869            operations.status().expect("a status").is_healthy(),
9870            "the discriminator: healthy before the record is made unreadable"
9871        );
9872
9873        let record = InstallRecord::path(&host.paths);
9874        std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
9875            .expect("the mode is applied");
9876
9877        let status = operations
9878            .status()
9879            .expect("a record this account may not read is reported, not thrown");
9880        assert!(status.is_installed(), "{status}");
9881        assert!(!status.is_healthy(), "{status}");
9882
9883        let printed = status.to_string();
9884        assert!(
9885            printed.contains("this account may not read it"),
9886            "the operator is told which of the two states this is: {printed}"
9887        );
9888        assert!(
9889            !printed.contains("there is no install record"),
9890            "a record that is there and unreadable is not a record that is missing, and the \
9891             missing one's remedy starts with `service uninstall`: {printed}"
9892        );
9893
9894        // Left readable so the fixture's own cleanup is not the thing that
9895        // fails on a host where TempDir removal walks the tree.
9896        std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
9897            .expect("the mode is restored");
9898    }
9899
9900    #[test]
9901    fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
9902        let host = Host::new();
9903        let operations = host.operations();
9904        operations
9905            .install(&host.request(StartMode::Boot))
9906            .expect("an install");
9907
9908        // The discriminator: healthy first, so a `is_healthy` that always
9909        // returned false could not pass this test.
9910        assert!(
9911            operations.status().expect("a status").is_healthy(),
9912            "the freshly installed host must be healthy"
9913        );
9914
9915        std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
9916
9917        let status = operations.status().expect("a status");
9918        assert!(!status.is_healthy(), "{status}");
9919        assert!(
9920            status
9921                .problems()
9922                .iter()
9923                .any(|problem| problem.subject == "binary"),
9924            "{status}"
9925        );
9926        assert!(status.to_string().contains("STALE"), "{status}");
9927    }
9928
9929    #[test]
9930    fn status_reports_a_registration_that_would_not_start_at_boot() {
9931        let host = Host::new();
9932        let operations = host.operations();
9933        operations
9934            .install(&host.request(StartMode::Boot))
9935            .expect("an install");
9936        assert!(operations.status().expect("a status").is_healthy());
9937
9938        host.controls.edit("runner-manager", |registration| {
9939            registration.starts_automatically = false;
9940        });
9941
9942        let status = operations.status().expect("a status");
9943        assert!(!status.is_healthy(), "{status}");
9944        assert!(
9945            status
9946                .problems()
9947                .iter()
9948                .any(|problem| problem.detail.contains("after a reboot")),
9949            "{status}"
9950        );
9951    }
9952
9953    #[test]
9954    fn install_starts_the_registration_and_a_later_stop_is_unhealthy() {
9955        let host = Host::new();
9956        let operations = host.operations();
9957        operations
9958            .install(&host.request(StartMode::Login))
9959            .expect("install and immediate start");
9960
9961        assert!(
9962            operations.status().expect("running status").is_running(),
9963            "install must not wait for the next login trigger"
9964        );
9965        operations.stop().expect("stop the registration");
9966        let status = operations.status().expect("stopped status");
9967        assert!(!status.is_healthy(), "{status}");
9968        assert!(
9969            status
9970                .problems()
9971                .iter()
9972                .any(|problem| problem.subject == "runtime"),
9973            "{status}"
9974        );
9975        let rendered = status.to_string();
9976        assert!(
9977            rendered.contains("runner-manager service install --start-at login"),
9978            "{rendered}"
9979        );
9980        assert!(
9981            !rendered.contains("runner-manager service start"),
9982            "{rendered}"
9983        );
9984    }
9985
9986    #[test]
9987    fn status_reports_a_restart_policy_something_else_edited() {
9988        let host = Host::new();
9989        let operations = host.operations();
9990        operations
9991            .install(&host.request(StartMode::Boot))
9992            .expect("an install");
9993        assert!(operations.status().expect("a status").is_healthy());
9994
9995        host.controls.edit("runner-manager", |registration| {
9996            registration.restart_delay = Some(Duration::from_secs(1));
9997        });
9998
9999        let status = operations.status().expect("a status");
10000        assert!(!status.is_healthy(), "{status}");
10001        assert!(
10002            status
10003                .problems()
10004                .iter()
10005                .any(|problem| problem.subject == "restart policy"),
10006            "{status}"
10007        );
10008    }
10009
10010    #[test]
10011    fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
10012        let host = Host::new();
10013        let operations = host.operations();
10014        operations
10015            .install(&host.request(StartMode::Boot))
10016            .expect("an install");
10017        let other = host.binary.with_file_name("someone-elses.exe");
10018        std::fs::write(&other, b"x").expect("writable");
10019
10020        host.controls.edit("runner-manager", |registration| {
10021            registration.command_line = quote_argument(&other.to_string_lossy());
10022        });
10023
10024        let status = operations.status().expect("a status");
10025        assert!(!status.is_healthy(), "{status}");
10026        assert!(
10027            matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
10028            "{status}"
10029        );
10030    }
10031
10032    #[test]
10033    fn status_reports_a_record_no_service_manager_knows_about() {
10034        let host = Host::new();
10035        let operations = host.operations();
10036        operations
10037            .install(&host.request(StartMode::Boot))
10038            .expect("an install");
10039        // Something removed the registration behind this product's back.
10040        for mode in [StartMode::Boot, StartMode::Login] {
10041            host.controls
10042                .control(mode)
10043                .expect("a control")
10044                .uninstall(&ServiceIdentity::product())
10045                .expect("removed");
10046        }
10047
10048        let status = operations.status().expect("a status");
10049        assert!(!status.is_healthy(), "{status}");
10050        assert!(
10051            status
10052                .problems()
10053                .iter()
10054                .any(|problem| problem.subject == "registration"),
10055            "{status}"
10056        );
10057    }
10058
10059    #[test]
10060    fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
10061        let host = Host::new();
10062        let status = host.operations().status().expect("a status");
10063        assert!(!status.is_installed());
10064        assert!(
10065            status.is_healthy(),
10066            "a host that never installed the service has no fault to report: {status}"
10067        );
10068        assert!(status.to_string().contains("installed"), "{status}");
10069    }
10070
10071    #[test]
10072    fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
10073        let host = Host::new();
10074        let operations = host.operations();
10075        operations
10076            .install(&host.request(StartMode::Login))
10077            .expect("an install at login");
10078        let status = operations.status().expect("a status");
10079        assert!(
10080            status
10081                .notes()
10082                .iter()
10083                .any(|note| note.contains("does not run until the operator signs in")),
10084            "05-infrastructure.md requires `service status` to say so: {status}"
10085        );
10086    }
10087
10088    /// A Mac that signs its runner account in automatically does resume, and
10089    /// `service status` used to tell it otherwise.
10090    #[test]
10091    fn a_mac_with_automatic_login_as_the_service_account_resumes_after_a_reboot() {
10092        use crate::unattended_login::{AutoLogin, UnattendedLogin};
10093        let automatic = UnattendedLogin {
10094            auto_login: AutoLogin::As("ivan".into()),
10095            filevault_on: Some(false),
10096        };
10097        assert_eq!(
10098            unattended_gap(StartMode::Login, "ivan", Some(&automatic)),
10099            None
10100        );
10101
10102        let off = UnattendedLogin {
10103            auto_login: AutoLogin::Off,
10104            filevault_on: Some(false),
10105        };
10106        let gap = unattended_gap(StartMode::Login, "ivan", Some(&off)).expect("a gap");
10107        assert!(gap.reason.contains("automatic login is off"), "{gap:?}");
10108        assert!(gap.reason.contains("Automatically log in as"), "{gap:?}");
10109
10110        let locked = UnattendedLogin {
10111            auto_login: AutoLogin::As("ivan".into()),
10112            filevault_on: Some(true),
10113        };
10114        let gap = unattended_gap(StartMode::Boot, "root", Some(&locked)).expect("a gap");
10115        assert!(gap.reason.contains("FileVault is on"), "{gap:?}");
10116
10117        // An unanswered probe is no gap: `host doctor` says what it could not
10118        // read.
10119        let unread = UnattendedLogin {
10120            auto_login: AutoLogin::As("ivan".into()),
10121            filevault_on: None,
10122        };
10123        assert_eq!(
10124            unattended_gap(StartMode::Login, "ivan", Some(&unread)),
10125            None
10126        );
10127
10128        // Where nothing can be probed, a login registration still says so.
10129        let gap = unattended_gap(StartMode::Login, "ivan", None).expect("a gap");
10130        assert!(
10131            gap.reason
10132                .contains("does not run until the operator signs in")
10133        );
10134        assert_eq!(unattended_gap(StartMode::Boot, "root", None), None);
10135    }
10136
10137    /// A daemon blocked in a file system call is still "running" to the
10138    /// service manager; its heartbeat is what says it is stuck.
10139    #[test]
10140    fn a_daemon_that_stopped_beating_makes_the_service_unhealthy() {
10141        let host = Host::new();
10142        let operations = host.operations();
10143        operations
10144            .install(&host.request(StartMode::Login))
10145            .expect("an install at login");
10146        let stalled_problem = |operations: &ServiceOperations| {
10147            operations
10148                .status()
10149                .expect("a status")
10150                .problems()
10151                .iter()
10152                .find(|problem| problem.subject == DAEMON_STALLED_SUBJECT)
10153                .map(|problem| problem.detail.clone())
10154        };
10155        crate::daemon_heartbeat::beat(&operations.paths, Utc::now()).unwrap();
10156        assert_eq!(stalled_problem(&operations), None, "beating");
10157
10158        crate::daemon_heartbeat::beat(
10159            &operations.paths,
10160            Utc::now() - chrono::Duration::minutes(20),
10161        )
10162        .unwrap();
10163        let detail = stalled_problem(&operations).expect("a stall is a problem");
10164        assert!(detail.contains("has made no progress since"), "{detail}");
10165        assert!(detail.contains("runner-manager service stop"), "{detail}");
10166    }
10167
10168    /// A daemon refusing every runner is not a healthy service.
10169    #[test]
10170    fn a_daemon_that_finds_the_host_unfit_makes_the_service_unhealthy() {
10171        let host = Host::new();
10172        let operations = host.operations();
10173        operations
10174            .install(&host.request(StartMode::Login))
10175            .expect("an install at login");
10176        let paths = operations.paths.clone();
10177        crate::host_fitness::record_host_unfit(
10178            &paths,
10179            &[crate::host_fitness::UnfitFinding {
10180                id: "host.runner_root_responsive".into(),
10181                detail: "macOS has not let the service list /Volumes/NVME/runners".into(),
10182                remedy: Some("click Allow".into()),
10183            }],
10184            Utc::now(),
10185        )
10186        .unwrap();
10187        let status = operations.status().expect("a status");
10188        assert!(!status.is_healthy(), "{status}");
10189        let problem = status
10190            .problems()
10191            .iter()
10192            .find(|problem| problem.subject == HOST_UNFIT_SUBJECT)
10193            .expect("the refusal is a problem");
10194        assert!(
10195            problem.detail.contains("/Volumes/NVME/runners"),
10196            "{problem:?}"
10197        );
10198        assert!(problem.detail.contains("Fix: click Allow"), "{problem:?}");
10199
10200        // A record nobody has re-stamped for a while is a stopped daemon's,
10201        // not a refusal in force.
10202        crate::host_fitness::clear_host_unfit(&paths).unwrap();
10203        crate::host_fitness::record_host_unfit(
10204            &paths,
10205            &[],
10206            Utc::now() - chrono::Duration::hours(2),
10207        )
10208        .unwrap();
10209        let status = operations.status().expect("a status");
10210        assert!(
10211            !status
10212                .problems()
10213                .iter()
10214                .any(|problem| problem.subject == HOST_UNFIT_SUBJECT),
10215            "{status}"
10216        );
10217    }
10218
10219    // -----------------------------------------------------------------------
10220    // Start and stop
10221    // -----------------------------------------------------------------------
10222
10223    #[test]
10224    fn start_and_stop_reach_the_domain_that_holds_the_registration() {
10225        let host = Host::new();
10226        let operations = host.operations();
10227        operations
10228            .install(&host.request(StartMode::Login))
10229            .expect("an install at login");
10230        operations.start().expect("a start");
10231        assert!(operations.status().expect("a status").is_running());
10232        assert!(operations.stop().expect("a stop"));
10233        assert!(!operations.status().expect("a status").is_running());
10234    }
10235
10236    #[test]
10237    fn starting_a_host_with_no_registration_is_refused() {
10238        let host = Host::new();
10239        let error = host.operations().start().expect_err("nothing to start");
10240        assert!(
10241            matches!(error, ServiceError::NotInstalled { .. }),
10242            "{error}"
10243        );
10244    }
10245
10246    // -----------------------------------------------------------------------
10247    // The seam with `d2`
10248    // -----------------------------------------------------------------------
10249
10250    /// Requirement 2 is *"an account that can **read the secret store**"*, and
10251    /// on Windows what an account may read is decided by a DACL in a file this
10252    /// task does not own. Asserting the account name alone would be asserting
10253    /// this module against itself; this reads `d2`'s protection back off a real
10254    /// store and checks that it admits the account this installer registers.
10255    ///
10256    /// No privileges are needed. `d2` documents that a rooted machine-scoped
10257    /// store *"is protected and encrypted exactly as the standard one is"* —
10258    /// the Windows backend picks its DACL from the scope, not from the site —
10259    /// so a store in a temporary directory carries the access control the real
10260    /// one does.
10261    #[cfg(windows)]
10262    #[test]
10263    fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
10264        use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
10265
10266        let root = tempfile::tempdir().expect("a temporary directory");
10267        let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
10268            .expect("a rooted machine-scoped store");
10269        store
10270            .store(&secrecy::SecretString::from("a stand-in for the token"))
10271            .expect("the store accepts a value");
10272        let protection = store.protection().expect("the DACL can be read back");
10273
10274        assert!(
10275            protection.description().contains(";;;SY)"),
10276            "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
10277             read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
10278            protection.description()
10279        );
10280        assert_eq!(
10281            ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
10282            ServiceAccount::LocalSystem,
10283            "and that is the account this installer registers, which is why SY is what matters"
10284        );
10285        assert!(
10286            !protection.readable_by_other_local_users(),
10287            "the same DACL must still exclude ordinary local users: {}",
10288            protection.description()
10289        );
10290
10291        // The other half of the analysis in `docs/service-account.md`, and the
10292        // reason LocalService and NetworkService are not used: the DACL names
10293        // three trustees and neither of them is among them.
10294        for rejected in [";;;LS)", ";;;NS)"] {
10295            assert!(
10296                !protection.description().contains(rejected),
10297                "if the store ever admitted {rejected}, the least-privilege analysis in \
10298                 docs/service-account.md would need redoing: {}",
10299                protection.description()
10300            );
10301        }
10302    }
10303}