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