Skip to main content

runner_manager_platform/
service.rs

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