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