Skip to main content

runner_manager_platform/
service.rs

1// owner: d3-service-installers
2
3//! Registering `daemon run` with the operating system, so that a home host
4//! resumes work by itself after a reboot with nobody logged in.
5//!
6//! Journey 5 is the whole of the requirement: *"the machine reboots with nobody
7//! logged in; the boot-start service starts the agent, which reads the user
8//! access token from the machine-scoped secret store"*. Everything in this file
9//! exists to make that sentence true on Windows, macOS, and Linux, and to make
10//! `service status` say so — or say precisely why not.
11//!
12//! # The shape: render, then apply
13//!
14//! A service manager is the one dependency a `cargo test` run cannot have. So
15//! this module is split in two, and the split is the reason most of it is
16//! testable on a developer's laptop whatever OS that laptop runs:
17//!
18//! * **Rendering** is pure. [`ServiceDefinition`] turns an [`InstallPlan`] into
19//!   the exact text the platform consumes — a systemd unit, a launchd property
20//!   list, a Task Scheduler XML document, or a canonical descriptor of the
21//!   Windows service parameters. No `cfg`, no privileges, no I/O. A Windows
22//!   developer renders and asserts the systemd unit; a Linux CI leg renders and
23//!   asserts the launchd plist.
24//! * **Applying** is behind [`ServiceControl`], one trait with a backend per
25//!   platform and a public in-memory double ([`RecordingControls`]) that `f3`
26//!   and this crate's own tests drive without touching the host.
27//!
28//! [`ServiceOperations`] is the layer above both, and it is where the logic
29//! that is *not* platform-specific lives: refusing an install while the
30//! single-instance lock is held, recording the resolved absolute binary path,
31//! detecting a stale one, switching start mode without reinstalling, and
32//! uninstalling without deleting a byte of configuration, secrets, or cache.
33//!
34//! # Two domains, not two products
35//!
36//! `--start-at boot` and `--start-at login` are the same daemon registered in
37//! two different *domains*:
38//!
39//! | | boot | login |
40//! |---|---|---|
41//! | Windows | a service in the Service Control Manager, `LocalSystem` | a Task Scheduler task with a logon trigger, running at `LeastPrivilege` |
42//! | macOS | a LaunchDaemon in `/Library/LaunchDaemons` | a LaunchAgent in `~/Library/LaunchAgents` |
43//! | Linux | a system unit in `/etc/systemd/system` | a user unit in `~/.config/systemd/user` |
44//!
45//! The Windows row is the one that is not symmetric, and it is worth saying why
46//! rather than leaving a reader to wonder. **Windows services cannot start at
47//! logon.** Service trigger-start covers domain join, an IP address becoming
48//! available, a device arriving, a firewall port event and a group-policy
49//! change; there is no logon trigger, and there is no user-session service
50//! type this product could use instead. Task Scheduler is the mechanism Windows
51//! actually provides for "run this when the operator signs in", so that is what
52//! `--start-at login` uses there. It is registered, inspected, and removed
53//! through the same [`ServiceControl`] trait as everything else, so nothing
54//! above this module has to know.
55//!
56//! # What the account can reach, and why it is not more
57//!
58//! `05-infrastructure.md` requires *"a least-privilege account that can read the
59//! machine-scoped secret store and write its configured cache and runtime
60//! directories"*. Those two clauses pull in opposite directions on every
61//! platform, and the resolution is recorded per platform in
62//! `docs/service-account.md` and checked by [`review_least_privilege`], which
63//! reads the rendered definition back and reports anything it grants beyond the
64//! requirement.
65//!
66//! The Windows resolution is the one that surprises people. `d2` protects the
67//! machine-scoped store with `D:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;OW)` and
68//! documents that the DACL *is* the access control, because a machine-scope
69//! DPAPI blob is decryptable by any process on the host. `NT AUTHORITY\
70//! LocalService` and `NT AUTHORITY\NetworkService` — the two accounts a
71//! "least privilege service" reflex reaches for — are named by none of those
72//! three ACEs and therefore **cannot read the token at all**. Widening the DACL
73//! to reach them would grant every service on the host read access to the one
74//! credential this product holds, which is strictly worse than running as
75//! `LocalSystem`. `secrets.rs` is not this task's file and is not widened; the
76//! service runs as `LocalSystem`, and [`review_least_privilege`] records the
77//! account together with the reason it is the minimum that satisfies the
78//! requirement rather than pretending it is small.
79//!
80//! # What is deliberately not claimed
81//!
82//! **A reboot is not something a test suite can have.** Every assertion here is
83//! about configuration a boot-time start depends on — the start type, the
84//! account, the recorded absolute path, the restart policy the service manager
85//! reports back — and about a store that is readable outside any login session,
86//! which `d2` proves separately. That the machine actually comes back up and
87//! the agent actually resumes is human gate 3 in `06-migration-rollout.md`, and
88//! nothing in this file is evidence for it.
89
90use std::collections::BTreeMap;
91use std::ffi::OsString;
92use std::fmt;
93use std::path::{Path, PathBuf};
94use std::time::Duration;
95
96use chrono::{DateTime, Utc};
97use runner_manager_domain::model::StartMode;
98use runner_manager_domain::path::LocalAbsolutePath;
99use serde::{Deserialize, Serialize};
100
101use crate::lock::{HostLock, LockError, LockKind};
102use crate::paths::AppPaths;
103#[cfg(windows)]
104use crate::runner_root_access::RootAdmission;
105use crate::runner_root_access::{
106    Reversal, RootAccessChange, RootAccessError, RootAccessReport, RootAccessSummary,
107};
108
109// ---------------------------------------------------------------------------
110// Names
111// ---------------------------------------------------------------------------
112
113/// The product's own service name, on every platform that wants a short one.
114pub const SERVICE_NAME: &str = "runner-manager";
115/// Hidden CLI marker carried only by Windows boot-service registrations.
116///
117/// Application-data arguments are shared by every service manager and cannot
118/// distinguish SCM from Task Scheduler. This marker is the durable routing
119/// contract between the Windows installer and the shipping binary.
120pub const WINDOWS_SCM_HOST_ARGUMENT: &str = "--windows-service-host";
121
122/// What an operator sees in `services.msc`, `launchctl list`, or
123/// `systemctl status`.
124pub const DISPLAY_NAME: &str = "GitHub Actions Runner Manager";
125
126/// One line of explanation, for the same three places.
127pub const DESCRIPTION: &str =
128    "Starts ephemeral GitHub Actions self-hosted runners on this machine on demand.";
129
130/// The arguments the installed command line carries.
131///
132/// `05-infrastructure.md`: *"`service install` registers `daemon run` for the
133/// current host"*. Spelled once, here, so the installer and `f3`'s command
134/// surface cannot disagree about what was registered.
135pub const DAEMON_ARGUMENTS: [&str; 2] = ["daemon", "run"];
136
137/// The file `install` writes and `status` reads, inside `config/`.
138///
139/// `config/` and not `state/`: `05-infrastructure.md` gives `config/` to
140/// *"non-secret TOML"*, and this is exactly that — a record of what was
141/// registered, holding no credential. It is also the one file `uninstall`
142/// removes, which is what keeps "uninstall deletes no configuration" honest:
143/// the record is not configuration, it is the registration's own footprint.
144pub const RECORD_FILE: &str = "service.toml";
145
146/// The file the daemon touches after every successful GitHub call, inside
147/// `state/`.
148///
149/// See [`record_github_contact`] for the contract; `service status` reads it
150/// and Journey 5 step 4 requires it.
151pub const CONTACT_FILE: &str = "github-contact.toml";
152
153/// Present while GitHub is rejecting the daemon's credential, inside `state/`.
154///
155/// See [`record_github_credential_rejected`] for the contract.
156pub const CREDENTIAL_REJECTION_FILE: &str = "github-credential-rejected.toml";
157
158/// Present while the daemon cannot read its own stored credential, inside
159/// `state/`.
160///
161/// See [`record_credential_unreadable`] for the contract.
162pub const CREDENTIAL_UNREADABLE_FILE: &str = "credential-unreadable.toml";
163
164/// The `service status` subject a [`CREDENTIAL_UNREADABLE_FILE`] is reported
165/// under, which the TUI matches to offer the same command.
166pub const CREDENTIAL_UNREADABLE_SUBJECT: &str = "stored credential";
167
168/// The `service status` subject blocked runner launches are reported under.
169pub const LAUNCHES_BLOCKED_SUBJECT: &str = "runner launches";
170
171/// The agent's last runner-root refusal, for `service status` to report.
172///
173/// See [`record_runner_root_refusal`] for the contract.
174pub const ROOT_REFUSAL_FILE: &str = "runner-root-refusal.toml";
175
176/// The rotating diagnostic log the **daemon** writes, inside `logs/`.
177///
178/// The appender adds its own date suffix, so this is the stem rather than a
179/// file that exists. `service status` reports the directory and the stem,
180/// because that is what an operator needs in order to find today's file and
181/// yesterday's.
182///
183/// It is [`crate::logging::SERVICE_LOG_STEM`] and not the operator's, because a
184/// boot-mode daemon runs under a different account and writes a different file.
185/// [`crate::logging::LogRole`] says why the two were separated.
186pub const LOG_FILE_STEM: &str = crate::logging::SERVICE_LOG_STEM;
187
188// ---------------------------------------------------------------------------
189// Identity
190// ---------------------------------------------------------------------------
191
192/// What the operating system calls this registration.
193///
194/// A type rather than three constants, for one reason that is not tidiness: the
195/// privileged installer tests register a **real** service on a **real** machine,
196/// and they must not be able to collide with — or remove — an operator's
197/// installation. [`ServiceIdentity::fixture`] produces a name that is
198/// unmistakably a test artefact, and every backend takes its name from here, so
199/// there is no path by which a test reaches the product's own registration.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct ServiceIdentity {
202    name: String,
203    display_name: String,
204    description: String,
205}
206
207impl ServiceIdentity {
208    /// The registration an operator installs.
209    #[must_use]
210    pub fn product() -> Self {
211        Self {
212            name: SERVICE_NAME.to_string(),
213            display_name: DISPLAY_NAME.to_string(),
214            description: DESCRIPTION.to_string(),
215        }
216    }
217
218    /// A disposable registration for a privileged test, named so that it cannot
219    /// be mistaken for — or collide with — [`ServiceIdentity::product`].
220    ///
221    /// `tag` distinguishes concurrent runs; it is reduced to ASCII alphanumerics
222    /// and `-` so that it is a legal service name, launchd label, systemd unit
223    /// name, and Task Scheduler task name at once.
224    #[must_use]
225    pub fn fixture(tag: &str) -> Self {
226        let tag: String = tag
227            .chars()
228            .map(|c| {
229                if c.is_ascii_alphanumeric() {
230                    c.to_ascii_lowercase()
231                } else {
232                    '-'
233                }
234            })
235            .collect();
236        let name = format!("{SERVICE_NAME}-selftest-{tag}");
237        Self {
238            display_name: format!("{DISPLAY_NAME} (self-test fixture {tag})"),
239            description: "Disposable fixture created by runner-manager's own installer tests. \
240                          Safe to remove."
241                .to_string(),
242            name,
243        }
244    }
245
246    /// Whether this identity is a disposable test fixture.
247    ///
248    /// The privileged tests assert this before they delete anything, which is
249    /// the mechanical half of *"never touch a service you did not create"*.
250    #[must_use]
251    pub fn is_fixture(&self) -> bool {
252        self.name.starts_with(&format!("{SERVICE_NAME}-selftest-"))
253    }
254
255    /// The Windows service name, the systemd unit stem, and the Task Scheduler
256    /// task name.
257    #[must_use]
258    pub fn name(&self) -> &str {
259        &self.name
260    }
261
262    /// The human-readable name.
263    #[must_use]
264    pub fn display_name(&self) -> &str {
265        &self.display_name
266    }
267
268    /// One line of explanation.
269    #[must_use]
270    pub fn description(&self) -> &str {
271        &self.description
272    }
273
274    /// The launchd label — reverse-domain, as launchd expects.
275    ///
276    /// Built from the same three product segments [`crate::paths`] resolves the
277    /// application-data directories with, so a label and a directory cannot
278    /// drift apart.
279    #[must_use]
280    pub fn launchd_label(&self) -> String {
281        format!(
282            "{}.{}.{}",
283            crate::paths::QUALIFIER,
284            crate::paths::ORGANIZATION,
285            self.name
286        )
287    }
288
289    /// The systemd unit file name.
290    #[must_use]
291    pub fn systemd_unit(&self) -> String {
292        format!("{}.service", self.name)
293    }
294}
295
296impl fmt::Display for ServiceIdentity {
297    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
298        f.write_str(&self.name)
299    }
300}
301
302// ---------------------------------------------------------------------------
303// Restart policy
304// ---------------------------------------------------------------------------
305
306/// The restart-on-failure policy, and the bound on how fast it may retry.
307///
308/// `05-infrastructure.md` item 3: *"set a restart-on-failure policy with bounded
309/// delay"*. Both halves matter and they are different requirements. A service
310/// that does not come back after a crash defeats Journey 5; a service that comes
311/// back instantly, forever, turns one bad configuration into a fork bomb against
312/// GitHub's rate limit. So the delay has a floor as well as a ceiling, and the
313/// floor is what `does not restart-loop faster than that bound` is measured
314/// against.
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub struct RestartPolicy {
317    delay: Duration,
318    reset_after: Duration,
319}
320
321impl RestartPolicy {
322    /// The shortest delay this will accept.
323    ///
324    /// One second is not a tuning preference. Below it, launchd's own throttle
325    /// and systemd's start-limit logic both take over and the configured number
326    /// stops being the number in force, so a policy under this floor would be a
327    /// value `service status` reported and the platform ignored.
328    pub const MIN_DELAY: Duration = Duration::from_secs(1);
329
330    /// The longest delay this will accept. Past five minutes an operator
331    /// watching a restart would reasonably conclude the service is simply gone.
332    pub const MAX_DELAY: Duration = Duration::from_secs(300);
333
334    /// What `service install` uses when nothing is said.
335    pub const DEFAULT_DELAY: Duration = Duration::from_secs(15);
336
337    /// How long the service must stay up before its failure count is forgotten.
338    pub const DEFAULT_RESET_AFTER: Duration = Duration::from_secs(600);
339
340    /// # Errors
341    ///
342    /// [`ServiceError::RestartDelay`] when `delay` is outside
343    /// [`RestartPolicy::MIN_DELAY`]..=[`RestartPolicy::MAX_DELAY`], or when
344    /// `reset_after` is not longer than `delay` — a reset window shorter than
345    /// the delay can never elapse between two restarts, so the failure count
346    /// would reset on every attempt and no start-limit could ever trip.
347    pub fn new(delay: Duration, reset_after: Duration) -> Result<Self, ServiceError> {
348        if delay < Self::MIN_DELAY || delay > Self::MAX_DELAY {
349            return Err(ServiceError::RestartDelay {
350                requested_secs: delay.as_secs(),
351                min_secs: Self::MIN_DELAY.as_secs(),
352                max_secs: Self::MAX_DELAY.as_secs(),
353            });
354        }
355        if reset_after <= delay {
356            return Err(ServiceError::RestartResetWindow {
357                reset_secs: reset_after.as_secs(),
358                delay_secs: delay.as_secs(),
359            });
360        }
361        Ok(Self { delay, reset_after })
362    }
363
364    /// How long the service manager waits before restarting a failed service.
365    #[must_use]
366    pub const fn delay(&self) -> Duration {
367        self.delay
368    }
369
370    /// How long the service must run before its failure count resets.
371    #[must_use]
372    pub const fn reset_after(&self) -> Duration {
373        self.reset_after
374    }
375
376    /// The delay a given manager can actually express.
377    ///
378    /// Three of the four take seconds and enforce exactly what they are given.
379    /// **Windows Task Scheduler does not**: `RestartOnFailure/Interval` is
380    /// expressed in whole minutes with a one-minute floor, and it *rejects* the
381    /// registration outright rather than rounding — `PT15S` comes back as
382    /// "The task XML contains a value which is incorrectly formatted or out of
383    /// range", which is how this was found.
384    ///
385    /// So the interval is rounded **up**, never down, and never below one
386    /// minute. The direction is the point: the requirement is that the service
387    /// *"does not restart-loop faster than that bound"*, and a delay longer
388    /// than the configured one still satisfies it, while a shorter one would
389    /// not. `service status` reports the difference as a note so that an
390    /// operator reading `15s` in the record and `60s` from the manager is told
391    /// why rather than left to wonder.
392    #[must_use]
393    pub const fn effective_delay(&self, kind: DefinitionKind) -> Duration {
394        match kind {
395            DefinitionKind::WindowsScheduledTask => {
396                let seconds = self.delay.as_secs();
397                let minutes = seconds.div_ceil(60);
398                Duration::from_secs(if minutes == 0 { 60 } else { minutes * 60 })
399            }
400            _ => self.delay,
401        }
402    }
403}
404
405impl Default for RestartPolicy {
406    fn default() -> Self {
407        Self {
408            delay: Self::DEFAULT_DELAY,
409            reset_after: Self::DEFAULT_RESET_AFTER,
410        }
411    }
412}
413
414impl fmt::Display for RestartPolicy {
415    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
416        write!(
417            f,
418            "restart after {}s, failure count resets after {}s",
419            self.delay.as_secs(),
420            self.reset_after.as_secs()
421        )
422    }
423}
424
425// ---------------------------------------------------------------------------
426// The account
427// ---------------------------------------------------------------------------
428
429/// The account a registration runs under.
430///
431/// Not an operator choice. It is a function of the start mode and the platform,
432/// because the start mode decides which secret store the daemon must read and
433/// the store's own access control decides which accounts can read it. See
434/// `docs/service-account.md`, and [`review_least_privilege`] for the check.
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
436#[serde(rename_all = "snake_case")]
437pub enum ServiceAccount {
438    /// `NT AUTHORITY\SYSTEM`. The only stock Windows account named by the
439    /// machine-scoped store's DACL.
440    LocalSystem,
441    /// `root`. What a LaunchDaemon and a systemd system unit run as, and what
442    /// the macOS System Keychain's root-only master key requires.
443    Root,
444    /// The operator's own account, for a login-mode registration.
445    InvokingUser,
446}
447
448impl ServiceAccount {
449    /// The account a given kind of definition obliges.
450    ///
451    /// Keyed on the definition rather than on `cfg!(windows)`, and the
452    /// difference is not academic: a Windows developer rendering the launchd
453    /// property list would otherwise write `UserName = NT AUTHORITY\SYSTEM`
454    /// into it, which is not an account macOS has. A definition's account is a
455    /// property of the definition, and the whole point of rendering being pure
456    /// is that any host can render any platform's and get the right answer.
457    #[must_use]
458    pub const fn for_definition(kind: DefinitionKind, mode: StartMode) -> Self {
459        match (kind, mode) {
460            (DefinitionKind::WindowsService, _) => Self::LocalSystem,
461            (DefinitionKind::WindowsScheduledTask, _) | (_, StartMode::Login) => Self::InvokingUser,
462            (_, StartMode::Boot) => Self::Root,
463        }
464    }
465
466    /// The account the given start mode obliges on the platform this binary was
467    /// built for.
468    #[must_use]
469    pub const fn for_start_mode(mode: StartMode) -> Self {
470        Self::for_definition(host_definition_kind(mode), mode)
471    }
472
473    /// How the platform spells it.
474    #[must_use]
475    pub const fn as_str(&self) -> &'static str {
476        match self {
477            Self::LocalSystem => "NT AUTHORITY\\SYSTEM",
478            Self::Root => "root",
479            Self::InvokingUser => "the invoking user",
480        }
481    }
482
483    /// Why this is the *minimum* account that can do the job, not merely the
484    /// convenient one.
485    ///
486    /// Printed by `service status` and by [`review_least_privilege`], because a
487    /// privileged account with no stated reason is indistinguishable from a
488    /// privileged account nobody thought about.
489    #[must_use]
490    pub const fn justification(&self) -> &'static str {
491        match self {
492            Self::LocalSystem => {
493                "the machine-scoped store's DACL names SY, BA and OW only; LocalService and \
494                 NetworkService cannot read it, and widening the DACL to reach them would grant \
495                 every service on this host read access to the one credential this product holds"
496            }
497            Self::Root => {
498                "a boot-time registration runs outside every login session: on macOS the System \
499                 Keychain is unlocked by /var/db/SystemKey, which is root-only, and on Linux the \
500                 machine-scoped store is a 0600 file under /var/lib that only root can open \
501                 before a session exists"
502            }
503            Self::InvokingUser => {
504                "a login-mode registration reads the user-scoped store, which is deliberately \
505                 readable by exactly one account and needs no elevation at all"
506            }
507        }
508    }
509
510    /// Whether registering under this account needs administrative rights.
511    #[must_use]
512    pub const fn needs_elevation(&self) -> bool {
513        matches!(self, Self::LocalSystem | Self::Root)
514    }
515}
516
517impl fmt::Display for ServiceAccount {
518    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
519        f.write_str(self.as_str())
520    }
521}
522
523// ---------------------------------------------------------------------------
524// Errors
525// ---------------------------------------------------------------------------
526
527/// Something went wrong installing, inspecting, or removing the registration.
528///
529/// Every variant is formatted straight into an operator-facing message, so each
530/// one says what to do next rather than only what failed. No variant carries a
531/// secret: this module never reads the token, only the *location* `d2` publishes
532/// for exactly this purpose.
533#[derive(Debug, thiserror::Error)]
534pub enum ServiceError {
535    /// The single-instance lock is held, so a second agent must not be
536    /// registered.
537    ///
538    /// `05-infrastructure.md` item 1. The message is `d1`'s, verbatim, because
539    /// `d1` already worked out what to tell an operator who lost that race.
540    #[error("cannot install the service while an agent is already running on this host: {source}")]
541    LockHeld {
542        /// What `d1` said, including who holds it.
543        #[source]
544        source: Box<LockError>,
545    },
546
547    /// The lock could not be inspected at all, which is not the same as it being
548    /// free.
549    #[error(
550        "cannot tell whether an agent is already running on this host, so the service was not \
551         installed: {source}. Fix the reported problem with the state directory and try again."
552    )]
553    LockUnreadable {
554        /// The underlying failure.
555        #[source]
556        source: Box<LockError>,
557    },
558
559    /// The runner root this registration would run jobs under could not be
560    /// created, inspected, or given the access the registration's account needs.
561    ///
562    /// Reported *before* anything is registered. A registration whose workspaces
563    /// would land in a directory ordinary local users can write is a
564    /// registration that should not exist, and `04-security-recovery.md` asks
565    /// for exactly that refusal rather than a warning.
566    #[error(
567        "this registration would run jobs under the default runner root, and that root could \
568         not be prepared, so nothing was registered: {source}"
569    )]
570    RunnerRoot {
571        /// What [`crate::runner_root_access`] reported, including the remedy.
572        #[source]
573        source: Box<RootAccessError>,
574    },
575
576    /// The path of the running binary could not be resolved.
577    #[error(
578        "cannot resolve the absolute path of this executable, so there is nothing to register: \
579         {detail}. Run the installer from the installed binary rather than through a shell \
580         function or a wrapper that replaces argv[0]."
581    )]
582    BinaryPath {
583        /// What the platform reported.
584        detail: String,
585    },
586
587    /// The binary named for the registration is not there.
588    #[error(
589        "{} is not a file, so registering it would create a service that cannot start. Install \
590         the product first, then run `service install` from the installed binary.",
591        path.display()
592    )]
593    BinaryMissing {
594        /// The path that was to be registered.
595        path: PathBuf,
596    },
597
598    /// The requested restart delay is outside the bound.
599    #[error(
600        "a restart delay of {requested_secs}s is outside the supported range \
601         {min_secs}s-{max_secs}s. Below the floor the platform's own throttle overrides the \
602         value, so `service status` would report a delay that is not the one in force."
603    )]
604    RestartDelay {
605        /// What was asked for.
606        requested_secs: u64,
607        /// The floor.
608        min_secs: u64,
609        /// The ceiling.
610        max_secs: u64,
611    },
612
613    /// The failure-count reset window is not longer than the restart delay.
614    #[error(
615        "a failure-count reset window of {reset_secs}s is not longer than the {delay_secs}s \
616         restart delay, so the count would reset between every pair of restarts and no \
617         start limit could ever apply."
618    )]
619    RestartResetWindow {
620        /// What was asked for.
621        reset_secs: u64,
622        /// The delay it has to exceed.
623        delay_secs: u64,
624    },
625
626    /// A registration is already there, in the **other** start mode.
627    ///
628    /// Only the other one. A registration in the mode being asked for is
629    /// replaced rather than refused — see [`Operations::install`], and the
630    /// outage that taught us why.
631    #[error(
632        "{name} is already registered to start at {existing}, and this asks for {requested}. \
633         Changing the start mode moves the registration between two different service \
634         managers, so it is not something an install does by itself: run `service uninstall` \
635         first, or change the start mode in the terminal UI (`runner-manager tui`), which \
636         switches it in place and keeps the registration running throughout."
637    )]
638    AlreadyInstalled {
639        /// Which registration.
640        name: String,
641        /// The mode it currently carries.
642        existing: StartMode,
643        /// The mode this install asked for.
644        requested: StartMode,
645    },
646
647    /// No registration is there.
648    #[error("{name} is not registered on this host, so there is nothing to {operation}.")]
649    NotInstalled {
650        /// Which registration.
651        name: String,
652        /// What was being attempted.
653        operation: &'static str,
654    },
655
656    /// The record `install` wrote could not be read or written.
657    #[error("cannot {operation} the service record {}: {detail}", path.display())]
658    Record {
659        /// Read, write, or remove.
660        operation: &'static str,
661        /// The record file.
662        path: PathBuf,
663        /// The underlying failure.
664        detail: String,
665    },
666
667    /// The record is there but is not one this version wrote.
668    #[error(
669        "the service record {} was not written by this product, or was written by a version \
670         this one cannot read: {detail}. Run `service uninstall` and `service install` again; \
671         neither touches configuration, secrets, or the cache.",
672        path.display()
673    )]
674    RecordUnreadable {
675        /// The record file.
676        path: PathBuf,
677        /// What was wrong with it.
678        detail: String,
679    },
680
681    /// The record is there and **this account** may not read it.
682    ///
683    /// Its own variant rather than a [`ServiceError::Record`] with a
684    /// `Permission denied` in the detail, because it is the one record failure
685    /// that is not a fault in the record: `service status` reports it and
686    /// carries on, where every other read failure ends the command. See
687    /// [`Operations::status`].
688    ///
689    /// A version before this one wrote the record at `0600` through a temporary
690    /// file, so a boot-mode `sudo service install` left it owned by `root` and
691    /// unreadable by the operator whose profile it is in. `service status` then
692    /// failed on their own host, with a remedy naming the command that had just
693    /// failed.
694    #[error(
695        "the service record {} is there and this account may not read it: {detail}. It was \
696         written by whichever account installed the service -- on a boot-mode host, `sudo \
697         service install`. Running `service install` again rewrites it readable; until then \
698         only what the service manager itself reports is available.",
699        path.display()
700    )]
701    RecordNotPermitted {
702        /// The record file.
703        path: PathBuf,
704        /// What the operating system said.
705        detail: String,
706    },
707
708    /// The application-data directories could not be resolved or created.
709    #[error("cannot prepare this host's application-data directories: {source}")]
710    Paths {
711        /// The underlying failure.
712        #[source]
713        source: Box<crate::paths::PathsError>,
714    },
715
716    /// The platform's service manager refused, or could not be reached.
717    #[error("cannot {operation} {name} through {manager}: {detail}")]
718    Control {
719        /// What was being attempted.
720        operation: &'static str,
721        /// Which registration.
722        name: String,
723        /// Which service manager.
724        manager: &'static str,
725        /// What it said.
726        detail: String,
727    },
728
729    /// An operation failed and its compensating action failed too.
730    #[error(
731        "cannot {operation} {name}: {cause}. The attempted rollback also failed: {rollback}. \
732         Inspect `service status` before retrying."
733    )]
734    Rollback {
735        /// The transaction that could not be completed.
736        operation: &'static str,
737        /// Which registration was involved.
738        name: String,
739        /// The original failure.
740        cause: String,
741        /// The failure while restoring the previous state.
742        rollback: String,
743    },
744
745    /// The operation needs administrative rights it does not have.
746    #[error("{operation} {name} needs administrative rights: {detail}. {remedy}")]
747    NeedsElevation {
748        /// What was being attempted.
749        operation: &'static str,
750        /// Which registration.
751        name: String,
752        /// What the platform said.
753        detail: String,
754        /// How to get them, in this platform's own terms.
755        remedy: &'static str,
756    },
757}
758
759// ---------------------------------------------------------------------------
760// Directories
761// ---------------------------------------------------------------------------
762
763/// The four directories the registration was installed against.
764///
765/// Recorded rather than re-derived, because `05-infrastructure.md` item 2 speaks
766/// of the *configured* cache and runtime directories: the account the service
767/// runs under and the account that ran `service install` do not always resolve
768/// the same platform-standard locations, and a claim about what the service can
769/// write is only checkable against a specific set of paths.
770#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
771pub struct ServiceDirectories {
772    /// Non-secret TOML and the SQLite database.
773    pub config: PathBuf,
774    /// The agent lock, the attempt journal, and the runner package cache.
775    pub state: PathBuf,
776    /// Per-attempt disposable runner workspaces.
777    pub runtime: PathBuf,
778    /// Rotating redacted diagnostics.
779    pub logs: PathBuf,
780}
781
782impl ServiceDirectories {
783    /// Snapshots the four directories a resolved [`AppPaths`] names.
784    #[must_use]
785    pub fn of(paths: &AppPaths) -> Self {
786        Self {
787            config: paths.config_dir().to_path_buf(),
788            state: paths.state_dir().to_path_buf(),
789            runtime: paths.runtime_dir().to_path_buf(),
790            logs: paths.logs_dir().to_path_buf(),
791        }
792    }
793
794    /// The four, in the order `05-infrastructure.md` lists them.
795    #[must_use]
796    pub fn all(&self) -> [&Path; 4] {
797        [
798            self.config.as_path(),
799            self.state.as_path(),
800            self.runtime.as_path(),
801            self.logs.as_path(),
802        ]
803    }
804
805    /// The rotating diagnostic log's stem, which is what `service status`
806    /// reports and what `05-infrastructure.md` item 4 requires be preserved.
807    #[must_use]
808    pub fn log_file(&self) -> PathBuf {
809        self.logs.join(LOG_FILE_STEM)
810    }
811}
812
813// ---------------------------------------------------------------------------
814// The install plan
815// ---------------------------------------------------------------------------
816
817/// What `service install` was asked for, before anything has been resolved.
818///
819/// Separate from [`InstallPlan`] because a request is the operator's words and
820/// a plan is what the host actually supports: the plan carries a *resolved
821/// absolute* binary path, the account the start mode obliges, and the four
822/// directories the registration is installed against, none of which the caller
823/// supplies.
824#[derive(Debug, Clone, PartialEq, Eq)]
825pub struct InstallRequest {
826    start_mode: StartMode,
827    binary: Option<PathBuf>,
828    source_binary: Option<PathBuf>,
829    arguments: Vec<OsString>,
830    restart: RestartPolicy,
831    on_demand: bool,
832}
833
834impl InstallRequest {
835    /// Register the binary that is running this call.
836    #[must_use]
837    pub fn new(start_mode: StartMode) -> Self {
838        Self {
839            start_mode,
840            binary: None,
841            source_binary: None,
842            arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
843            restart: RestartPolicy::default(),
844            on_demand: false,
845        }
846    }
847
848    /// Registers the service but does not ask the manager to start it by
849    /// itself.
850    ///
851    /// Production never uses this: a boot-mode registration that does not start
852    /// at boot is the failure `service status` reports. **The privileged
853    /// installer tests use it for every fixture they create**, so that a
854    /// registration which somehow escaped its cleanup guard cannot start with
855    /// the owner's machine on the next reboot. A leaked service is bad; a
856    /// leaked service that runs is worse, and the difference costs one flag.
857    #[must_use]
858    pub const fn started_on_demand(mut self) -> Self {
859        self.on_demand = true;
860        self
861    }
862
863    /// Register a named binary instead of the running one.
864    ///
865    /// The privileged installer tests use this to register a fixture service
866    /// host. Production does not: `05-infrastructure.md` item 6 is specifically
867    /// about the path of *the running binary*, and letting an operator name a
868    /// different one would defeat the stale-path detection it asks for.
869    #[must_use]
870    pub fn for_binary(mut self, binary: impl Into<PathBuf>) -> Self {
871        self.binary = Some(binary.into());
872        self
873    }
874
875    /// Where the registered binary was copied from.
876    ///
877    /// # Why a registration has a source at all
878    ///
879    /// A service registered directly against a package manager's own file can
880    /// never be upgraded on Windows, because the running service holds that
881    /// file open and `npm i -g` cannot replace it. What it does instead is
882    /// worse than failing: it rewrites the package metadata, reports success,
883    /// and leaves the old executable in place — so the operator is told the new
884    /// version is installed while the old one keeps running. That was observed,
885    /// twice in a row, before this existed.
886    ///
887    /// So the service runs a copy the product owns, and this records where that
888    /// copy came from. The source is what a package manager updates, what
889    /// [`inspect_binary`] watches for item 6's stale-path case, and what the
890    /// daemon compares its own version against to know an upgrade is waiting.
891    #[must_use]
892    pub fn copied_from(mut self, source: impl Into<PathBuf>) -> Self {
893        self.source_binary = Some(source.into());
894        self
895    }
896
897    /// Replace the registered arguments. Defaults to [`DAEMON_ARGUMENTS`].
898    #[must_use]
899    pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
900    where
901        I: IntoIterator<Item = S>,
902        S: Into<OsString>,
903    {
904        self.arguments = arguments.into_iter().map(Into::into).collect();
905        self
906    }
907
908    /// Replace the restart-on-failure policy. Defaults to
909    /// [`RestartPolicy::default`].
910    #[must_use]
911    pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
912        self.restart = restart;
913        self
914    }
915
916    /// The start mode asked for.
917    #[must_use]
918    pub const fn start_mode(&self) -> StartMode {
919        self.start_mode
920    }
921}
922
923/// Everything a backend needs, resolved and validated.
924///
925/// Constructing one is where `05-infrastructure.md` item 6 is actually
926/// satisfied: the binary path is made absolute and is confirmed to be a file
927/// *before* anything is registered, so a registration that would fail at boot
928/// is refused at install time instead.
929#[derive(Debug, Clone, PartialEq, Eq)]
930pub struct InstallPlan {
931    identity: ServiceIdentity,
932    start_mode: StartMode,
933    binary: PathBuf,
934    source_binary: Option<PathBuf>,
935    arguments: Vec<OsString>,
936    account: ServiceAccount,
937    restart: RestartPolicy,
938    directories: ServiceDirectories,
939    secret_guard: Option<PathBuf>,
940    on_demand: bool,
941}
942
943impl InstallPlan {
944    /// Resolves a request against this host.
945    ///
946    /// # Errors
947    ///
948    /// [`ServiceError::BinaryPath`] when the running executable cannot be
949    /// located, and [`ServiceError::BinaryMissing`] when the path that would be
950    /// registered is not a file.
951    pub fn resolve(
952        identity: ServiceIdentity,
953        request: &InstallRequest,
954        directories: ServiceDirectories,
955    ) -> Result<Self, ServiceError> {
956        let binary = match &request.binary {
957            Some(named) => absolute(named)?,
958            None => running_executable()?,
959        };
960        if !binary.is_file() {
961            return Err(ServiceError::BinaryMissing { path: binary });
962        }
963        // `d2` publishes the guard file the store lives in so the systemd
964        // sandbox can admit only its parent for atomic token rotation. A store
965        // that cannot be resolved is not fatal here; it merely cannot receive
966        // that narrow writable-path exception.
967        let secret_guard = crate::secrets::PlatformSecretStore::for_start_mode(request.start_mode)
968            .ok()
969            .map(|store| store.guard());
970        Ok(Self {
971            identity,
972            start_mode: request.start_mode,
973            binary,
974            source_binary: request.source_binary.clone(),
975            arguments: request.arguments.clone(),
976            account: ServiceAccount::for_start_mode(request.start_mode),
977            restart: request.restart,
978            directories,
979            secret_guard,
980            on_demand: request.on_demand,
981        })
982    }
983
984    /// Builds a plan from values a caller already holds, without touching the
985    /// filesystem.
986    ///
987    /// This is what makes the renderers testable on a host that has none of the
988    /// paths involved: a Windows developer can render the systemd unit for
989    /// `/opt/runner-manager/bin/runner-manager` and assert every line of it.
990    #[must_use]
991    pub fn unchecked(
992        identity: ServiceIdentity,
993        start_mode: StartMode,
994        binary: impl Into<PathBuf>,
995        directories: ServiceDirectories,
996    ) -> Self {
997        Self {
998            identity,
999            start_mode,
1000            binary: binary.into(),
1001            source_binary: None,
1002            arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
1003            account: ServiceAccount::for_start_mode(start_mode),
1004            restart: RestartPolicy::default(),
1005            directories,
1006            secret_guard: None,
1007            on_demand: false,
1008        }
1009    }
1010
1011    /// Registers without arming the manager's automatic start. See
1012    /// [`InstallRequest::started_on_demand`].
1013    #[must_use]
1014    pub const fn started_on_demand(mut self) -> Self {
1015        self.on_demand = true;
1016        self
1017    }
1018
1019    /// Whether the manager was asked not to start this by itself.
1020    #[must_use]
1021    pub const fn is_on_demand(&self) -> bool {
1022        self.on_demand
1023    }
1024
1025    /// Names the file the secret store lives in, so systemd can permit atomic
1026    /// replacement of it under `ProtectSystem=strict`.
1027    ///
1028    /// [`InstallPlan::resolve`] fills this from `d2`; a test names it directly
1029    /// so that a Windows or macOS CI leg can assert the Linux unit's writable
1030    /// path for a store that platform does not have.
1031    #[must_use]
1032    pub fn with_secret_guard(mut self, guard: impl Into<PathBuf>) -> Self {
1033        self.secret_guard = Some(guard.into());
1034        self
1035    }
1036
1037    /// The file the machine-scoped secret store lives in, when one resolved.
1038    #[must_use]
1039    pub fn secret_guard(&self) -> Option<&Path> {
1040        self.secret_guard.as_deref()
1041    }
1042
1043    /// Overrides the restart policy on an already-built plan.
1044    #[must_use]
1045    pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1046        self.restart = restart;
1047        self
1048    }
1049
1050    /// Overrides the registered arguments on an already-built plan.
1051    #[must_use]
1052    pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1053    where
1054        I: IntoIterator<Item = S>,
1055        S: Into<OsString>,
1056    {
1057        self.arguments = arguments.into_iter().map(Into::into).collect();
1058        self
1059    }
1060
1061    /// What the operating system calls this registration.
1062    #[must_use]
1063    pub const fn identity(&self) -> &ServiceIdentity {
1064        &self.identity
1065    }
1066
1067    /// Boot or login.
1068    #[must_use]
1069    pub const fn start_mode(&self) -> StartMode {
1070        self.start_mode
1071    }
1072
1073    /// The resolved absolute path of the binary to register.
1074    #[must_use]
1075    pub fn binary(&self) -> &Path {
1076        &self.binary
1077    }
1078
1079    /// Where [`Self::binary`] was copied from, when it is a copy.
1080    ///
1081    /// `None` is the legacy layout: the registration names the file a package
1082    /// manager owns, and cannot be upgraded while it runs. See
1083    /// [`InstallRequest::copied_from`].
1084    #[must_use]
1085    pub fn source_binary(&self) -> Option<&Path> {
1086        self.source_binary.as_deref()
1087    }
1088
1089    /// The arguments it is registered with.
1090    #[must_use]
1091    pub fn arguments(&self) -> &[OsString] {
1092        &self.arguments
1093    }
1094
1095    /// The account it runs under.
1096    #[must_use]
1097    pub const fn account(&self) -> &ServiceAccount {
1098        &self.account
1099    }
1100
1101    /// The restart-on-failure policy.
1102    #[must_use]
1103    pub const fn restart(&self) -> RestartPolicy {
1104        self.restart
1105    }
1106
1107    /// The four directories it was installed against.
1108    #[must_use]
1109    pub const fn directories(&self) -> &ServiceDirectories {
1110        &self.directories
1111    }
1112
1113    /// The command line as one string, quoted the way each platform expects.
1114    #[must_use]
1115    pub fn command_line(&self) -> String {
1116        let mut out = quote_argument(&self.binary.to_string_lossy());
1117        for argument in &self.arguments {
1118            out.push(' ');
1119            out.push_str(&quote_argument(&argument.to_string_lossy()));
1120        }
1121        out
1122    }
1123}
1124
1125/// Undoes a runner-root change, and says what could not be undone.
1126///
1127/// `None` when the rollback was complete — either because there was nothing to
1128/// undo, or because the directory this operation created was removed again and
1129/// the descriptor it replaced was written back. `Some` is the "report any
1130/// non-reversible existing directory state explicitly" half of the requirement,
1131/// and the caller folds it into the failure it was already reporting.
1132fn retained_runner_root(change: &RootAccessChange) -> Option<String> {
1133    let reversal = change.revert();
1134    matches!(reversal, Reversal::Retained { .. }).then(|| reversal.to_string())
1135}
1136
1137/// The error a failed step returns once both of its rollbacks have been
1138/// attempted.
1139///
1140/// `retained` is [`retained_runner_root`]'s answer and `rollback` is what
1141/// undoing the *registration* reported — whatever it reports on success, which
1142/// a failure has no use for. Both arrive already evaluated, because each may be
1143/// attempted exactly once and the order is the caller's to choose.
1144///
1145/// A complete rollback leaves `cause` exactly as it was: the operator's problem
1146/// is what failed, not the tidying up afterwards. An incomplete one is promoted
1147/// to [`ServiceError::Rollback`], which is the variant that exists to say "this
1148/// failed *and* something is left behind" — and it names everything that did.
1149fn rolled_back<T>(
1150    retained: Option<String>,
1151    rollback: Result<T, ServiceError>,
1152    operation: &'static str,
1153    identity: &ServiceIdentity,
1154    cause: ServiceError,
1155) -> ServiceError {
1156    let left_behind = match (rollback.err(), retained) {
1157        (Some(rollback), Some(retained)) => Some(format!("{rollback}; {retained}")),
1158        (Some(rollback), None) => Some(rollback.to_string()),
1159        (None, retained) => retained,
1160    };
1161    match left_behind {
1162        Some(rollback) => ServiceError::Rollback {
1163            operation,
1164            name: identity.name().to_string(),
1165            cause: cause.to_string(),
1166            rollback,
1167        },
1168        None => cause,
1169    }
1170}
1171
1172/// [`rolled_back`] for a failure with nothing registered yet, where the runner
1173/// root is the only thing there is to undo.
1174fn undo_runner_root(
1175    change: &RootAccessChange,
1176    operation: &'static str,
1177    identity: &ServiceIdentity,
1178    cause: ServiceError,
1179) -> ServiceError {
1180    rolled_back(
1181        retained_runner_root(change),
1182        Ok(()),
1183        operation,
1184        identity,
1185        cause,
1186    )
1187}
1188
1189/// Makes a path absolute without resolving symlinks.
1190///
1191/// Symbolic links are deliberately left alone. A Homebrew installation puts a
1192/// stable link at `/opt/homebrew/bin/runner-manager` and moves the file it
1193/// points at on every upgrade; recording the link's target would make an
1194/// ordinary `brew upgrade` look exactly like the npm failure this module exists
1195/// to detect, and recording the link records the thing the operator installed.
1196///
1197/// Linux is the one platform where this is not fully in the caller's hands:
1198/// `std::env::current_exe` reads `/proc/self/exe`, which the kernel has already
1199/// resolved. There the recorded path is the real file rather than the shim —
1200/// which is still correct for the npm case, because a real file under a Node
1201/// prefix disappears with the prefix.
1202fn absolute(path: &Path) -> Result<PathBuf, ServiceError> {
1203    std::path::absolute(path).map_err(|error| ServiceError::BinaryPath {
1204        detail: format!("{} could not be made absolute: {error}", path.display()),
1205    })
1206}
1207
1208/// The absolute path of the executable running this call.
1209fn running_executable() -> Result<PathBuf, ServiceError> {
1210    let raw = std::env::current_exe().map_err(|error| ServiceError::BinaryPath {
1211        detail: error.to_string(),
1212    })?;
1213    absolute(&raw)
1214}
1215
1216/// Quotes one command-line argument when it needs it.
1217///
1218/// The rule is Windows', because Windows is the platform where a command line is
1219/// a single string the callee re-splits, and because `windows-service` applies
1220/// exactly this rule when it builds `lpBinaryPathName`. The Unix backends embed
1221/// arguments in a plist array and in a systemd `ExecStart=`, both of which
1222/// accept the same quoting, so one rule serves all three rather than three
1223/// nearly-identical ones.
1224pub(crate) fn quote_argument(argument: &str) -> String {
1225    if !argument.is_empty() && !argument.contains([' ', '"', '\t', '\n']) {
1226        return argument.to_string();
1227    }
1228    let mut out = String::with_capacity(argument.len() + 2);
1229    out.push('"');
1230    let mut backslashes = 0usize;
1231    for c in argument.chars() {
1232        match c {
1233            '\\' => {
1234                backslashes += 1;
1235                out.push('\\');
1236            }
1237            '"' => {
1238                // Every backslash immediately before a quote must be doubled,
1239                // and the quote itself escaped.
1240                for _ in 0..=backslashes {
1241                    out.push('\\');
1242                }
1243                out.push('"');
1244                backslashes = 0;
1245            }
1246            other => {
1247                backslashes = 0;
1248                out.push(other);
1249            }
1250        }
1251    }
1252    // Trailing backslashes would otherwise escape the closing quote.
1253    for _ in 0..backslashes {
1254        out.push('\\');
1255    }
1256    out.push('"');
1257    out
1258}
1259
1260/// Reads the executable back out of a command line quoted by
1261/// [`quote_argument`].
1262///
1263/// `service status` needs this because the Windows Service Control Manager
1264/// stores the binary and its arguments as one string and hands the whole thing
1265/// back from `QueryServiceConfigW`. Comparing a record against a registration
1266/// means splitting that string the same way Windows itself does.
1267///
1268/// Returns `None` for an empty or whitespace-only command line, which is the
1269/// only input with no first argument to find.
1270#[must_use]
1271pub fn executable_from_command_line(command_line: &str) -> Option<PathBuf> {
1272    let trimmed = command_line.trim_start();
1273    if trimmed.is_empty() {
1274        return None;
1275    }
1276    let mut out = String::new();
1277    let mut chars = trimmed.chars().peekable();
1278    let quoted = chars.peek() == Some(&'"');
1279    if quoted {
1280        chars.next();
1281        let mut backslashes = 0usize;
1282        for c in chars {
1283            match c {
1284                '\\' => {
1285                    backslashes += 1;
1286                }
1287                '"' => {
1288                    // `2n` backslashes then a quote closes the argument; `2n+1`
1289                    // is a literal quote inside it.
1290                    out.extend(std::iter::repeat_n('\\', backslashes / 2));
1291                    if backslashes.is_multiple_of(2) {
1292                        break;
1293                    }
1294                    backslashes = 0;
1295                    out.push('"');
1296                }
1297                other => {
1298                    out.extend(std::iter::repeat_n('\\', backslashes));
1299                    backslashes = 0;
1300                    out.push(other);
1301                }
1302            }
1303        }
1304    } else {
1305        for c in chars {
1306            if c == ' ' || c == '\t' {
1307                break;
1308            }
1309            out.push(c);
1310        }
1311    }
1312    if out.is_empty() {
1313        None
1314    } else {
1315        Some(PathBuf::from(out))
1316    }
1317}
1318
1319// ---------------------------------------------------------------------------
1320// Rendered definitions
1321// ---------------------------------------------------------------------------
1322
1323/// Which of the four things a platform reads.
1324#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1325pub enum DefinitionKind {
1326    /// The parameters `CreateServiceW` is called with, rendered as a canonical
1327    /// descriptor so that they can be reviewed and asserted like the other
1328    /// three. The Service Control Manager has no file.
1329    WindowsService,
1330    /// A Task Scheduler XML document, for `--start-at login` on Windows.
1331    WindowsScheduledTask,
1332    /// A launchd property list — a LaunchDaemon at boot, a LaunchAgent at login.
1333    LaunchdPlist,
1334    /// A systemd unit — a system unit at boot, a user unit at login.
1335    SystemdUnit,
1336}
1337
1338impl DefinitionKind {
1339    /// What to call the thing that reads it, in an operator-facing message.
1340    #[must_use]
1341    pub const fn manager(self) -> &'static str {
1342        match self {
1343            Self::WindowsService => "the Windows Service Control Manager",
1344            Self::WindowsScheduledTask => "Windows Task Scheduler",
1345            Self::LaunchdPlist => "launchd",
1346            Self::SystemdUnit => "systemd",
1347        }
1348    }
1349}
1350
1351impl fmt::Display for DefinitionKind {
1352    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1353        f.write_str(self.manager())
1354    }
1355}
1356
1357/// One platform's definition of the registration, as text.
1358///
1359/// Producing this is pure: no `cfg`, no privileges, no filesystem. That is what
1360/// lets every leg of the CI matrix assert every platform's definition, instead
1361/// of each leg asserting only its own and the other two being reviewed by
1362/// reading.
1363#[derive(Debug, Clone, PartialEq, Eq)]
1364pub struct ServiceDefinition {
1365    kind: DefinitionKind,
1366    text: String,
1367    install_path: Option<PathBuf>,
1368}
1369
1370impl ServiceDefinition {
1371    /// Which platform this is for.
1372    #[must_use]
1373    pub const fn kind(&self) -> DefinitionKind {
1374        self.kind
1375    }
1376
1377    /// The definition itself.
1378    #[must_use]
1379    pub fn text(&self) -> &str {
1380        &self.text
1381    }
1382
1383    /// Where the platform expects the file, when the platform reads a file.
1384    ///
1385    /// `None` for [`DefinitionKind::WindowsService`], whose "definition" is a
1386    /// set of arguments to `CreateServiceW`, and for a Task Scheduler document,
1387    /// which is handed to `schtasks` from a temporary file and is not stored
1388    /// where it was written.
1389    #[must_use]
1390    pub fn install_path(&self) -> Option<&Path> {
1391        self.install_path.as_deref()
1392    }
1393
1394    /// The definition this host's own service manager would be given.
1395    ///
1396    /// The value-level twin of [`host_definition_kind`]. Rendering needs no
1397    /// privileges and no service manager, so anything that wants to *see* what
1398    /// an install would register — `f3`'s `service install --dry-run`, a
1399    /// support bundle, [`RecordingControls`] — can have it without registering
1400    /// anything.
1401    ///
1402    /// # Errors
1403    ///
1404    /// [`ServiceError::Control`] when a Windows logon-triggered registration is
1405    /// asked for and the session reports no account to register it for.
1406    pub fn for_host(plan: &InstallPlan) -> Result<Self, ServiceError> {
1407        Ok(match host_definition_kind(plan.start_mode()) {
1408            DefinitionKind::WindowsService => Self::windows_service(plan),
1409            DefinitionKind::WindowsScheduledTask => {
1410                Self::windows_scheduled_task(plan, &TaskPrincipal::current()?)
1411            }
1412            DefinitionKind::LaunchdPlist => Self::launchd(plan, host_home().as_deref()),
1413            DefinitionKind::SystemdUnit => Self::systemd(plan, host_home().as_deref()),
1414        })
1415    }
1416
1417    /// The Windows service parameters, as a canonical descriptor.
1418    #[must_use]
1419    pub fn windows_service(plan: &InstallPlan) -> Self {
1420        Self {
1421            kind: DefinitionKind::WindowsService,
1422            text: windows_service_descriptor(plan),
1423            install_path: None,
1424        }
1425    }
1426
1427    /// A Task Scheduler document for `--start-at login`.
1428    #[must_use]
1429    pub fn windows_scheduled_task(plan: &InstallPlan, principal: &TaskPrincipal) -> Self {
1430        Self {
1431            kind: DefinitionKind::WindowsScheduledTask,
1432            text: windows_scheduled_task_xml(plan, principal),
1433            install_path: None,
1434        }
1435    }
1436
1437    /// A launchd property list.
1438    ///
1439    /// `home` is only consulted for `--start-at login`, where the file belongs
1440    /// in the operator's own `~/Library/LaunchAgents`.
1441    #[must_use]
1442    pub fn launchd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1443        let file = format!("{}.plist", plan.identity().launchd_label());
1444        let install_path = match plan.start_mode() {
1445            StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
1446            StartMode::Login => home.map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file)),
1447        };
1448        Self {
1449            kind: DefinitionKind::LaunchdPlist,
1450            text: launchd_plist(plan),
1451            install_path,
1452        }
1453    }
1454
1455    /// Wraps text this module did not render.
1456    ///
1457    /// Two callers need it and both matter. A test builds a deliberately
1458    /// widened definition and watches [`review_least_privilege`] reject it —
1459    /// without which the review would be a function nobody had ever seen fail.
1460    /// And `service status` can review the definition **actually on disk**,
1461    /// which on Linux and macOS an operator is free to edit after installation.
1462    #[must_use]
1463    pub fn from_text(kind: DefinitionKind, text: impl Into<String>) -> Self {
1464        Self {
1465            kind,
1466            text: text.into(),
1467            install_path: None,
1468        }
1469    }
1470
1471    /// A systemd unit.
1472    ///
1473    /// `home` is only consulted for `--start-at login`, where the unit belongs
1474    /// in the operator's own `~/.config/systemd/user`.
1475    #[must_use]
1476    pub fn systemd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1477        let file = plan.identity().systemd_unit();
1478        let install_path = match plan.start_mode() {
1479            StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
1480            StartMode::Login => home.map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file)),
1481        };
1482        Self {
1483            kind: DefinitionKind::SystemdUnit,
1484            text: systemd_unit(plan),
1485            install_path,
1486        }
1487    }
1488}
1489
1490/// Where a LaunchDaemon lives.
1491pub const LAUNCH_DAEMONS_DIR: &str = "/Library/LaunchDaemons";
1492/// Where a LaunchAgent lives, under the operator's home directory.
1493pub const LAUNCH_AGENTS_SUBDIR: &str = "Library/LaunchAgents";
1494/// Where a systemd system unit lives.
1495pub const SYSTEMD_SYSTEM_DIR: &str = "/etc/systemd/system";
1496/// Where a systemd user unit lives, under the operator's home directory.
1497pub const SYSTEMD_USER_SUBDIR: &str = ".config/systemd/user";
1498
1499/// The product's own page, cited by every definition so an operator who finds
1500/// one on a host can find out what put it there.
1501const DOCUMENTATION: &str = "https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI";
1502
1503/// How many consecutive failures the platform tolerates before it stops
1504/// retrying.
1505///
1506/// The companion to [`RestartPolicy::reset_after`]: the delay bounds how *fast*
1507/// a restart may come, and this bounds how *many* come before the host gives
1508/// up and leaves the failure visible instead of hiding it behind an endless
1509/// retry.
1510pub const START_LIMIT_BURST: u32 = 5;
1511
1512// -- systemd -----------------------------------------------------------------
1513
1514/// Renders the systemd unit for this plan.
1515///
1516/// # Why the sandbox is this tight, and what it costs
1517///
1518/// `05-infrastructure.md` item 2 ends *"and write its configured cache and
1519/// runtime directories — and no more"*. `ProtectSystem=strict` plus an explicit
1520/// `ReadWritePaths` is what "and no more" means on Linux: everything outside the
1521/// recorded application-data directories and the credential store directory
1522/// is read-only to this unit. The latter must be writable because refresh-token
1523/// rotation atomically replaces the credential file.
1524///
1525/// **The runner inherits it.** The agent spawns the GitHub Actions runner as a
1526/// child, so a workflow running on this host also runs inside this sandbox: it
1527/// cannot write outside those directories and its private `/tmp`, and
1528/// `NoNewPrivileges=yes` means it cannot `sudo`. `07-security.md` assumes a
1529/// hostile workflow may run here, so that is the intended direction — but it is
1530/// a real behavioural limit and `docs/service-account.md` states it where an
1531/// operator will find it.
1532#[must_use]
1533pub fn systemd_unit(plan: &InstallPlan) -> String {
1534    let identity = plan.identity();
1535    let restart = plan.restart();
1536    let directories = plan.directories();
1537
1538    let mut out = String::new();
1539    out.push_str("[Unit]\n");
1540    out.push_str(&format!("Description={}\n", identity.display_name()));
1541    out.push_str(&format!("Documentation={DOCUMENTATION}\n"));
1542    out.push_str("After=network-online.target\n");
1543    out.push_str("Wants=network-online.target\n");
1544    // In `[Unit]` rather than `[Service]`: systemd moved these in v229 and only
1545    // accepts them here without a deprecation warning.
1546    out.push_str(&format!(
1547        "StartLimitIntervalSec={}\n",
1548        restart.reset_after().as_secs()
1549    ));
1550    out.push_str(&format!("StartLimitBurst={START_LIMIT_BURST}\n"));
1551
1552    out.push_str("\n[Service]\n");
1553    out.push_str("Type=simple\n");
1554    // A runner is a child of the daemon. In particular, systemd must not
1555    // signal it merely because a cooperative daemon handover replaces the
1556    // manager process; startup recovery supervises any survivor instead.
1557    out.push_str("KillMode=process\n");
1558    out.push_str(&format!("ExecStart={}\n", plan.command_line()));
1559    out.push_str(&format!(
1560        "WorkingDirectory={}\n",
1561        directories.state.display()
1562    ));
1563    out.push_str(&format!("SyslogIdentifier={identity}\n"));
1564    out.push_str("Restart=on-failure\n");
1565    out.push_str(&format!("RestartSec={}\n", restart.delay().as_secs()));
1566
1567    out.push_str("\n# Least privilege. See docs/service-account.md.\n");
1568    for directive in SYSTEMD_HARDENING {
1569        out.push_str(directive);
1570        out.push('\n');
1571    }
1572    let mut writable = directories.all().to_vec();
1573    if let Some(secret_directory) = plan.secret_guard().and_then(Path::parent) {
1574        writable.push(secret_directory);
1575    }
1576    out.push_str(&format!(
1577        "ReadWritePaths={}\n",
1578        writable
1579            .iter()
1580            .map(|path| quote_argument(&path.to_string_lossy()))
1581            .collect::<Vec<_>>()
1582            .join(" ")
1583    ));
1584
1585    out.push_str("\n[Install]\n");
1586    out.push_str(match plan.start_mode() {
1587        StartMode::Boot => "WantedBy=multi-user.target\n",
1588        StartMode::Login => "WantedBy=default.target\n",
1589    });
1590    out
1591}
1592
1593/// The hardening directives every unit carries, in the order they are rendered.
1594///
1595/// A constant rather than a sequence of `push_str` calls so that
1596/// [`review_least_privilege`] can be written against the same list the renderer
1597/// emits: a directive dropped from the unit is a directive the review then
1598/// reports as missing, rather than one that silently stops being checked.
1599pub const SYSTEMD_HARDENING: [&str; 13] = [
1600    "NoNewPrivileges=yes",
1601    "CapabilityBoundingSet=",
1602    "AmbientCapabilities=",
1603    "PrivateTmp=yes",
1604    "PrivateDevices=yes",
1605    "ProtectSystem=strict",
1606    "ProtectKernelTunables=yes",
1607    "ProtectKernelModules=yes",
1608    "ProtectControlGroups=yes",
1609    "RestrictNamespaces=yes",
1610    "RestrictRealtime=yes",
1611    "RestrictSUIDSGID=yes",
1612    "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1613];
1614
1615// -- launchd -----------------------------------------------------------------
1616
1617/// The `ProcessType` every rendered plist carries.
1618///
1619/// # Why not `Background`
1620///
1621/// launchd applies a job's `ProcessType` to the whole process tree it starts,
1622/// not only to the daemon. Under `Background` every runner the daemon launched
1623/// ran at base priority 4, on efficiency cores with throttled I/O, so each job
1624/// took 4 to 20 times as long as on a classic runner.
1625///
1626/// A child cannot leave it. This is the process type launchd chose at spawn
1627/// (`spawn type = background (5)` in `launchctl print`), not the
1628/// `PRIO_DARWIN_BG` flag `setpriority` controls: measured on macOS 27, a child
1629/// of a `Background` agent reads `getpriority(PRIO_DARWIN_PROCESS) == 0`, and
1630/// `setpriority(PRIO_DARWIN_PROCESS, 0, 0)` returns success and leaves it at
1631/// 4, before `exec` and after it alike. 0.4.31 tried exactly that.
1632///
1633/// The measured alternatives, as the base priority of a runner the agent
1634/// starts: `Background` 4, `Adaptive` 4, `Standard` (the default when the key
1635/// is absent) 20, `Interactive` 31 (the same as a classic runner, and the value
1636/// GitHub's own runner service template uses). The daemon itself mostly waits,
1637/// so it costs nothing to run it at that priority too.
1638pub const LAUNCHD_PROCESS_TYPE: &str = "Interactive";
1639
1640/// Renders the launchd property list for this plan.
1641///
1642/// `KeepAlive` is a dictionary rather than `<true/>` on purpose: the
1643/// requirement is *restart on failure*, and a bare `KeepAlive` also restarts a
1644/// job that exited cleanly, which would turn a deliberate `service stop` into a
1645/// fight with launchd.
1646#[must_use]
1647pub fn launchd_plist(plan: &InstallPlan) -> String {
1648    let identity = plan.identity();
1649    let directories = plan.directories();
1650    let mut out = String::new();
1651    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
1652    out.push_str(
1653        "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
1654         \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
1655    );
1656    out.push_str("<plist version=\"1.0\">\n<dict>\n");
1657    out.push_str(&plist_string("Label", &identity.launchd_label()));
1658
1659    out.push_str("  <key>ProgramArguments</key>\n  <array>\n");
1660    out.push_str(&format!(
1661        "    <string>{}</string>\n",
1662        xml_escape(&plan.binary().to_string_lossy())
1663    ));
1664    for argument in plan.arguments() {
1665        out.push_str(&format!(
1666            "    <string>{}</string>\n",
1667            xml_escape(&argument.to_string_lossy())
1668        ));
1669    }
1670    out.push_str("  </array>\n");
1671
1672    out.push_str("  <key>RunAtLoad</key>\n  <true/>\n");
1673    out.push_str("  <key>KeepAlive</key>\n  <dict>\n");
1674    out.push_str("    <key>SuccessfulExit</key>\n    <false/>\n");
1675    out.push_str("  </dict>\n");
1676    out.push_str(&format!(
1677        "  <key>ThrottleInterval</key>\n  <integer>{}</integer>\n",
1678        plan.restart().delay().as_secs()
1679    ));
1680    out.push_str(&plist_string("ProcessType", LAUNCHD_PROCESS_TYPE));
1681    out.push_str(&plist_string(
1682        "WorkingDirectory",
1683        &directories.state.to_string_lossy(),
1684    ));
1685    out.push_str(&plist_string(
1686        "StandardOutPath",
1687        &directories
1688            .logs
1689            .join("runner-manager.launchd.out.log")
1690            .to_string_lossy(),
1691    ));
1692    out.push_str(&plist_string(
1693        "StandardErrorPath",
1694        &directories
1695            .logs
1696            .join("runner-manager.launchd.err.log")
1697            .to_string_lossy(),
1698    ));
1699
1700    match plan.start_mode() {
1701        StartMode::Boot => {
1702            // A LaunchDaemon runs as root unless told otherwise, and here it
1703            // must: the System Keychain's master key is root-only.
1704            out.push_str(&plist_string(
1705                "UserName",
1706                ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
1707                    .as_str(),
1708            ));
1709            // A daemon has no user session and must not be given one.
1710            out.push_str("  <key>SessionCreate</key>\n  <false/>\n");
1711        }
1712        StartMode::Login => {
1713            // A LaunchAgent already runs as the operator. Naming a `UserName`
1714            // here would be asking launchd for an account switch a login-mode
1715            // registration has no reason to want.
1716        }
1717    }
1718
1719    out.push_str("</dict>\n</plist>\n");
1720    out
1721}
1722
1723/// One `<key>`/`<string>` pair, indented as the rest of the plist is.
1724fn plist_string(key: &str, value: &str) -> String {
1725    format!(
1726        "  <key>{}</key>\n  <string>{}</string>\n",
1727        xml_escape(key),
1728        xml_escape(value)
1729    )
1730}
1731
1732// -- Windows Task Scheduler --------------------------------------------------
1733
1734/// The account a Task Scheduler task runs as.
1735///
1736/// Task Scheduler, unlike the other three managers, needs the principal spelled
1737/// out in the document. Isolating the one impure step keeps
1738/// [`windows_scheduled_task_xml`] a pure function that every CI leg can assert.
1739#[derive(Debug, Clone, PartialEq, Eq)]
1740pub struct TaskPrincipal {
1741    user_id: String,
1742}
1743
1744impl TaskPrincipal {
1745    /// The account running this call, as `DOMAIN\user`.
1746    ///
1747    /// # Errors
1748    ///
1749    /// [`ServiceError::BinaryPath`] is not the right shape here, so this
1750    /// reports [`ServiceError::Control`] naming what the environment failed to
1751    /// say.
1752    pub fn current() -> Result<Self, ServiceError> {
1753        let user = std::env::var("USERNAME")
1754            .ok()
1755            .filter(|value| !value.trim().is_empty());
1756        let Some(user) = user else {
1757            return Err(ServiceError::Control {
1758                operation: "identify the account for",
1759                name: SERVICE_NAME.to_string(),
1760                manager: "Windows Task Scheduler",
1761                detail: "this session reports no %USERNAME%, so there is no principal to \
1762                         register a logon-triggered task for"
1763                    .to_string(),
1764            });
1765        };
1766        let domain = std::env::var("USERDOMAIN")
1767            .ok()
1768            .filter(|value| !value.trim().is_empty());
1769        Ok(Self {
1770            user_id: match domain {
1771                Some(domain) => format!("{domain}\\{user}"),
1772                None => user,
1773            },
1774        })
1775    }
1776
1777    /// A named principal, for tests and for a caller that already knows.
1778    #[must_use]
1779    pub fn named(user_id: impl Into<String>) -> Self {
1780        Self {
1781            user_id: user_id.into(),
1782        }
1783    }
1784
1785    /// `DOMAIN\user`.
1786    #[must_use]
1787    pub fn user_id(&self) -> &str {
1788        &self.user_id
1789    }
1790}
1791
1792/// Renders the Task Scheduler document for a `--start-at login` registration.
1793///
1794/// `RunLevel` is `LeastPrivilege`, which is the whole of Windows' answer to
1795/// item 2 in this domain: the task runs with the operator's ordinary filtered
1796/// token and never with an elevated one, whatever the operator's group
1797/// membership.
1798#[must_use]
1799pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
1800    let identity = plan.identity();
1801    let user = xml_escape(principal.user_id());
1802    let arguments = plan
1803        .arguments()
1804        .iter()
1805        .map(|argument| quote_argument(&argument.to_string_lossy()))
1806        .collect::<Vec<_>>()
1807        .join(" ");
1808    let mut out = String::new();
1809    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
1810    out.push_str(
1811        "<Task version=\"1.4\" \
1812         xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
1813    );
1814    out.push_str("  <RegistrationInfo>\n");
1815    out.push_str(&format!(
1816        "    <Description>{}</Description>\n",
1817        xml_escape(identity.description())
1818    ));
1819    out.push_str(&format!(
1820        "    <URI>\\{}</URI>\n",
1821        xml_escape(identity.name())
1822    ));
1823    out.push_str("  </RegistrationInfo>\n");
1824
1825    out.push_str("  <Triggers>\n    <LogonTrigger>\n");
1826    out.push_str("      <Enabled>true</Enabled>\n");
1827    out.push_str(&format!("      <UserId>{user}</UserId>\n"));
1828    out.push_str("    </LogonTrigger>\n  </Triggers>\n");
1829
1830    out.push_str("  <Principals>\n    <Principal id=\"Author\">\n");
1831    out.push_str(&format!("      <UserId>{user}</UserId>\n"));
1832    out.push_str("      <LogonType>InteractiveToken</LogonType>\n");
1833    out.push_str("      <RunLevel>LeastPrivilege</RunLevel>\n");
1834    out.push_str("    </Principal>\n  </Principals>\n");
1835
1836    out.push_str("  <Settings>\n");
1837    // One agent per host is `d1`'s lock; saying so here means Task Scheduler
1838    // refuses the second start rather than starting a process that then loses
1839    // the race and exits.
1840    out.push_str("    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
1841    out.push_str("    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
1842    out.push_str("    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
1843    out.push_str("    <AllowHardTerminate>true</AllowHardTerminate>\n");
1844    out.push_str("    <StartWhenAvailable>true</StartWhenAvailable>\n");
1845    out.push_str("    <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
1846    out.push_str("    <IdleSettings>\n");
1847    out.push_str("      <StopOnIdleEnd>false</StopOnIdleEnd>\n");
1848    out.push_str("      <RestartOnIdle>false</RestartOnIdle>\n");
1849    out.push_str("    </IdleSettings>\n");
1850    out.push_str("    <AllowStartOnDemand>true</AllowStartOnDemand>\n");
1851    out.push_str("    <Enabled>true</Enabled>\n");
1852    out.push_str("    <Hidden>false</Hidden>\n");
1853    out.push_str("    <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
1854    out.push_str("    <WakeToRun>false</WakeToRun>\n");
1855    // A daemon has no natural end, so any limit here would be a scheduled kill.
1856    out.push_str("    <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
1857    out.push_str("    <Priority>7</Priority>\n");
1858    out.push_str("    <RestartOnFailure>\n");
1859    out.push_str(&format!(
1860        "      <Interval>{}</Interval>\n",
1861        iso8601_minutes(
1862            plan.restart()
1863                .effective_delay(DefinitionKind::WindowsScheduledTask)
1864        )
1865    ));
1866    out.push_str(&format!("      <Count>{START_LIMIT_BURST}</Count>\n"));
1867    out.push_str("    </RestartOnFailure>\n");
1868    out.push_str("  </Settings>\n");
1869
1870    out.push_str("  <Actions Context=\"Author\">\n    <Exec>\n");
1871    out.push_str(&format!(
1872        "      <Command>{}</Command>\n",
1873        xml_escape(&plan.binary().to_string_lossy())
1874    ));
1875    if !arguments.is_empty() {
1876        out.push_str(&format!(
1877            "      <Arguments>{}</Arguments>\n",
1878            xml_escape(&arguments)
1879        ));
1880    }
1881    out.push_str(&format!(
1882        "      <WorkingDirectory>{}</WorkingDirectory>\n",
1883        xml_escape(&plan.directories().state.to_string_lossy())
1884    ));
1885    out.push_str("    </Exec>\n  </Actions>\n");
1886    out.push_str("</Task>\n");
1887    out
1888}
1889
1890/// `PT<n>M`, which is the only shape Task Scheduler accepts for a restart
1891/// interval. See [`RestartPolicy::effective_delay`].
1892fn iso8601_minutes(duration: Duration) -> String {
1893    format!("PT{}M", duration.as_secs() / 60)
1894}
1895
1896/// Escapes the five XML entities. Applied to every value that reaches a plist
1897/// or a task document, because a Windows account name may legitimately contain
1898/// `&` and a path may contain `<`.
1899///
1900/// `pub(crate)` rather than private because [`crate::wsl::task`] renders a
1901/// second kind of Task Scheduler document and must escape it the same way; two
1902/// escapers in one crate is exactly how one of them ends up subtly different.
1903pub(crate) fn xml_escape(value: &str) -> String {
1904    let mut out = String::with_capacity(value.len());
1905    for c in value.chars() {
1906        match c {
1907            '&' => out.push_str("&amp;"),
1908            '<' => out.push_str("&lt;"),
1909            '>' => out.push_str("&gt;"),
1910            '"' => out.push_str("&quot;"),
1911            '\'' => out.push_str("&apos;"),
1912            other => out.push(other),
1913        }
1914    }
1915    out
1916}
1917
1918/// The inverse of [`xml_escape`], for reading a definition back off disk.
1919///
1920/// `&amp;` is replaced last, which is the whole subtlety: replacing it first
1921/// would turn a literal `&amp;amp;` into `&` in two passes instead of `&amp;`
1922/// in one.
1923fn xml_unescape(value: &str) -> String {
1924    value
1925        .replace("&lt;", "<")
1926        .replace("&gt;", ">")
1927        .replace("&quot;", "\"")
1928        .replace("&apos;", "'")
1929        .replace("&amp;", "&")
1930}
1931
1932// -- Windows Service Control Manager -----------------------------------------
1933
1934/// How `CreateServiceW` should be called, in a form that can be reviewed,
1935/// asserted, and printed on any platform.
1936///
1937/// The Windows backend builds its `ServiceInfo` from **this** value rather than
1938/// from the plan, so the descriptor a Linux CI leg asserts and the parameters a
1939/// Windows host registers cannot drift apart.
1940#[derive(Debug, Clone, PartialEq, Eq)]
1941pub struct WindowsServiceSpec {
1942    /// The service name.
1943    pub name: String,
1944    /// What `services.msc` shows.
1945    pub display_name: String,
1946    /// The one-line description.
1947    pub description: String,
1948    /// `true` for `SERVICE_AUTO_START`, `false` for `SERVICE_DEMAND_START`.
1949    pub automatic_start: bool,
1950    /// The account, or `None` for `LocalSystem`, which is what
1951    /// `CreateServiceW` takes a null `lpServiceStartName` to mean.
1952    pub account: Option<String>,
1953    /// The full launch command, quoted.
1954    pub command_line: String,
1955    /// The restart-on-failure policy.
1956    pub restart: RestartPolicy,
1957}
1958
1959/// Derives the Windows service parameters from a plan.
1960#[must_use]
1961pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
1962    WindowsServiceSpec {
1963        name: plan.identity().name().to_string(),
1964        display_name: plan.identity().display_name().to_string(),
1965        description: plan.identity().description().to_string(),
1966        // Only a boot-mode registration is a service at all; a login-mode one
1967        // is a scheduled task. So an automatic start is the only kind here, and
1968        // the field exists because the privileged tests register an on-demand
1969        // fixture rather than one that starts with the test machine.
1970        automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
1971        account: match ServiceAccount::for_definition(
1972            DefinitionKind::WindowsService,
1973            plan.start_mode(),
1974        ) {
1975            // `None` means LocalSystem to `CreateServiceW`, and naming it
1976            // explicitly would be one more string to spell right.
1977            ServiceAccount::LocalSystem => None,
1978            other => Some(other.as_str().to_string()),
1979        },
1980        command_line: plan.command_line(),
1981        restart: plan.restart(),
1982    }
1983}
1984
1985/// Renders the descriptor [`review_least_privilege`] reads and `service status`
1986/// can print.
1987#[must_use]
1988fn windows_service_descriptor(plan: &InstallPlan) -> String {
1989    let spec = windows_service_spec(plan);
1990    let mut out = String::new();
1991    out.push_str("[windows-service]\n");
1992    out.push_str(&format!("Name={}\n", spec.name));
1993    out.push_str(&format!("DisplayName={}\n", spec.display_name));
1994    out.push_str(&format!("Description={}\n", spec.description));
1995    // OWN_PROCESS and never INTERACTIVE_PROCESS: an interactive service would
1996    // put a process this product controls on the operator's desktop, which is
1997    // both deprecated by Windows and more than the requirement asks for.
1998    out.push_str("ServiceType=OWN_PROCESS\n");
1999    out.push_str(&format!(
2000        "StartType={}\n",
2001        if spec.automatic_start {
2002            "AutoStart"
2003        } else {
2004            "OnDemand"
2005        }
2006    ));
2007    out.push_str("ErrorControl=Normal\n");
2008    out.push_str(&format!(
2009        "Account={}\n",
2010        spec.account
2011            .as_deref()
2012            .unwrap_or(ServiceAccount::LocalSystem.as_str())
2013    ));
2014    out.push_str(&format!("CommandLine={}\n", spec.command_line));
2015    out.push_str(&format!(
2016        "FailureActionRestartDelaySecs={}\n",
2017        spec.restart.delay().as_secs()
2018    ));
2019    out.push_str(&format!(
2020        "FailureActionsResetPeriodSecs={}\n",
2021        spec.restart.reset_after().as_secs()
2022    ));
2023    // Without this flag the Service Control Manager applies failure actions
2024    // only to a crash, and a daemon that exits non-zero after failing to reach
2025    // GitHub is not a crash.
2026    out.push_str("FailureActionsOnNonCrashFailures=true\n");
2027    out.push_str(&format!(
2028        "ReadWritePaths={}\n",
2029        plan.directories()
2030            .all()
2031            .iter()
2032            .map(|path| quote_argument(&path.to_string_lossy()))
2033            .collect::<Vec<_>>()
2034            .join(" ")
2035    ));
2036    out
2037}
2038
2039// ---------------------------------------------------------------------------
2040// The least-privilege review
2041// ---------------------------------------------------------------------------
2042
2043/// Whether a finding is about too much authority or too little.
2044#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2045pub enum FindingKind {
2046    /// The definition grants more than `05-infrastructure.md` item 2 asks for.
2047    /// This is what makes a review fail.
2048    Excess,
2049    /// The definition does not grant something the daemon needs, or does not
2050    /// state something the review cannot verify without.
2051    Shortfall,
2052}
2053
2054impl fmt::Display for FindingKind {
2055    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2056        f.write_str(match self {
2057            Self::Excess => "excess",
2058            Self::Shortfall => "shortfall",
2059        })
2060    }
2061}
2062
2063/// One thing the review has to say about a definition.
2064#[derive(Debug, Clone, PartialEq, Eq)]
2065pub struct PrivilegeFinding {
2066    /// Excess or shortfall.
2067    pub kind: FindingKind,
2068    /// The directive, key, or element it is about.
2069    pub subject: String,
2070    /// What is wrong with it, in terms an operator can act on.
2071    pub detail: String,
2072}
2073
2074impl fmt::Display for PrivilegeFinding {
2075    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2076        write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2077    }
2078}
2079
2080/// What a definition actually grants, measured against the requirement.
2081///
2082/// `05-infrastructure.md` item 2 and `07-security.md`'s release gate
2083/// (*"Service account permissions are documented and verified least
2084/// privilege"*) are the same requirement stated twice, and this is the
2085/// verification half. The documentation half is `docs/service-account.md`.
2086///
2087/// The review reads the **rendered text**, not the plan it came from. That is
2088/// deliberate and it is the only version of this check worth having: a review
2089/// that re-derived its expectations from the same values the renderer used
2090/// would agree with the renderer by construction and could never fail. Reading
2091/// the text means a hand-edited unit on a real host is reviewed as it is, and
2092/// means a test can widen a definition and watch this say so.
2093#[derive(Debug, Clone, PartialEq, Eq)]
2094pub struct PrivilegeReview {
2095    kind: DefinitionKind,
2096    account: ServiceAccount,
2097    controls: Vec<String>,
2098    findings: Vec<PrivilegeFinding>,
2099}
2100
2101impl PrivilegeReview {
2102    /// Whether the definition grants nothing beyond the requirement.
2103    #[must_use]
2104    pub fn is_least_privilege(&self) -> bool {
2105        !self
2106            .findings
2107            .iter()
2108            .any(|finding| finding.kind == FindingKind::Excess)
2109    }
2110
2111    /// Everything the review found, excesses and shortfalls together.
2112    #[must_use]
2113    pub fn findings(&self) -> &[PrivilegeFinding] {
2114        &self.findings
2115    }
2116
2117    /// Only the excesses — the findings that make [`Self::is_least_privilege`]
2118    /// false.
2119    #[must_use]
2120    pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2121        self.findings
2122            .iter()
2123            .filter(|finding| finding.kind == FindingKind::Excess)
2124            .collect()
2125    }
2126
2127    /// The controls the definition was confirmed to carry.
2128    ///
2129    /// Present so that a passing review says *what it checked* rather than only
2130    /// that it passed. A check that reports nothing when it succeeds is
2131    /// indistinguishable from a check that did not run.
2132    #[must_use]
2133    pub fn controls(&self) -> &[String] {
2134        &self.controls
2135    }
2136
2137    /// The account the registration runs under, and why it is the minimum.
2138    #[must_use]
2139    pub const fn account(&self) -> &ServiceAccount {
2140        &self.account
2141    }
2142
2143    /// Which platform's definition was reviewed.
2144    #[must_use]
2145    pub const fn kind(&self) -> DefinitionKind {
2146        self.kind
2147    }
2148}
2149
2150impl fmt::Display for PrivilegeReview {
2151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2152        writeln!(
2153            f,
2154            "{} runs as {} ({})",
2155            self.kind,
2156            self.account,
2157            self.account.justification()
2158        )?;
2159        for control in &self.controls {
2160            writeln!(f, "  confirmed  {control}")?;
2161        }
2162        for finding in &self.findings {
2163            writeln!(f, "  {finding}")?;
2164        }
2165        if self.is_least_privilege() {
2166            write!(f, "  verdict    least privilege")
2167        } else {
2168            write!(
2169                f,
2170                "  verdict    NOT least privilege: {} excess(es)",
2171                self.excesses().len()
2172            )
2173        }
2174    }
2175}
2176
2177/// Reads a definition back and reports what it grants.
2178///
2179/// `plan` supplies only the two things the text cannot: the four directories
2180/// the registration is *allowed* to write, and the start mode, which decides
2181/// which account is the minimum.
2182#[must_use]
2183pub fn review_least_privilege(
2184    definition: &ServiceDefinition,
2185    plan: &InstallPlan,
2186) -> PrivilegeReview {
2187    let mut controls = Vec::new();
2188    let mut findings = Vec::new();
2189    match definition.kind() {
2190        DefinitionKind::SystemdUnit => {
2191            review_systemd(definition.text(), plan, &mut controls, &mut findings);
2192        }
2193        DefinitionKind::LaunchdPlist => {
2194            review_launchd(definition.text(), plan, &mut controls, &mut findings);
2195        }
2196        DefinitionKind::WindowsScheduledTask => {
2197            review_scheduled_task(definition.text(), &mut controls, &mut findings);
2198        }
2199        DefinitionKind::WindowsService => {
2200            review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2201        }
2202    }
2203    PrivilegeReview {
2204        kind: definition.kind(),
2205        // The definition's account, not the plan's: a Linux CI leg reviewing the
2206        // Windows descriptor must report `LocalSystem`, which is the account
2207        // that descriptor registers, and not `root`, which is the account this
2208        // leg's own host would use.
2209        account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2210        controls,
2211        findings,
2212    }
2213}
2214
2215/// Every writable path a definition may name, as the strings it names them by.
2216fn permitted_paths(kind: DefinitionKind, plan: &InstallPlan) -> Vec<String> {
2217    let mut permitted = plan
2218        .directories()
2219        .all()
2220        .iter()
2221        .map(|path| path.to_string_lossy().into_owned())
2222        .collect::<Vec<_>>();
2223    if kind == DefinitionKind::SystemdUnit
2224        && let Some(secret_directory) = plan.secret_guard().and_then(Path::parent)
2225    {
2226        permitted.push(secret_directory.to_string_lossy().into_owned());
2227    }
2228    permitted
2229}
2230
2231/// Whether two path spellings name the same place on **this host's**
2232/// filesystem. Case-insensitive on Windows, exact elsewhere.
2233///
2234/// For a rendered definition use [`same_path_for`] instead: which comparison is
2235/// right there follows the platform the definition is *for*, and every leg of
2236/// the CI matrix reviews all four.
2237fn same_path_text(left: &str, right: &str) -> bool {
2238    if cfg!(windows) {
2239        left.eq_ignore_ascii_case(right)
2240    } else {
2241        left == right
2242    }
2243}
2244
2245/// Whether two path spellings name the same place on the platform a given
2246/// definition targets.
2247fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
2248    match kind {
2249        DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
2250            left.eq_ignore_ascii_case(right)
2251        }
2252        DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
2253    }
2254}
2255
2256/// Checks a `ReadWritePaths`-style list against the permitted directories.
2257fn review_writable_paths(
2258    kind: DefinitionKind,
2259    subject: &str,
2260    listed: &[String],
2261    plan: &InstallPlan,
2262    controls: &mut Vec<String>,
2263    findings: &mut Vec<PrivilegeFinding>,
2264) {
2265    let permitted = permitted_paths(kind, plan);
2266    for entry in listed {
2267        if !permitted
2268            .iter()
2269            .any(|allowed| same_path_for(kind, allowed, entry))
2270        {
2271            findings.push(PrivilegeFinding {
2272                kind: FindingKind::Excess,
2273                subject: subject.to_string(),
2274                detail: format!(
2275                    "{entry} is writable but is not one of this registration's required paths"
2276                ),
2277            });
2278        }
2279    }
2280    for allowed in &permitted {
2281        if !listed
2282            .iter()
2283            .any(|entry| same_path_for(kind, allowed, entry))
2284        {
2285            findings.push(PrivilegeFinding {
2286                kind: FindingKind::Shortfall,
2287                subject: subject.to_string(),
2288                detail: format!(
2289                    "{allowed} is one of this registration's directories but is not writable, \
2290                     so the daemon cannot use it"
2291                ),
2292            });
2293        }
2294    }
2295    if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
2296        controls.push(format!(
2297            "{subject} names exactly the required application-data and credential paths"
2298        ));
2299    }
2300}
2301
2302/// The one inbound-surface rule, applied to whichever platform names it.
2303///
2304/// `07-security.md` handling rule 2: *"The product exposes no inbound HTTP,
2305/// socket, or RPC surface anywhere, in any command."* A definition that asks the
2306/// service manager to open a socket or publish a Mach service on the daemon's
2307/// behalf would create exactly that surface without a line of product code
2308/// changing, which is why it is checked here and not only in review.
2309fn review_inbound_surface(
2310    text: &str,
2311    markers: &[(&str, &str)],
2312    controls: &mut Vec<String>,
2313    findings: &mut Vec<PrivilegeFinding>,
2314) {
2315    let mut clean = true;
2316    for (marker, detail) in markers {
2317        if text.contains(marker) {
2318            clean = false;
2319            findings.push(PrivilegeFinding {
2320                kind: FindingKind::Excess,
2321                subject: (*marker).to_string(),
2322                detail: (*detail).to_string(),
2323            });
2324        }
2325    }
2326    if clean {
2327        controls.push(
2328            "no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
2329        );
2330    }
2331}
2332
2333fn review_systemd(
2334    text: &str,
2335    plan: &InstallPlan,
2336    controls: &mut Vec<String>,
2337    findings: &mut Vec<PrivilegeFinding>,
2338) {
2339    let directives = ini_directives(text, "Service");
2340    for expected in SYSTEMD_HARDENING {
2341        let (key, value) = expected
2342            .split_once('=')
2343            .expect("every hardening directive is written as key=value");
2344        match directives.get(key) {
2345            Some(actual) if actual == value => controls.push((*expected).to_string()),
2346            Some(actual) => findings.push(PrivilegeFinding {
2347                kind: FindingKind::Excess,
2348                subject: key.to_string(),
2349                detail: format!(
2350                    "is `{actual}`, not `{value}`, so the unit keeps authority the \
2351                                 requirement does not ask for"
2352                ),
2353            }),
2354            None => findings.push(PrivilegeFinding {
2355                kind: FindingKind::Excess,
2356                subject: key.to_string(),
2357                detail: format!(
2358                    "is absent, so the unit inherits systemd's default rather than `{value}`"
2359                ),
2360            }),
2361        }
2362    }
2363
2364    match directives.get("ReadWritePaths") {
2365        Some(value) => {
2366            let listed = split_quoted(value);
2367            review_writable_paths(
2368                DefinitionKind::SystemdUnit,
2369                "ReadWritePaths",
2370                &listed,
2371                plan,
2372                controls,
2373                findings,
2374            );
2375        }
2376        None => findings.push(PrivilegeFinding {
2377            kind: FindingKind::Shortfall,
2378            subject: "ReadWritePaths".to_string(),
2379            detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
2380                .to_string(),
2381        }),
2382    }
2383
2384    // `User=` on a system unit is an escalation only in the other direction, so
2385    // what is checked is the pair systemd actually treats as privileged.
2386    if directives.contains_key("PrivateUsers")
2387        && directives.get("PrivateUsers") == Some(&"no".to_string())
2388    {
2389        findings.push(PrivilegeFinding {
2390            kind: FindingKind::Excess,
2391            subject: "PrivateUsers".to_string(),
2392            detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
2393                .to_string(),
2394        });
2395    }
2396
2397    review_inbound_surface(
2398        text,
2399        &[
2400            (
2401                "ListenStream=",
2402                "asks systemd to open a listening socket for this service, which \
2403                 07-security.md rule 2 forbids the product to have",
2404            ),
2405            (
2406                "ListenDatagram=",
2407                "asks systemd to open a listening socket for this service, which \
2408                 07-security.md rule 2 forbids the product to have",
2409            ),
2410        ],
2411        controls,
2412        findings,
2413    );
2414}
2415
2416fn review_launchd(
2417    text: &str,
2418    plan: &InstallPlan,
2419    controls: &mut Vec<String>,
2420    findings: &mut Vec<PrivilegeFinding>,
2421) {
2422    // A scheduling class is not an authority, so no value here is an excess.
2423    // Anything but `Interactive` is a shortfall: launchd applies it to every
2424    // runner the daemon starts. See `LAUNCHD_PROCESS_TYPE`.
2425    match plist_string_value(text, "ProcessType") {
2426        Some(value) if value == LAUNCHD_PROCESS_TYPE => {
2427            controls.push(format!("ProcessType={LAUNCHD_PROCESS_TYPE}"));
2428        }
2429        found => findings.push(PrivilegeFinding {
2430            kind: FindingKind::Shortfall,
2431            subject: "ProcessType".to_string(),
2432            detail: format!(
2433                "is {}, and launchd applies it to every runner the daemon starts, so their jobs \
2434                 run below normal priority. The daemon rewrites it to `{LAUNCHD_PROCESS_TYPE}` \
2435                 when it next starts with no runner; `service status` says how to restart it",
2436                found.map_or_else(
2437                    || "absent (launchd's `Standard`)".to_string(),
2438                    |value| format!("`{value}`")
2439                )
2440            ),
2441        }),
2442    }
2443
2444    match plan.start_mode() {
2445        StartMode::Boot => {
2446            if plist_bool_value(text, "SessionCreate") == Some(true) {
2447                findings.push(PrivilegeFinding {
2448                    kind: FindingKind::Excess,
2449                    subject: "SessionCreate".to_string(),
2450                    detail: "asks launchd to create a security session for a job that runs \
2451                             outside every login session and has no use for one"
2452                        .to_string(),
2453                });
2454            } else {
2455                controls.push("SessionCreate is not requested".to_string());
2456            }
2457            match plist_string_value(text, "UserName").as_deref() {
2458                Some("root") => {
2459                    controls.push("UserName=root, stated rather than inherited".to_string())
2460                }
2461                Some(other) => findings.push(PrivilegeFinding {
2462                    kind: FindingKind::Shortfall,
2463                    subject: "UserName".to_string(),
2464                    detail: format!(
2465                        "is `{other}`, which cannot unlock the System Keychain: \
2466                         /var/db/SystemKey is root-only, so the daemon would start and then \
2467                         find no credential"
2468                    ),
2469                }),
2470                None => findings.push(PrivilegeFinding {
2471                    kind: FindingKind::Shortfall,
2472                    subject: "UserName".to_string(),
2473                    detail: "is absent, so the account is launchd's implicit default and this \
2474                             review cannot confirm it"
2475                        .to_string(),
2476                }),
2477            }
2478        }
2479        StartMode::Login => {
2480            if let Some(named) = plist_string_value(text, "UserName") {
2481                findings.push(PrivilegeFinding {
2482                    kind: FindingKind::Excess,
2483                    subject: "UserName".to_string(),
2484                    detail: format!(
2485                        "names `{named}` in a LaunchAgent, which already runs as the operator; \
2486                         naming an account here asks launchd for a switch a login-mode \
2487                         registration has no reason to want"
2488                    ),
2489                });
2490            } else {
2491                controls
2492                    .push("no UserName: the agent runs as the operator and no other".to_string());
2493            }
2494        }
2495    }
2496
2497    review_inbound_surface(
2498        text,
2499        &[
2500            (
2501                "<key>Sockets</key>",
2502                "asks launchd to open a socket for this job, which 07-security.md rule 2 \
2503                 forbids the product to have",
2504            ),
2505            (
2506                "<key>MachServices</key>",
2507                "publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
2508                 forbids the product to have",
2509            ),
2510        ],
2511        controls,
2512        findings,
2513    );
2514}
2515
2516fn review_scheduled_task(
2517    text: &str,
2518    controls: &mut Vec<String>,
2519    findings: &mut Vec<PrivilegeFinding>,
2520) {
2521    match xml_value(text, "RunLevel").as_deref() {
2522        Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
2523        Some(other) => findings.push(PrivilegeFinding {
2524            kind: FindingKind::Excess,
2525            subject: "RunLevel".to_string(),
2526            detail: format!(
2527                "is `{other}`, so the task runs with an elevated token whenever the operator is \
2528                 an administrator"
2529            ),
2530        }),
2531        None => findings.push(PrivilegeFinding {
2532            kind: FindingKind::Excess,
2533            subject: "RunLevel".to_string(),
2534            detail: "is absent, so Task Scheduler decides the token rather than the definition"
2535                .to_string(),
2536        }),
2537    }
2538
2539    match xml_value(text, "LogonType").as_deref() {
2540        Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
2541        Some(other) => findings.push(PrivilegeFinding {
2542            kind: FindingKind::Excess,
2543            subject: "LogonType".to_string(),
2544            detail: format!(
2545                "is `{other}`, which asks Windows to store or synthesise a credential for this \
2546                 task; an interactive token needs neither"
2547            ),
2548        }),
2549        None => findings.push(PrivilegeFinding {
2550            kind: FindingKind::Shortfall,
2551            subject: "LogonType".to_string(),
2552            detail: "is absent, so this review cannot confirm that no credential is stored"
2553                .to_string(),
2554        }),
2555    }
2556}
2557
2558fn review_windows_service(
2559    text: &str,
2560    plan: &InstallPlan,
2561    controls: &mut Vec<String>,
2562    findings: &mut Vec<PrivilegeFinding>,
2563) {
2564    let directives = ini_directives(text, "windows-service");
2565
2566    match directives.get("ServiceType").map(String::as_str) {
2567        Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
2568        Some(other) => findings.push(PrivilegeFinding {
2569            kind: FindingKind::Excess,
2570            subject: "ServiceType".to_string(),
2571            detail: format!(
2572                "is `{other}`; an interactive or shared-process service reaches further than a \
2573                 daemon that only talks to GitHub over HTTPS"
2574            ),
2575        }),
2576        None => findings.push(PrivilegeFinding {
2577            kind: FindingKind::Shortfall,
2578            subject: "ServiceType".to_string(),
2579            detail: "is absent, so this review cannot confirm the service is not interactive"
2580                .to_string(),
2581        }),
2582    }
2583
2584    // The account is not a free choice on Windows: `d2`'s DACL decides it. What
2585    // the review can check is that the definition names the one account that
2586    // DACL admits, and no broader one.
2587    match directives.get("Account").map(String::as_str) {
2588        Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
2589            controls.push(format!(
2590                "Account={account}: the only stock account the machine-scoped store's DACL \
2591                 (SY, BA, OW) admits"
2592            ));
2593        }
2594        Some(other) => findings.push(PrivilegeFinding {
2595            kind: FindingKind::Shortfall,
2596            subject: "Account".to_string(),
2597            detail: format!(
2598                "is `{other}`, which the machine-scoped store's DACL does not name, so the \
2599                 daemon would start and then find no credential. Widening that DACL is not this \
2600                 registration's to do: an ACE reaching `{other}` would also reach every other \
2601                 service running under it"
2602            ),
2603        }),
2604        None => findings.push(PrivilegeFinding {
2605            kind: FindingKind::Shortfall,
2606            subject: "Account".to_string(),
2607            detail: "is absent, so this review cannot confirm which account was registered"
2608                .to_string(),
2609        }),
2610    }
2611
2612    match directives.get("ReadWritePaths") {
2613        Some(value) => {
2614            let listed = split_quoted(value);
2615            review_writable_paths(
2616                DefinitionKind::WindowsService,
2617                "ReadWritePaths",
2618                &listed,
2619                plan,
2620                controls,
2621                findings,
2622            );
2623        }
2624        None => findings.push(PrivilegeFinding {
2625            kind: FindingKind::Shortfall,
2626            subject: "ReadWritePaths".to_string(),
2627            detail: "is absent, so the directories the service was installed against are not \
2628                     recorded"
2629                .to_string(),
2630        }),
2631    }
2632}
2633
2634// -- parsing helpers ---------------------------------------------------------
2635
2636/// Reads `key=value` lines out of one `[section]` of an INI-shaped document.
2637///
2638/// systemd's unit format and this module's Windows descriptor are both this
2639/// shape. A repeated key takes its last value, which is systemd's own rule for
2640/// every directive here.
2641fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
2642    let mut out = BTreeMap::new();
2643    let mut inside = false;
2644    for line in text.lines() {
2645        let line = line.trim();
2646        if line.starts_with('[') && line.ends_with(']') {
2647            inside = &line[1..line.len() - 1] == section;
2648            continue;
2649        }
2650        if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2651            continue;
2652        }
2653        if let Some((key, value)) = line.split_once('=') {
2654            out.insert(key.trim().to_string(), value.trim().to_string());
2655        }
2656    }
2657    out
2658}
2659
2660/// Splits a whitespace-separated list that may contain arguments quoted by
2661/// [`quote_argument`].
2662fn split_quoted(value: &str) -> Vec<String> {
2663    let mut out = Vec::new();
2664    let mut rest = value.trim();
2665    while !rest.is_empty() {
2666        if rest.starts_with('"') {
2667            // `executable_from_command_line` already implements Windows' quoting
2668            // rules; reuse it rather than write a second, subtly different one.
2669            if let Some(parsed) = executable_from_command_line(rest) {
2670                out.push(parsed.to_string_lossy().into_owned());
2671            }
2672            // Advance past the closing quote.
2673            let mut depth = 0usize;
2674            let mut end = rest.len();
2675            for (index, c) in rest.char_indices() {
2676                match c {
2677                    '\\' => depth += 1,
2678                    '"' => {
2679                        if depth.is_multiple_of(2) && index > 0 {
2680                            end = index + 1;
2681                            break;
2682                        }
2683                        depth = 0;
2684                    }
2685                    _ => depth = 0,
2686                }
2687            }
2688            rest = rest[end..].trim_start();
2689        } else {
2690            let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
2691            out.push(rest[..end].to_string());
2692            rest = rest[end..].trim_start();
2693        }
2694    }
2695    out
2696}
2697
2698/// The text between the **first** `<tag>` and its `</tag>`, unescaped, for the
2699/// small, known documents this module renders. Not a general XML parser and
2700/// does not pretend to be one.
2701///
2702/// "First" matters for a document that repeats a tag: a Task Scheduler task has
2703/// an `<Enabled>` in its trigger *and* one in its settings, so a caller after
2704/// the second one passes the slice that starts at `<Settings>`.
2705///
2706/// `pub(crate)` rather than private because [`crate::wsl::task`] reads back a
2707/// second kind of Task Scheduler document; one reader for both is the same
2708/// argument [`xml_escape`] makes for one escaper.
2709pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
2710    let open = format!("<{tag}>");
2711    let close = format!("</{tag}>");
2712    let start = text.find(&open)? + open.len();
2713    let end = text[start..].find(&close)? + start;
2714    Some(xml_unescape(text[start..end].trim()))
2715}
2716
2717/// Whether Task Scheduler will launch a login task automatically.
2718///
2719/// Its `/Query /XML` output normalises away values that equal the schema
2720/// default. In particular, an enabled task commonly has no `<Enabled>` node at
2721/// all, so only an explicit `false` means disabled.
2722#[cfg(any(windows, test))]
2723fn windows_login_task_starts_automatically(document: &str) -> bool {
2724    document.contains("<LogonTrigger>")
2725        && xml_value(document, "Enabled").as_deref() != Some("false")
2726}
2727
2728/// The `<string>` that follows `<key>key</key>` in a property list.
2729fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
2730    let marker = format!("<key>{key}</key>");
2731    let start = text.find(&marker)? + marker.len();
2732    Some(text[start..].trim_start())
2733}
2734
2735pub fn plist_string_value(text: &str, key: &str) -> Option<String> {
2736    let rest = plist_value_after_key(text, key)?;
2737    if !rest.starts_with("<string>") {
2738        return None;
2739    }
2740    xml_value(rest, "string")
2741}
2742
2743fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
2744    let rest = plist_value_after_key(text, key)?;
2745    if rest.starts_with("<true/>") {
2746        Some(true)
2747    } else if rest.starts_with("<false/>") {
2748        Some(false)
2749    } else {
2750        None
2751    }
2752}
2753
2754// ---------------------------------------------------------------------------
2755// The install record
2756// ---------------------------------------------------------------------------
2757
2758/// The version of [`InstallRecord`] on disk.
2759///
2760/// Bumped when a record written by an older version can no longer be read.
2761/// `service status` reports an unreadable record as a problem with a remedy
2762/// rather than failing, because the remedy — uninstall and install again —
2763/// touches no configuration, no secret and no cache.
2764pub const RECORD_SCHEMA_VERSION: u32 = 1;
2765
2766/// What `service install` wrote down, and what `service status` reads back.
2767///
2768/// This is the *record* half of `05-infrastructure.md` item 6. The service
2769/// manager also knows the binary path, and the two are compared: a registration
2770/// whose command line no longer matches the record is a registration something
2771/// else has edited, and saying so is more useful than picking one of them to
2772/// believe.
2773///
2774/// It holds no credential. The nearest thing is the *location* of the secret
2775/// store, which `d2` publishes for `host show` to print, and even that is
2776/// derived rather than stored.
2777#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2778pub struct InstallRecord {
2779    /// [`RECORD_SCHEMA_VERSION`] at the time of writing.
2780    pub schema_version: u32,
2781    /// What the operating system calls the registration.
2782    pub service_name: String,
2783    /// Which service manager holds it.
2784    pub manager: String,
2785    /// Boot or login.
2786    pub start_mode: StartMode,
2787    /// The account it runs under.
2788    pub account: ServiceAccount,
2789    /// **The resolved absolute path of the binary at install time.** Item 6.
2790    pub binary: PathBuf,
2791    /// Where [`Self::binary`] was copied from, when it is a copy.
2792    ///
2793    /// `#[serde(default)]` rather than a schema bump: a record written before
2794    /// this existed is still readable, and reads as `None`. That is not a
2795    /// silent downgrade — `None` means the registration names a package
2796    /// manager's own file, which is the layout that cannot be upgraded while
2797    /// the service runs, and `service status` says so by name.
2798    #[serde(default)]
2799    pub source_binary: Option<PathBuf>,
2800    /// The arguments it was registered with.
2801    pub arguments: Vec<String>,
2802    /// The restart-on-failure delay.
2803    pub restart_delay_secs: u64,
2804    /// How long it must run before the failure count resets.
2805    pub restart_reset_secs: u64,
2806    /// The rotating diagnostic log's stem. Item 4.
2807    pub log_file: PathBuf,
2808    /// Whether the manager was asked not to start this by itself. Always
2809    /// `false` for a registration an operator made; see
2810    /// [`InstallRequest::started_on_demand`].
2811    ///
2812    /// `#[serde(default)]` so that a record missing the field is rejected by
2813    /// the schema check below, with its remedy, rather than by a parse error
2814    /// that names a field an operator has never heard of.
2815    #[serde(default)]
2816    pub starts_on_demand: bool,
2817    /// Where the platform's definition file was written, when there is one.
2818    pub definition_path: Option<PathBuf>,
2819    /// When the registration was made.
2820    pub installed_at: DateTime<Utc>,
2821    /// Which build of the product made it.
2822    pub installed_by_version: String,
2823    // A TOML table has to follow every scalar at its level, so this field is
2824    // last by necessity rather than by taste.
2825    /// The four directories the registration was installed against. Item 2's
2826    /// *"configured cache and runtime directories"*.
2827    pub directories: ServiceDirectories,
2828}
2829
2830impl InstallRecord {
2831    /// Where the record lives, given this host's directories.
2832    #[must_use]
2833    pub fn path(paths: &AppPaths) -> PathBuf {
2834        paths.config_dir().join(RECORD_FILE)
2835    }
2836
2837    /// Builds the record for a plan that has just been applied.
2838    #[must_use]
2839    pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
2840        Self {
2841            schema_version: RECORD_SCHEMA_VERSION,
2842            service_name: plan.identity().name().to_string(),
2843            manager: definition.kind().manager().to_string(),
2844            start_mode: plan.start_mode(),
2845            account: plan.account().clone(),
2846            binary: plan.binary().to_path_buf(),
2847            arguments: plan
2848                .arguments()
2849                .iter()
2850                .map(|argument| argument.to_string_lossy().into_owned())
2851                .collect(),
2852            restart_delay_secs: plan.restart().delay().as_secs(),
2853            restart_reset_secs: plan.restart().reset_after().as_secs(),
2854            starts_on_demand: plan.is_on_demand(),
2855            source_binary: plan.source_binary().map(Path::to_path_buf),
2856            log_file: plan.directories().log_file(),
2857            definition_path: definition.install_path().map(Path::to_path_buf),
2858            installed_at: at,
2859            installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
2860            directories: plan.directories().clone(),
2861        }
2862    }
2863
2864    /// The plan this record describes, as this build would register it.
2865    ///
2866    /// The directories, binary, arguments and restart policy are the recorded
2867    /// ones; everything else -- hardening, the secret store's guard -- is what
2868    /// this build renders. That difference is the point: it is how a
2869    /// registration written by an older build is compared against, and
2870    /// repaired to, the current definition.
2871    #[must_use]
2872    pub fn plan(&self, identity: ServiceIdentity) -> InstallPlan {
2873        let plan = InstallPlan::unchecked(
2874            identity,
2875            self.start_mode,
2876            self.binary.clone(),
2877            self.directories.clone(),
2878        )
2879        .with_arguments(self.arguments.clone())
2880        .with_restart(self.restart());
2881        let plan = if self.starts_on_demand {
2882            plan.started_on_demand()
2883        } else {
2884            plan
2885        };
2886        match crate::secrets::PlatformSecretStore::for_start_mode(self.start_mode) {
2887            Ok(store) => plan.with_secret_guard(store.guard()),
2888            Err(_) => plan,
2889        }
2890    }
2891
2892    /// The restart policy this record describes, or [`RestartPolicy::default`]
2893    /// when the recorded numbers are outside the supported range — which can
2894    /// only happen to a record something else has edited.
2895    #[must_use]
2896    pub fn restart(&self) -> RestartPolicy {
2897        RestartPolicy::new(
2898            Duration::from_secs(self.restart_delay_secs),
2899            Duration::from_secs(self.restart_reset_secs),
2900        )
2901        .unwrap_or_default()
2902    }
2903
2904    /// Reads the record, or reports that there is none.
2905    ///
2906    /// `Ok(None)` means no registration was made by this product on this host.
2907    ///
2908    /// # Errors
2909    ///
2910    /// [`ServiceError::Record`] when the file exists and cannot be read,
2911    /// [`ServiceError::RecordNotPermitted`] when it exists and this account may
2912    /// not read it, and [`ServiceError::RecordUnreadable`] when it is not a
2913    /// record this version understands.
2914    pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
2915        let path = Self::path(paths);
2916        let text = match std::fs::read_to_string(&path) {
2917            Ok(text) => text,
2918            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2919            // Told apart from every other read failure because `status` carries
2920            // on through this one alone. See `ServiceError::RecordNotPermitted`.
2921            Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
2922                return Err(ServiceError::RecordNotPermitted {
2923                    path,
2924                    detail: error.to_string(),
2925                });
2926            }
2927            Err(error) => {
2928                return Err(ServiceError::Record {
2929                    operation: "read",
2930                    path,
2931                    detail: error.to_string(),
2932                });
2933            }
2934        };
2935        let record: Self =
2936            toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
2937                path: path.clone(),
2938                detail: error.to_string(),
2939            })?;
2940        if record.schema_version != RECORD_SCHEMA_VERSION {
2941            return Err(ServiceError::RecordUnreadable {
2942                path,
2943                detail: format!(
2944                    "it declares schema version {} and this build reads version {}",
2945                    record.schema_version, RECORD_SCHEMA_VERSION
2946                ),
2947            });
2948        }
2949        Ok(Some(record))
2950    }
2951
2952    /// Writes the record, replacing whatever was there.
2953    ///
2954    /// # Errors
2955    ///
2956    /// [`ServiceError::Record`].
2957    pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
2958        use std::io::Write as _;
2959
2960        let path = Self::path(paths);
2961        let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
2962            operation: "encode",
2963            path: path.clone(),
2964            detail: error.to_string(),
2965        })?;
2966        if let Some(parent) = path.parent() {
2967            std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
2968                operation: "write",
2969                path: path.clone(),
2970                detail: error.to_string(),
2971            })?;
2972        }
2973        let parent = path.parent().ok_or_else(|| ServiceError::Record {
2974            operation: "write",
2975            path: path.clone(),
2976            detail: "the record path has no parent directory".to_string(),
2977        })?;
2978        let mut temporary =
2979            tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
2980                operation: "write",
2981                path: path.clone(),
2982                detail: error.to_string(),
2983            })?;
2984        temporary
2985            .write_all(text.as_bytes())
2986            .and_then(|()| temporary.as_file().sync_all())
2987            .map_err(|error| ServiceError::Record {
2988                operation: "write",
2989                path: path.clone(),
2990                detail: error.to_string(),
2991            })?;
2992        // ------------------------------------------------------------------
2993        // THE RECORD IS READABLE BY THE ACCOUNT WHOSE DIRECTORY IT IS IN.
2994        // ------------------------------------------------------------------
2995        // `NamedTempFile` creates at `0600`, which is right for a temporary
2996        // file and wrong for this one. A boot-mode `service install` runs under
2997        // `sudo`, so `0600` made the record `root`-owned and unreadable by the
2998        // operator — and `service status`, which asks no privilege of anybody,
2999        // failed on their own host with `Permission denied` and a remedy that
3000        // named the command that had just failed.
3001        //
3002        // `0644` does not widen anything: `RECORD_FILE`'s own documentation is
3003        // that this is *"non-secret TOML"* holding no credential, and
3004        // `AppPaths::create_all` puts it in a `0700` directory, so no other
3005        // local account can reach it whatever its own mode says. It is the same
3006        // arrangement the rotating diagnostics already have.
3007        #[cfg(unix)]
3008        {
3009            use std::os::unix::fs::PermissionsExt as _;
3010
3011            temporary
3012                .as_file()
3013                .set_permissions(std::fs::Permissions::from_mode(0o644))
3014                .map_err(|error| ServiceError::Record {
3015                    operation: "write",
3016                    path: path.clone(),
3017                    detail: error.to_string(),
3018                })?;
3019        }
3020        temporary
3021            .persist(&path)
3022            .map(|_| ())
3023            .map_err(|error| ServiceError::Record {
3024                operation: "write",
3025                path,
3026                detail: error.error.to_string(),
3027            })
3028    }
3029
3030    /// Removes the record. Returns whether there was one.
3031    ///
3032    /// # Errors
3033    ///
3034    /// [`ServiceError::Record`].
3035    pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
3036        let path = Self::path(paths);
3037        match std::fs::remove_file(&path) {
3038            Ok(()) => Ok(true),
3039            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
3040            Err(error) => Err(ServiceError::Record {
3041                operation: "remove",
3042                path,
3043                detail: error.to_string(),
3044            }),
3045        }
3046    }
3047}
3048
3049// ---------------------------------------------------------------------------
3050// The last successful GitHub contact
3051// ---------------------------------------------------------------------------
3052
3053/// The heartbeat file's own schema version.
3054const CONTACT_SCHEMA_VERSION: u32 = 1;
3055
3056#[derive(Debug, Clone, Serialize, Deserialize)]
3057struct ContactRecord {
3058    schema_version: u32,
3059    last_success: DateTime<Utc>,
3060}
3061
3062/// Records that GitHub was reached successfully, for `service status` to
3063/// report.
3064///
3065/// # The contract, for `f3` and for the agent
3066///
3067/// Journey 5 step 4 requires `service status` to report *"the last successful
3068/// GitHub contact"*, and `service status` runs in the operator's terminal while
3069/// the daemon runs in a service. They share no memory, so the fact has to be on
3070/// disk, and this is the file. **The daemon calls this after each successful
3071/// GitHub call; nothing else writes it.**
3072///
3073/// It is a single timestamp and deliberately not a log: an operator asking this
3074/// question wants to know whether the agent is alive and reaching GitHub *now*,
3075/// and a value that is minutes old answers it. Writing it is a whole-file
3076/// replace through a temporary in the same directory, so a status command can
3077/// never read a half-written timestamp.
3078///
3079/// # Errors
3080///
3081/// [`ServiceError::Record`] when `state/` cannot be written.
3082pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3083    write_state_record(
3084        &contact_path(paths),
3085        &ContactRecord {
3086            schema_version: CONTACT_SCHEMA_VERSION,
3087            last_success: at,
3088        },
3089    )
3090}
3091
3092/// Reads the last successful GitHub contact, or reports that none was recorded.
3093///
3094/// `Ok(None)` is the honest answer on a host whose agent has never run, and it
3095/// is what `service status` prints rather than a zero timestamp.
3096///
3097/// # Errors
3098///
3099/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3100/// A malformed heartbeat is reported rather than treated as absence: absence
3101/// means *"the agent has never reached GitHub"*, and reporting a parse failure
3102/// as that would be a wrong answer to the question Journey 5 asks.
3103pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3104    Ok(read_state_record::<ContactRecord>(&contact_path(paths))?.map(|record| record.last_success))
3105}
3106
3107/// Where the heartbeat lives.
3108#[must_use]
3109pub fn contact_path(paths: &AppPaths) -> PathBuf {
3110    paths.state_dir().join(CONTACT_FILE)
3111}
3112
3113/// Writes a small TOML record under `state/`, through a temporary in the same
3114/// directory and a rename, so a status command never reads half of one.
3115pub(crate) fn write_state_record(path: &Path, record: &impl Serialize) -> Result<(), ServiceError> {
3116    let failed = |detail: String| ServiceError::Record {
3117        operation: "write",
3118        path: path.to_path_buf(),
3119        detail,
3120    };
3121    let text = toml::to_string_pretty(record).map_err(|error| failed(error.to_string()))?;
3122    let directory = path.parent().unwrap_or_else(|| Path::new("."));
3123    std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3124    let temporary = path.with_extension("toml.new");
3125    std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3126    std::fs::rename(&temporary, path).map_err(|error| failed(error.to_string()))
3127}
3128
3129/// Reads a record [`write_state_record`] wrote. `Ok(None)` when there is none;
3130/// one that exists and does not parse is an error, never absence.
3131pub(crate) fn read_state_record<T: serde::de::DeserializeOwned>(
3132    path: &Path,
3133) -> Result<Option<T>, ServiceError> {
3134    let failed = |detail: String| ServiceError::Record {
3135        operation: "read",
3136        path: path.to_path_buf(),
3137        detail,
3138    };
3139    let text = match std::fs::read_to_string(path) {
3140        Ok(text) => text,
3141        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3142        Err(error) => return Err(failed(error.to_string())),
3143    };
3144    toml::from_str(&text)
3145        .map(Some)
3146        .map_err(|error| failed(error.to_string()))
3147}
3148
3149/// Removes a record; one that is not there is already removed.
3150pub(crate) fn remove_state_record(path: &Path) -> Result<(), ServiceError> {
3151    match std::fs::remove_file(path) {
3152        Ok(()) => Ok(()),
3153        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3154        Err(error) => Err(ServiceError::Record {
3155            operation: "remove",
3156            path: path.to_path_buf(),
3157            detail: error.to_string(),
3158        }),
3159    }
3160}
3161
3162// ---------------------------------------------------------------------------
3163// The credential GitHub rejected
3164// ---------------------------------------------------------------------------
3165
3166#[derive(Debug, Serialize, Deserialize)]
3167#[serde(deny_unknown_fields)]
3168struct CredentialRejectionRecord {
3169    schema_version: u32,
3170    rejected_since: DateTime<Utc>,
3171}
3172
3173/// Records that GitHub rejected the daemon's credential, keeping the first
3174/// moment of an ongoing rejection.
3175///
3176/// # Why a file of its own
3177///
3178/// A rejected credential used to be visible only in the daemon's log: the
3179/// contact heartbeat simply stopped moving, `service status` still printed
3180/// `healthy`, and the TUI -- which authenticates with its own, perhaps freshly
3181/// renewed, copy -- showed nothing at all while the service started no runner
3182/// for hours. Separate from [`CONTACT_FILE`] so that an older build reading
3183/// that file is not handed a field it refuses.
3184///
3185/// # Errors
3186///
3187/// [`ServiceError::Record`] when `state/` cannot be written.
3188pub fn record_github_credential_rejected(
3189    paths: &AppPaths,
3190    at: DateTime<Utc>,
3191) -> Result<(), ServiceError> {
3192    if github_credential_rejected_since(paths)
3193        .ok()
3194        .flatten()
3195        .is_some()
3196    {
3197        return Ok(());
3198    }
3199    write_state_record(
3200        &credential_rejection_path(paths),
3201        &CredentialRejectionRecord {
3202            schema_version: CONTACT_SCHEMA_VERSION,
3203            rejected_since: at,
3204        },
3205    )
3206}
3207
3208/// Clears a recorded rejection once GitHub accepts the credential again.
3209///
3210/// # Errors
3211///
3212/// [`ServiceError::Record`] when the record exists and cannot be removed.
3213pub fn clear_github_credential_rejection(paths: &AppPaths) -> Result<(), ServiceError> {
3214    remove_state_record(&credential_rejection_path(paths))
3215}
3216
3217/// Since when GitHub has been rejecting the daemon's credential, if it is.
3218///
3219/// # Errors
3220///
3221/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3222pub fn github_credential_rejected_since(
3223    paths: &AppPaths,
3224) -> Result<Option<DateTime<Utc>>, ServiceError> {
3225    Ok(
3226        read_state_record::<CredentialRejectionRecord>(&credential_rejection_path(paths))?
3227            .map(|record| record.rejected_since),
3228    )
3229}
3230
3231/// Where the rejection record lives.
3232#[must_use]
3233pub fn credential_rejection_path(paths: &AppPaths) -> PathBuf {
3234    paths.state_dir().join(CREDENTIAL_REJECTION_FILE)
3235}
3236
3237#[derive(Debug, Serialize, Deserialize)]
3238#[serde(deny_unknown_fields)]
3239struct CredentialUnreadableRecord {
3240    schema_version: u32,
3241    unreadable_since: DateTime<Utc>,
3242}
3243
3244/// Records that the daemon could not read its stored credential, keeping the
3245/// first moment of an ongoing failure.
3246///
3247/// # Why a file of its own
3248///
3249/// A daemon that cannot read its credential exits, and the service manager
3250/// starts it again a few seconds later, for ever. `service status` reads the
3251/// registration, which is fine, and the heartbeat, which merely stops moving --
3252/// so it said `healthy` while a macOS host that had just been upgraded was
3253/// locked out of its own keychain item and started no runner. The reason was
3254/// only in the daemon's log. The daemon is the one process whose view of the
3255/// store matters, and it shares no memory with `service status`, so it says
3256/// so on disk; [`clear_credential_unreadable`] removes it as soon as a read
3257/// succeeds.
3258///
3259/// # Errors
3260///
3261/// [`ServiceError::Record`] when `state/` cannot be written.
3262pub fn record_credential_unreadable(
3263    paths: &AppPaths,
3264    at: DateTime<Utc>,
3265) -> Result<(), ServiceError> {
3266    if credential_unreadable_since(paths).ok().flatten().is_some() {
3267        return Ok(());
3268    }
3269    write_state_record(
3270        &credential_unreadable_path(paths),
3271        &CredentialUnreadableRecord {
3272            schema_version: CONTACT_SCHEMA_VERSION,
3273            unreadable_since: at,
3274        },
3275    )
3276}
3277
3278/// Clears a recorded failure once the daemon reads its credential again.
3279///
3280/// # Errors
3281///
3282/// [`ServiceError::Record`] when the record exists and cannot be removed.
3283pub fn clear_credential_unreadable(paths: &AppPaths) -> Result<(), ServiceError> {
3284    remove_state_record(&credential_unreadable_path(paths))
3285}
3286
3287/// Since when the daemon has been unable to read its stored credential, if it
3288/// has.
3289///
3290/// # Errors
3291///
3292/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3293pub fn credential_unreadable_since(
3294    paths: &AppPaths,
3295) -> Result<Option<DateTime<Utc>>, ServiceError> {
3296    Ok(
3297        read_state_record::<CredentialUnreadableRecord>(&credential_unreadable_path(paths))?
3298            .map(|record| record.unreadable_since),
3299    )
3300}
3301
3302/// Where the unreadable-credential record lives.
3303#[must_use]
3304pub fn credential_unreadable_path(paths: &AppPaths) -> PathBuf {
3305    paths.state_dir().join(CREDENTIAL_UNREADABLE_FILE)
3306}
3307
3308/// How to sign this host in again for `mode`: the exact command, in
3309/// backticks, and where to run it when that matters.
3310///
3311/// A machine-scoped store needs privilege: `sudo` on Unix, an elevated
3312/// terminal on Windows. A login-mode store on macOS is the login keychain,
3313/// which is unlocked for the desktop session only, so an SSH session cannot
3314/// write it.
3315#[must_use]
3316pub fn sign_in_instruction(mode: StartMode) -> String {
3317    let (command, place) = match mode {
3318        StartMode::Boot if cfg!(unix) => ("sudo runner-manager auth login --start-at boot", ""),
3319        StartMode::Boot => (
3320            "runner-manager auth login --start-at boot",
3321            " from an elevated terminal",
3322        ),
3323        StartMode::Login => (
3324            "runner-manager auth login --start-at login",
3325            MACOS_LOGIN_PLACE,
3326        ),
3327    };
3328    format!("`{command}`{place}")
3329}
3330
3331/// Where a login-mode sign-in has to happen on macOS, as a phrase to follow
3332/// the command; nothing elsewhere.
3333pub const MACOS_LOGIN_PLACE: &str = if cfg!(target_os = "macos") {
3334    " in a Terminal in this Mac's desktop session (an SSH session cannot unlock the login \
3335     keychain)"
3336} else {
3337    ""
3338};
3339
3340// ---------------------------------------------------------------------------
3341// A definition written by an older build
3342// ---------------------------------------------------------------------------
3343
3344/// An installed service definition that differs from what this build renders
3345/// for the same recorded installation.
3346///
3347/// # Why this exists
3348///
3349/// An upgrade replaces the binary a registration runs and nothing else. A
3350/// systemd unit written by 0.4.7 still carried `LoadCredential=`, which hands
3351/// the daemon a copy of the credential frozen at service start, and lacked
3352/// write access to the credential store. Every release after the fix kept
3353/// running under that unit: the daemon could neither renew the token nor see
3354/// one renewed by anybody else, and stopped starting runners the moment the
3355/// frozen access token expired.
3356#[derive(Debug, Clone, PartialEq, Eq)]
3357pub struct DefinitionDrift {
3358    /// The definition file on disk.
3359    pub path: PathBuf,
3360    /// The build that wrote it, as its install record says.
3361    pub installed_by_version: String,
3362    /// What this build renders in its place.
3363    pub rendered: String,
3364    /// The start mode it was installed for.
3365    pub start_mode: StartMode,
3366    /// Which manager reads the definition.
3367    pub manager: DefinitionKind,
3368}
3369
3370/// Compares the installed definition against this build's rendering.
3371///
3372/// A systemd unit is compared byte for byte. A launchd plist is compared on
3373/// `ProcessType` alone (see [`launchd_definition_drift`]). `None` for every
3374/// other manager, for a definition that cannot be read, and for one that
3375/// already matches. The file is read through `controls`, so a double never
3376/// compares against a real host's definition.
3377fn definition_drift(
3378    controls: &dyn ControlFactory,
3379    identity: &ServiceIdentity,
3380    record: &InstallRecord,
3381) -> Option<DefinitionDrift> {
3382    let compare = match host_definition_kind(record.start_mode) {
3383        DefinitionKind::SystemdUnit => systemd_definition_drift,
3384        DefinitionKind::LaunchdPlist => launchd_definition_drift,
3385        DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => return None,
3386    };
3387    let path = record.definition_path.clone()?;
3388    let installed = controls.installed_definition(&path)?;
3389    compare(identity, record, path, &installed)
3390}
3391
3392/// A plist whose `ProcessType` is not [`LAUNCHD_PROCESS_TYPE`], with the plist
3393/// this build renders in its place.
3394///
3395/// Only that one key, unlike the systemd comparison. The daemon repairs this
3396/// drift by itself (`daemon.rs`, `converge_launchd_definition`), so a
3397/// whole-file comparison would make it rewrite and reload over every edit an
3398/// operator made to the plist. A plist that does carry the wrong value is
3399/// replaced whole, as `service install` would replace it.
3400fn launchd_definition_drift(
3401    identity: &ServiceIdentity,
3402    record: &InstallRecord,
3403    path: PathBuf,
3404    installed: &str,
3405) -> Option<DefinitionDrift> {
3406    (plist_string_value(installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE)).then(
3407        || DefinitionDrift {
3408            path,
3409            installed_by_version: record.installed_by_version.clone(),
3410            rendered: launchd_plist(&record.plan(identity.clone())),
3411            start_mode: record.start_mode,
3412            manager: DefinitionKind::LaunchdPlist,
3413        },
3414    )
3415}
3416
3417fn systemd_definition_drift(
3418    identity: &ServiceIdentity,
3419    record: &InstallRecord,
3420    path: PathBuf,
3421    installed: &str,
3422) -> Option<DefinitionDrift> {
3423    let rendered = systemd_unit(&record.plan(identity.clone()));
3424    (installed != rendered).then(|| DefinitionDrift {
3425        path,
3426        installed_by_version: record.installed_by_version.clone(),
3427        rendered,
3428        start_mode: record.start_mode,
3429        manager: DefinitionKind::SystemdUnit,
3430    })
3431}
3432
3433// ---------------------------------------------------------------------------
3434// The last runner-root refusal
3435// ---------------------------------------------------------------------------
3436
3437const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
3438
3439#[derive(Debug, Clone, Serialize, Deserialize)]
3440struct RootRefusalFile {
3441    schema_version: u32,
3442    /// Keyed by policy, because the fact is per-policy and not per-host.
3443    #[serde(default)]
3444    refusals: BTreeMap<String, RootRefusalEntry>,
3445}
3446
3447#[derive(Debug, Clone, Serialize, Deserialize)]
3448struct RootRefusalEntry {
3449    at: DateTime<Utc>,
3450    kind: String,
3451    root: String,
3452    detail: String,
3453}
3454
3455/// One policy's last runner-root refusal, as `service status` reports it.
3456#[derive(Debug, Clone, PartialEq, Eq)]
3457pub struct RunnerRootRefusal {
3458    /// The policy that placed no runner.
3459    pub policy: String,
3460    /// When it was last refused.
3461    pub at: DateTime<Utc>,
3462    /// The closed-vocabulary cause, as `RunnerRootError::kind` names it.
3463    pub kind: String,
3464    /// The root it was refused.
3465    pub root: String,
3466    /// The refusal in full, including the remediation. Not redacted: this file
3467    /// is read by a command in the operator's terminal, not by the log sink.
3468    pub detail: String,
3469}
3470
3471/// Records that one policy could not use its runner root.
3472///
3473/// # The contract, and why a file
3474///
3475/// A launch the runner root refuses fails before any attempt row exists, so the
3476/// journal cannot carry it and no screen can show it. The daemon's own log
3477/// cannot either: it redacts every field it does not allow-list and scrubs
3478/// anything path-shaped out of the ones it does, which is most of a
3479/// `RunnerRootError`. So the sentence an operator needs — the directory and the
3480/// remediation, verbatim — has nowhere to go, and the failure reads
3481/// `runner_start_failed reason=other` once per poll for as long as it lasts.
3482/// That is not hypothetical: it cost three hours.
3483///
3484/// **Keyed by policy, and that is load-bearing.** A host runs several policies
3485/// and they do not share a fate: `allocate_persistent_slot` tolerates an
3486/// unresolvable host default, so a repository with its own persistent root
3487/// places runners happily while an ephemeral policy on a withheld volume places
3488/// none. A single host-wide slot would have the working policy clear the broken
3489/// one's record on the same reconcile pass, and `service status` would report a
3490/// healthy host that had started zero runners for an entire target.
3491///
3492/// **The daemon writes this when a root refuses a launch and clears the policy's
3493/// entry when one succeeds; nothing else writes it.** It keeps the last refusal
3494/// per policy rather than a history, for the reason [`record_github_contact`] is
3495/// a single timestamp: the operator is asking whether runners can be placed
3496/// *now*.
3497///
3498/// Written whole through a temporary in the same directory, so a status command
3499/// cannot read half a record.
3500///
3501/// # Errors
3502///
3503/// [`ServiceError::Record`] when `state/` cannot be read or written.
3504pub fn record_runner_root_refusal(
3505    paths: &AppPaths,
3506    policy: &str,
3507    at: DateTime<Utc>,
3508    kind: &str,
3509    root: &str,
3510    detail: &str,
3511) -> Result<(), ServiceError> {
3512    let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
3513        schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
3514        refusals: BTreeMap::new(),
3515    });
3516    file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
3517    file.refusals.insert(
3518        policy.to_owned(),
3519        RootRefusalEntry {
3520            at,
3521            kind: kind.to_owned(),
3522            root: root.to_owned(),
3523            detail: detail.to_owned(),
3524        },
3525    );
3526    write_refusal_file(paths, &file)
3527}
3528
3529/// Clears one policy's entry, because that policy placed a runner.
3530///
3531/// Removes the file once the last entry goes, so a host that is working leaves
3532/// nothing behind for a later `service status` to report. A policy with no entry
3533/// is not an error: this is called on every successful placement, and almost
3534/// every one of those follows another success.
3535///
3536/// # Errors
3537///
3538/// [`ServiceError::Record`] when `state/` cannot be read or written.
3539pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
3540    let Some(mut file) = read_refusal_file(paths)? else {
3541        return Ok(());
3542    };
3543    if file.refusals.remove(policy).is_none() {
3544        return Ok(());
3545    }
3546    if file.refusals.is_empty() {
3547        let path = root_refusal_path(paths);
3548        return match std::fs::remove_file(&path) {
3549            Ok(()) => Ok(()),
3550            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3551            Err(error) => Err(ServiceError::Record {
3552                operation: "remove",
3553                path,
3554                detail: error.to_string(),
3555            }),
3556        };
3557    }
3558    write_refusal_file(paths, &file)
3559}
3560
3561/// Every policy whose runner root refused it and has not since succeeded.
3562///
3563/// Ordered by policy, so two runs of `service status` on an unchanged host
3564/// print the same thing in the same order.
3565///
3566/// # Errors
3567///
3568/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3569/// Reported rather than treated as absence, for the reason
3570/// [`last_github_contact`] gives: absence means "every policy is placing
3571/// runners", and answering a parse failure with that would be a wrong answer.
3572pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
3573    Ok(read_refusal_file(paths)?
3574        .map(|file| {
3575            file.refusals
3576                .into_iter()
3577                .map(|(policy, entry)| RunnerRootRefusal {
3578                    policy,
3579                    at: entry.at,
3580                    kind: entry.kind,
3581                    root: entry.root,
3582                    detail: entry.detail,
3583                })
3584                .collect()
3585        })
3586        .unwrap_or_default())
3587}
3588
3589fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
3590    read_state_record(&root_refusal_path(paths))
3591}
3592
3593fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
3594    write_state_record(&root_refusal_path(paths), file)
3595}
3596
3597/// Where the refusals live.
3598#[must_use]
3599pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
3600    paths.state_dir().join(ROOT_REFUSAL_FILE)
3601}
3602
3603// ---------------------------------------------------------------------------
3604// The recorded binary path
3605// ---------------------------------------------------------------------------
3606
3607/// What became of the absolute path `install` recorded.
3608///
3609/// `05-infrastructure.md` item 6 in one type. Three of the four variants are
3610/// errors, and `service status` reports them as errors rather than as health —
3611/// which is the whole point, because the npm case produces a service that looks
3612/// installed, is registered, and cannot start.
3613#[derive(Debug, Clone, PartialEq, Eq)]
3614pub enum BinaryPath {
3615    /// The recorded path is a file, and the service manager still starts it.
3616    Current {
3617        /// The path.
3618        path: PathBuf,
3619    },
3620    /// **Nothing is at the recorded path.**
3621    ///
3622    /// This is the npm upgrade: `npm i -g @ivan-murzak/runner-manager` puts the binary under
3623    /// the active Node installation's global prefix, and switching Node versions
3624    /// with `nvm`, `fnm`, or `volta` moves that prefix. The service is still
3625    /// registered, still set to start at boot, and starts nothing.
3626    Missing {
3627        /// The path the record names.
3628        recorded: PathBuf,
3629    },
3630    /// Something is at the recorded path, but it is not a file the service
3631    /// manager could start.
3632    NotExecutable {
3633        /// The path the record names.
3634        recorded: PathBuf,
3635        /// What is there instead.
3636        detail: String,
3637    },
3638    /// The record and the service manager name different binaries.
3639    Diverged {
3640        /// What the record says.
3641        recorded: PathBuf,
3642        /// What the service manager is registered to start.
3643        registered: PathBuf,
3644    },
3645}
3646
3647impl BinaryPath {
3648    /// Whether this is a state `service status` must report as an error.
3649    #[must_use]
3650    pub const fn is_error(&self) -> bool {
3651        !matches!(self, Self::Current { .. })
3652    }
3653
3654    /// The path the record names, whatever state it is in.
3655    #[must_use]
3656    pub fn recorded(&self) -> &Path {
3657        match self {
3658            Self::Current { path } => path,
3659            Self::Missing { recorded }
3660            | Self::NotExecutable { recorded, .. }
3661            | Self::Diverged { recorded, .. } => recorded,
3662        }
3663    }
3664}
3665
3666impl fmt::Display for BinaryPath {
3667    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3668        match self {
3669            Self::Current { path } => write!(f, "{}", path.display()),
3670            Self::Missing { recorded } => write!(
3671                f,
3672                "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
3673                 package manager that moved the binary is the usual cause; an `npm i -g` \
3674                 installation moves with the active Node version. Run `service install` again \
3675                 from the binary that is now installed.",
3676                recorded.display()
3677            ),
3678            Self::NotExecutable { recorded, detail } => write!(
3679                f,
3680                "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
3681                 from the installed binary.",
3682                recorded.display()
3683            ),
3684            Self::Diverged {
3685                recorded,
3686                registered,
3687            } => write!(
3688                f,
3689                "{} -- STALE: the service manager is registered to start {} instead. Something \
3690                 has edited the registration since it was installed. Run `service uninstall` and \
3691                 `service install`; neither touches configuration, secrets, or the cache.",
3692                recorded.display(),
3693                registered.display()
3694            ),
3695        }
3696    }
3697}
3698
3699/// Decides what became of a recorded path.
3700///
3701/// `registered` is what the service manager says it will start, when the
3702/// platform can be asked. Order matters and is deliberate: **absence is checked
3703/// first**, because a missing binary is the failure item 6 exists for and an
3704/// operator hearing "the registration disagrees with the record" about a file
3705/// that is not there would be sent to the wrong problem.
3706#[must_use]
3707pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
3708    match std::fs::metadata(recorded) {
3709        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3710            return BinaryPath::Missing {
3711                recorded: recorded.to_path_buf(),
3712            };
3713        }
3714        Err(error) => {
3715            return BinaryPath::NotExecutable {
3716                recorded: recorded.to_path_buf(),
3717                detail: format!("it cannot be inspected ({error})"),
3718            };
3719        }
3720        Ok(metadata) if !metadata.is_file() => {
3721            return BinaryPath::NotExecutable {
3722                recorded: recorded.to_path_buf(),
3723                detail: "what is there is not a file".to_string(),
3724            };
3725        }
3726        Ok(_) => {}
3727    }
3728    if let Some(registered) = registered
3729        && !same_path_text(&recorded.to_string_lossy(), &registered.to_string_lossy())
3730    {
3731        return BinaryPath::Diverged {
3732            recorded: recorded.to_path_buf(),
3733            registered: registered.to_path_buf(),
3734        };
3735    }
3736    BinaryPath::Current {
3737        path: recorded.to_path_buf(),
3738    }
3739}
3740
3741// ---------------------------------------------------------------------------
3742// The control seam
3743// ---------------------------------------------------------------------------
3744
3745/// What a service manager says about a registration it holds.
3746#[derive(Debug, Clone, PartialEq, Eq)]
3747pub struct Registration {
3748    /// Which manager holds it, which is also what decides the start mode:
3749    /// a Windows service is a boot registration and a Task Scheduler task is a
3750    /// login one; a LaunchDaemon and a system unit are boot, a LaunchAgent and
3751    /// a user unit are login.
3752    pub manager: DefinitionKind,
3753    /// The start mode the *domain* it was found in implies.
3754    pub start_mode: StartMode,
3755    /// The full command line, as the manager stores it.
3756    pub command_line: String,
3757    /// The account it runs under, when the manager reports one.
3758    pub account: Option<String>,
3759    /// Whether it is running now.
3760    pub running: bool,
3761    /// Whether it will start by itself.
3762    ///
3763    /// Distinct from [`Registration::start_mode`]: a Windows service can be
3764    /// registered in the boot domain and still be set to `demand` start, which
3765    /// is a service that exists, looks installed, and does not come back after
3766    /// a reboot.
3767    pub starts_automatically: bool,
3768    /// The restart-on-failure delay the manager reports, when it reports one.
3769    ///
3770    /// The *delay* rather than the whole [`RestartPolicy`], because that is the
3771    /// half every one of the four managers can be asked for. The failure-count
3772    /// reset window has no representation at all in Task Scheduler or launchd,
3773    /// so a `RestartPolicy` read back from them would carry one number the
3774    /// manager reported and one this module invented — and a comparison against
3775    /// an invented number is a comparison that cannot fail.
3776    pub restart_delay: Option<Duration>,
3777}
3778
3779impl Registration {
3780    /// The executable the manager will start, parsed out of the command line.
3781    #[must_use]
3782    pub fn binary(&self) -> Option<PathBuf> {
3783        executable_from_command_line(&self.command_line)
3784    }
3785}
3786
3787/// One platform's service manager, for one start-mode domain.
3788///
3789/// Every method takes the identity rather than storing it, so one control can
3790/// answer about the product's registration and about a test fixture's without
3791/// either being able to reach the other by accident.
3792pub trait ServiceControl: fmt::Debug {
3793    /// Which manager this is.
3794    fn manager(&self) -> DefinitionKind;
3795
3796    /// Registers the plan, returning the definition that was applied.
3797    ///
3798    /// # Errors
3799    ///
3800    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`].
3801    fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
3802
3803    /// Deregisters. Returns whether there was a registration to remove.
3804    ///
3805    /// **Removes the registration and nothing else.** No implementation of this
3806    /// method may delete a directory, a database, a secret, or a cache;
3807    /// `05-infrastructure.md` item 5 is a property of every backend, not a
3808    /// check somewhere above them.
3809    ///
3810    /// # Errors
3811    ///
3812    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`].
3813    fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3814
3815    /// What the manager knows about this registration, if anything.
3816    ///
3817    /// # Errors
3818    ///
3819    /// [`ServiceError::Control`].
3820    fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
3821
3822    /// Starts it now, without waiting for the next boot or logon.
3823    ///
3824    /// # Errors
3825    ///
3826    /// [`ServiceError::Control`], [`ServiceError::NeedsElevation`], or
3827    /// [`ServiceError::NotInstalled`].
3828    fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
3829
3830    /// Stops it. Returns whether it was running.
3831    ///
3832    /// # Errors
3833    ///
3834    /// As [`ServiceControl::start`].
3835    fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3836}
3837
3838/// Chooses the control for a start mode.
3839///
3840/// The indirection is what makes [`ServiceOperations`] testable: production
3841/// hands it [`HostControls`], every test in this file hands it
3842/// [`RecordingControls`], and neither knows the difference.
3843pub trait ControlFactory: fmt::Debug + Send + Sync {
3844    /// The control for one start-mode domain.
3845    ///
3846    /// # Errors
3847    ///
3848    /// [`ServiceError::Control`] when this host has no manager for that domain.
3849    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
3850
3851    /// The text of an installed definition file, for drift detection. `None`
3852    /// when there is none to read. The default reads nothing, so a double
3853    /// never reports the real host's unit as its own.
3854    fn installed_definition(&self, _path: &Path) -> Option<String> {
3855        None
3856    }
3857}
3858
3859/// The real service managers of the host this binary was built for.
3860#[derive(Debug, Clone, Copy, Default)]
3861pub struct HostControls;
3862
3863// ---------------------------------------------------------------------------
3864// The operations
3865// ---------------------------------------------------------------------------
3866
3867/// What `install` did.
3868#[derive(Debug, Clone)]
3869pub struct Installed {
3870    /// The plan that was applied, including the resolved absolute binary path.
3871    pub plan: InstallPlan,
3872    /// The definition the platform was given.
3873    pub definition: ServiceDefinition,
3874    /// The record that was written.
3875    pub record: InstallRecord,
3876    /// What the definition grants, measured against the requirement.
3877    pub review: PrivilegeReview,
3878    /// What was done to the runner root the registration will run jobs under.
3879    ///
3880    /// Named trustees rather than SIDs, so printing it adds no identity to the
3881    /// output. See [`crate::runner_root_access`].
3882    pub runner_root: RootAccessSummary,
3883    /// Whether this replaced a registration that was already there, rather than
3884    /// making a new one.
3885    ///
3886    /// Reported rather than glossed over: "Service installed" on a host that
3887    /// had one already reads as though nothing had been taken away and put
3888    /// back, and what was taken away and put back is the thing an operator
3889    /// watching a running agent wants to know about.
3890    pub replaced_existing: bool,
3891}
3892
3893/// What `uninstall` did — and, as importantly, what it did not.
3894#[derive(Debug, Clone, PartialEq, Eq)]
3895pub struct Uninstalled {
3896    /// Whether a registration was removed.
3897    pub removed_registration: bool,
3898    /// Whether the install record was removed.
3899    pub removed_record: bool,
3900    /// Whether a definition file was removed, and which.
3901    pub removed_definition: Option<PathBuf>,
3902    /// The directories that were **left exactly as they were**.
3903    ///
3904    /// `05-infrastructure.md` item 5 stated as a value rather than as a
3905    /// promise: `service uninstall` prints this list, so an operator can see
3906    /// that the configuration, the SQLite database, the stored token and the
3907    /// runner package cache are all still there.
3908    pub preserved: Vec<PathBuf>,
3909}
3910
3911impl fmt::Display for Uninstalled {
3912    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3913        if self.removed_registration {
3914            writeln!(f, "The service registration was removed.")?;
3915        } else {
3916            writeln!(f, "There was no service registration to remove.")?;
3917        }
3918        writeln!(f, "Nothing else was deleted. These are untouched:")?;
3919        for path in &self.preserved {
3920            writeln!(f, "  {}", path.display())?;
3921        }
3922        write!(
3923            f,
3924            "The stored GitHub token is untouched too; `auth logout` is what purges it."
3925        )
3926    }
3927}
3928
3929/// What `set_start_mode` did.
3930#[derive(Debug, Clone, PartialEq, Eq)]
3931pub struct StartModeChange {
3932    /// What it was.
3933    pub from: StartMode,
3934    /// What it is now.
3935    pub to: StartMode,
3936    /// `false` when the registration was already in the requested mode.
3937    pub changed: bool,
3938    /// The secret store the new mode obliges, so a caller can tell an operator
3939    /// whether the token has to move too.
3940    pub store_scope: crate::secrets::SecretScope,
3941    /// What the mode change did to the runner root's access control.
3942    ///
3943    /// A mode change moves the account the daemon runs as, and the runner root
3944    /// admits that account by name — so the two move together or the new
3945    /// registration cannot write its own workspaces.
3946    pub runner_root: RootAccessSummary,
3947}
3948
3949impl fmt::Display for StartModeChange {
3950    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3951        if !self.changed {
3952            return write!(f, "The service already starts at {}.", self.to);
3953        }
3954        write!(
3955            f,
3956            "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
3957             if the token was stored under the other scope, run `auth login` again. {}",
3958            self.to, self.from, self.store_scope, self.runner_root
3959        )
3960    }
3961}
3962
3963/// Install, uninstall, inspect, and switch start mode.
3964///
3965/// This is the whole of the library contract `f3` builds `service install`,
3966/// `service uninstall` and `service status` on. Nothing here prints; every
3967/// operation returns a value with a [`fmt::Display`] a command can write, so the
3968/// same facts are available to the TUI and to a JSON document without being
3969/// re-derived from text.
3970#[derive(Debug, Clone)]
3971pub struct ServiceOperations {
3972    paths: AppPaths,
3973    identity: ServiceIdentity,
3974    controls: std::sync::Arc<dyn ControlFactory>,
3975    runner_root: Option<LocalAbsolutePath>,
3976}
3977
3978impl ServiceOperations {
3979    /// Operates on this host's real service managers.
3980    #[must_use]
3981    pub fn on_this_host(paths: AppPaths) -> Self {
3982        Self::with_controls(
3983            paths,
3984            ServiceIdentity::product(),
3985            std::sync::Arc::new(HostControls),
3986        )
3987    }
3988
3989    /// Operates on the controls a caller supplies.
3990    ///
3991    /// Both other arguments are explicit for the same reason: the privileged
3992    /// installer tests register a real service under a
3993    /// [`ServiceIdentity::fixture`] name, against a disposable
3994    /// [`AppPaths::rooted_at`] tree, and must not be able to touch an operator's
3995    /// installation even by mistake.
3996    #[must_use]
3997    pub fn with_controls(
3998        paths: AppPaths,
3999        identity: ServiceIdentity,
4000        controls: std::sync::Arc<dyn ControlFactory>,
4001    ) -> Self {
4002        Self {
4003            paths,
4004            identity,
4005            controls,
4006            runner_root: None,
4007        }
4008    }
4009
4010    /// Points runner-root preparation at a directory the caller owns, for the
4011    /// **current account**.
4012    ///
4013    /// **Only a [`ServiceIdentity::fixture`] registration is allowed to move
4014    /// it, and the guard is here rather than at the call site.** A product
4015    /// registration ignores the override entirely and resolves
4016    /// [`crate::runner_root::default_runner_root`] itself, which is what keeps
4017    /// "custom roots are never re-ACLed" true no matter what a caller passes.
4018    ///
4019    /// It exists because the directory the product uses is
4020    /// `%SystemDrive%\rman`, and a smoke test that created and re-permissioned
4021    /// *that* would be editing the machine it runs on from outside its own
4022    /// fixture. So a fixture aims this at a temporary directory it created and
4023    /// will delete — and "and will delete" is why the override is always
4024    /// reconciled for the calling account rather than for the account the start
4025    /// mode obliges. A boot-mode root admits `SY` and `BA` only, which an
4026    /// ordinary filtered token is neither, so a test that supplied one could
4027    /// not then inspect or remove its own temporary directory.
4028    #[must_use]
4029    pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
4030        self.runner_root = Some(root);
4031        self
4032    }
4033
4034    /// The directories this operates against.
4035    #[must_use]
4036    pub const fn paths(&self) -> &AppPaths {
4037        &self.paths
4038    }
4039
4040    /// The registration this operates on.
4041    #[must_use]
4042    pub const fn identity(&self) -> &ServiceIdentity {
4043        &self.identity
4044    }
4045
4046    /// Registers `daemon run` with the operating system.
4047    ///
4048    /// The order is the contract, and each step is a requirement:
4049    ///
4050    /// 1. the four directories are created, because item 2 is about writing them;
4051    /// 2. **the single-instance lock is taken** — item 1. It is held for the
4052    ///    whole of the install rather than probed and released, so a daemon that
4053    ///    starts halfway through cannot end up racing a registration;
4054    /// 3. neither start-mode domain already holds a registration — item 6's
4055    ///    record would otherwise describe one of two;
4056    /// 4. the binary path is resolved and confirmed to be a file — item 6;
4057    /// 5. the platform registers it;
4058    /// 6. the record is written.
4059    ///
4060    /// # Errors
4061    ///
4062    /// [`ServiceError::LockHeld`] when an agent is already running,
4063    /// [`ServiceError::AlreadyInstalled`], [`ServiceError::BinaryMissing`], and
4064    /// whatever the platform reports.
4065    pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
4066        self.paths
4067            .create_all()
4068            .map_err(|source| ServiceError::Paths {
4069                source: Box::new(source),
4070            })?;
4071
4072        // Item 1. The guard lives until the end of this function; dropping it is
4073        // the release, so an early return releases it too.
4074        let _guard = self.refuse_while_an_agent_runs()?;
4075
4076        // --------------------------------------------------------------------
4077        // AN INSTALL OVER THE SAME START MODE REPLACES; IT DOES NOT REFUSE.
4078        // --------------------------------------------------------------------
4079        // This used to refuse any existing registration, and that was wrong in
4080        // a way that took a real host down.
4081        //
4082        // `service install` is *also* how an operator moves to a new version by
4083        // hand — `install_owned_copy` says so, and the `.old` rename it does
4084        // exists precisely so that reinstalling over a **running** service
4085        // works. So the caller replaces the copy the service runs and *then*
4086        // asks to register it. Refusing at that point does not undo the swap:
4087        // the daemon is left running a binary nobody registered, the command
4088        // reports failure, and on macOS the new binary is a program the stored
4089        // credential's keychain grant does not name. Watched on 0.1.17: the
4090        // copy under `state/bin` was replaced at 00:16, `service install` said
4091        // "already registered", and the daemon crash-looped on `-25293` from
4092        // then on.
4093        //
4094        // Registering the same service twice is not what an operator is asking
4095        // for here and is not what they get: the platform is asked to drop the
4096        // registration and take it again, which is a deregister followed by a
4097        // register because that is the only thing launchd, systemd and the SCM
4098        // all support — `launchctl bootstrap` refuses a label already loaded.
4099        //
4100        // The *other* mode is still refused. Moving between boot and login
4101        // moves the registration between two service managers and changes the
4102        // account and the secret store with it; `set_start_mode` does that,
4103        // carefully, and an install is not the place for it.
4104        let (replacing, previous_was_running) = match self.find_registration()? {
4105            Some((existing, registration)) if existing == request.start_mode() => {
4106                (true, registration.running)
4107            }
4108            Some((existing, _)) => {
4109                return Err(ServiceError::AlreadyInstalled {
4110                    name: self.identity.name().to_string(),
4111                    existing,
4112                    requested: request.start_mode(),
4113                });
4114            }
4115            None => (false, false),
4116        };
4117
4118        let plan = InstallPlan::resolve(
4119            self.identity.clone(),
4120            request,
4121            ServiceDirectories::of(&self.paths),
4122        )?;
4123
4124        // Resolved before the runner root, and used after it: every step from
4125        // here on has to be able to undo the directory, and a `?` between the
4126        // preparation and the first fallible use would leave one behind with
4127        // nothing to say so.
4128        let control = self.controls.control(plan.start_mode())?;
4129
4130        // Item 8, and the one step that can refuse an otherwise valid install.
4131        // Before the platform is asked to register anything, because a
4132        // registration whose workspaces would land in a directory ordinary local
4133        // users can write is one that should never have existed — and because
4134        // undoing a directory is cheaper than undoing a service.
4135        let root = self.prepare_runner_root(plan.start_mode())?;
4136
4137        // Read before the registration is dropped, so that a replace which then
4138        // fails to take can put back what was there. A record this account
4139        // cannot read leaves nothing to reinstate, which is reported rather
4140        // than pretended: see `reinstate`.
4141        let previous = if replacing {
4142            InstallRecord::read(&self.paths).ok().flatten()
4143        } else {
4144            None
4145        };
4146        if replacing && let Err(cause) = control.uninstall(&self.identity) {
4147            return Err(undo_runner_root(&root, "install", &self.identity, cause));
4148        }
4149
4150        let definition = match control.install(&plan) {
4151            Ok(definition) => definition,
4152            Err(cause) => {
4153                // Nothing was deregistered on a first install, so there is
4154                // nothing to put back and `reinstate` says so with `Ok(())`.
4155                let restored =
4156                    self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running);
4157                return Err(rolled_back(
4158                    retained_runner_root(&root),
4159                    restored,
4160                    "install",
4161                    &self.identity,
4162                    cause,
4163                ));
4164            }
4165        };
4166        let review = review_least_privilege(&definition, &plan);
4167        let record = InstallRecord::of(&plan, &definition, Utc::now());
4168        if let Err(cause) = record.write(&self.paths) {
4169            let rollback = control
4170                .uninstall(&self.identity)
4171                .map(|_| ())
4172                .and_then(|()| {
4173                    self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4174                });
4175            return Err(rolled_back(
4176                retained_runner_root(&root),
4177                rollback,
4178                "install",
4179                &self.identity,
4180                cause,
4181            ));
4182        }
4183
4184        // Registration is not the postcondition of an install: a service the
4185        // current session still has to start by hand is only half-installed.
4186        // This matters most for a logon-triggered Windows task because its
4187        // trigger has already passed by the time an operator reinstalls it.
4188        // Start through the same control that registered the definition and
4189        // roll the whole replacement back if the manager refuses it.
4190        if let Err(cause) = control.start(&self.identity) {
4191            let remove_new = control.uninstall(&self.identity).map(|_| ());
4192            let restore_registration = remove_new.and_then(|()| {
4193                self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4194            });
4195            let restore_record = restore_registration.and_then(|()| match &previous {
4196                Some(previous) => previous.write(&self.paths),
4197                None => InstallRecord::remove(&self.paths).map(|_| ()),
4198            });
4199            return Err(rolled_back(
4200                retained_runner_root(&root),
4201                restore_record,
4202                "install and start",
4203                &self.identity,
4204                cause,
4205            ));
4206        }
4207        Ok(Installed {
4208            plan,
4209            definition,
4210            record,
4211            review,
4212            runner_root: root.summary().clone(),
4213            replaced_existing: replacing,
4214        })
4215    }
4216
4217    /// Puts back a registration this call deregistered in order to replace it.
4218    ///
4219    /// `Ok(())` when there was nothing to put back, which is the ordinary case:
4220    /// a first install deregisters nothing. When there *was* a record, the plan
4221    /// is rebuilt from it rather than from the request, because the request is
4222    /// the one that just failed.
4223    ///
4224    /// Best effort by construction — the caller passes the result to
4225    /// [`rolled_back`], which reports a failed rollback alongside the original
4226    /// cause rather than replacing it.
4227    fn reinstate(
4228        &self,
4229        control: &dyn ServiceControl,
4230        previous: Option<&InstallRecord>,
4231        was_running: bool,
4232    ) -> Result<(), ServiceError> {
4233        let Some(record) = previous else {
4234            return Ok(());
4235        };
4236        control.install(&record.plan(self.identity.clone()))?;
4237        if was_running {
4238            control.start(&self.identity)?;
4239        }
4240        Ok(())
4241    }
4242
4243    /// Deregisters, and deletes nothing else.
4244    ///
4245    /// # Errors
4246    ///
4247    /// Whatever the platform reports. A missing registration is **not** an
4248    /// error: `service uninstall` on a host that has none should say so and
4249    /// exit cleanly, because an operator running it twice has not made a
4250    /// mistake.
4251    pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
4252        let record = InstallRecord::read(&self.paths).ok().flatten();
4253        let removed_definition = record
4254            .as_ref()
4255            .and_then(|record| record.definition_path.clone());
4256
4257        let mut removed_registration = false;
4258        // Both domains, not only the recorded one. A record can be lost while a
4259        // registration survives, and leaving a registration behind because a
4260        // TOML file was deleted is exactly the state `uninstall` exists to end.
4261        for mode in [StartMode::Boot, StartMode::Login] {
4262            let control = self.controls.control(mode)?;
4263            if control.uninstall(&self.identity)? {
4264                removed_registration = true;
4265            }
4266        }
4267        let removed_record = InstallRecord::remove(&self.paths)?;
4268        Ok(Uninstalled {
4269            removed_registration,
4270            removed_record,
4271            removed_definition: removed_definition.filter(|_| removed_registration),
4272            preserved: self
4273                .paths
4274                .all()
4275                .iter()
4276                .map(|(_, path)| (*path).to_path_buf())
4277                .collect(),
4278        })
4279    }
4280
4281    /// Switches between `boot` and `login` **without re-resolving anything**.
4282    ///
4283    /// `05-infrastructure.md` item 7. Everything the new registration carries —
4284    /// the absolute binary path, the arguments, the restart policy, the four
4285    /// directories — comes from the existing record, so the operator is not
4286    /// asked to reinstall the product and a binary that has since moved is not
4287    /// silently swapped for whichever one happens to be running this command.
4288    ///
4289    /// On Windows the registration necessarily moves between two different
4290    /// Windows facilities, because Windows has no service that starts at logon;
4291    /// see this module's documentation. That is still not a reinstall: no file
4292    /// is downloaded, replaced, or re-resolved.
4293    ///
4294    /// The caller must persist the new mode onto this host's `Host` record so
4295    /// that `host show` reports it — [`StartModeChange::to`] is the value, and
4296    /// `f1` already emits `host.service_start_mode` from that field.
4297    ///
4298    /// # Errors
4299    ///
4300    /// [`ServiceError::NotInstalled`] when there is no record to switch, and
4301    /// whatever the platform reports.
4302    pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
4303        let Some(record) = InstallRecord::read(&self.paths)? else {
4304            return Err(ServiceError::NotInstalled {
4305                name: self.identity.name().to_string(),
4306                operation: "switch the start mode of",
4307            });
4308        };
4309        let from = record.start_mode;
4310        if from == to {
4311            // Nothing moves, so nothing about the root's access control has to.
4312            // Reconciling it here would turn a no-op command into one that can
4313            // fail on a permission it does not need.
4314            return Ok(StartModeChange {
4315                from,
4316                to,
4317                changed: false,
4318                store_scope: crate::secrets::SecretScope::for_start_mode(to),
4319                runner_root: RootAccessSummary::NotApplicable,
4320            });
4321        }
4322
4323        #[cfg(windows)]
4324        let arguments = {
4325            let mut arguments = record.arguments.clone();
4326            arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
4327            if to == StartMode::Boot {
4328                arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
4329            }
4330            arguments
4331        };
4332        #[cfg(not(windows))]
4333        let arguments = record.arguments.clone();
4334        let plan = InstallPlan::unchecked(
4335            self.identity.clone(),
4336            to,
4337            record.binary.clone(),
4338            record.directories.clone(),
4339        )
4340        .with_arguments(arguments)
4341        .with_restart(record.restart());
4342        let plan = if record.starts_on_demand {
4343            plan.started_on_demand()
4344        } else {
4345            plan
4346        };
4347        let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
4348            Ok(store) => plan.with_secret_guard(store.guard()),
4349            Err(_) => plan,
4350        };
4351
4352        // Install the target domain before touching the live one. This makes a
4353        // failed target install a no-op from the operator's point of view and,
4354        // unlike uninstall-first ordering, never trades a working service for
4355        // an error message. Resolved before the runner root and used after it,
4356        // so that no `?` sits between preparing the directory and the first
4357        // step that knows how to undo it.
4358        let target = self.controls.control(to)?;
4359        // The domain being left, resolved here rather than where it is used for
4360        // the same reason: the only step that removes it runs after the runner
4361        // root has been prepared, and a `?` there would abandon the target
4362        // registration, the record and the directory without a word.
4363        let previous = self.controls.control(from)?;
4364
4365        // The account changes with the mode, and so must the account the runner
4366        // root admits: `04-security-recovery.md` requires the selected identity
4367        // to be *reconciled* when service mode changes, which means adding the
4368        // operator's on the way to login and dropping it again on the way back.
4369        // Before the target install, for the same reason `install` does it
4370        // first — a root that cannot be made safe must not produce a working
4371        // registration.
4372        let root = self.prepare_runner_root(to)?;
4373
4374        let definition = match target.install(&plan) {
4375            Ok(definition) => definition,
4376            Err(cause) => {
4377                return Err(undo_runner_root(
4378                    &root,
4379                    "switch start mode",
4380                    &self.identity,
4381                    cause,
4382                ));
4383            }
4384        };
4385        let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
4386        if let Err(cause) = next_record.write(&self.paths) {
4387            return Err(rolled_back(
4388                retained_runner_root(&root),
4389                target.uninstall(&self.identity),
4390                "switch start mode",
4391                &self.identity,
4392                cause,
4393            ));
4394        }
4395
4396        // Only after the target registration and its durable record exist is
4397        // it safe to remove the old domain. If that last step fails, remove the
4398        // target and put the old record back so status and reality agree.
4399        if let Err(cause) = previous.uninstall(&self.identity) {
4400            let target_rollback = target.uninstall(&self.identity);
4401            let record_rollback = record.write(&self.paths);
4402            return Err(rolled_back(
4403                retained_runner_root(&root),
4404                target_rollback.and(record_rollback),
4405                "switch start mode",
4406                &self.identity,
4407                cause,
4408            ));
4409        }
4410        Ok(StartModeChange {
4411            from,
4412            to,
4413            changed: true,
4414            store_scope: crate::secrets::SecretScope::for_start_mode(to),
4415            runner_root: root.summary().clone(),
4416        })
4417    }
4418
4419    /// Starts the registration now.
4420    ///
4421    /// # Errors
4422    ///
4423    /// [`ServiceError::NotInstalled`], or whatever the platform reports.
4424    pub fn start(&self) -> Result<(), ServiceError> {
4425        let Some((mode, _)) = self.find_registration()? else {
4426            return Err(ServiceError::NotInstalled {
4427                name: self.identity.name().to_string(),
4428                operation: "start",
4429            });
4430        };
4431        self.controls.control(mode)?.start(&self.identity)
4432    }
4433
4434    /// Stops the registration. Returns whether it was running.
4435    ///
4436    /// # Errors
4437    ///
4438    /// As [`ServiceOperations::start`].
4439    pub fn stop(&self) -> Result<bool, ServiceError> {
4440        let Some((mode, _)) = self.find_registration()? else {
4441            return Err(ServiceError::NotInstalled {
4442                name: self.identity.name().to_string(),
4443                operation: "stop",
4444            });
4445        };
4446        self.controls.control(mode)?.stop(&self.identity)
4447    }
4448
4449    /// Everything Journey 5 step 4 asks `service status` to report.
4450    ///
4451    /// # Errors
4452    ///
4453    /// [`ServiceError::Record`] when local state cannot be read, and whatever
4454    /// the platform reports. A *stale binary path* is deliberately **not** an
4455    /// error here: it is a reported state, because a status command that
4456    /// refused to print anything else would hide the very facts an operator
4457    /// needs in order to fix it.
4458    ///
4459    /// A record **this account may not read** is reported for the same reason
4460    /// and is the second exception. On a boot-mode host the record was written
4461    /// by `sudo service install`, and a version before this one wrote it `0600`
4462    /// — so the operator's own `service status` ended with `Permission denied`
4463    /// and printed nothing at all, on a host whose registration the service
4464    /// manager would have described perfectly well. What launchd, systemd or
4465    /// the SCM says is a separate fact from the record, and it is still worth
4466    /// having.
4467    pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
4468        let (record, record_refused) = match InstallRecord::read(&self.paths) {
4469            Ok(record) => (record, None),
4470            Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
4471            Err(error) => return Err(error),
4472        };
4473        let found = self.find_registration()?;
4474        let last_github_contact = last_github_contact(&self.paths)?;
4475        let drift = record
4476            .as_ref()
4477            .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, record));
4478        let credential_rejected_since =
4479            github_credential_rejected_since(&self.paths).ok().flatten();
4480        let credential_unreadable_since = credential_unreadable_since(&self.paths).ok().flatten();
4481        Ok(ServiceStatus::compose(
4482            self.identity.clone(),
4483            record,
4484            record_refused.as_ref(),
4485            found.map(|(_, registration)| registration),
4486            last_github_contact,
4487            &self.paths,
4488        )
4489        .with_definition_drift(drift)
4490        .with_credential_rejection(credential_rejected_since)
4491        .with_credential_unreadable(credential_unreadable_since)
4492        .with_launches_blocked(crate::launch_health::launches_blocked(
4493            &self.paths,
4494            Utc::now(),
4495        )))
4496    }
4497
4498    /// The installed definition, when it is not what this build renders for
4499    /// the recorded installation. See [`definition_drift`].
4500    ///
4501    /// # Errors
4502    ///
4503    /// Whatever reading the install record reports.
4504    pub fn definition_drift(&self) -> Result<Option<DefinitionDrift>, ServiceError> {
4505        Ok(InstallRecord::read(&self.paths)?
4506            .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, &record)))
4507    }
4508
4509    /// Writes the plist this build renders over an outdated one, then has a
4510    /// detached helper boot the job out and bootstrap it again from the new
4511    /// file.
4512    ///
4513    /// launchd reads a plist only when the job is bootstrapped. Rewriting the
4514    /// file changes nothing for a job that is already loaded, and a `KeepAlive`
4515    /// restart reuses the loaded copy. So the job has to be booted out, and
4516    /// that terminates the daemon that asked for it. The helper runs in a
4517    /// session of its own, which launchd does not kill with the job, and
4518    /// retries the bootstrap: launchd refuses it for a few seconds while the
4519    /// old job is still exiting (`5: Input/output error`, measured).
4520    ///
4521    /// Call this only from the daemon the job runs, and only while it holds no
4522    /// runner. It is an operation on the registration the daemon itself runs
4523    /// under, and it never changes the start mode.
4524    ///
4525    /// # Errors
4526    ///
4527    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`] when the
4528    /// plist cannot be written or the helper cannot be started. The loaded job
4529    /// is untouched in both cases.
4530    #[cfg(target_os = "macos")]
4531    pub fn reload_launchd_definition(&self, drift: &DefinitionDrift) -> Result<(), ServiceError> {
4532        // The record names the start mode; the account this process runs as has
4533        // to agree, or the record describes a registration other than the one
4534        // running, and root would end up rewriting an operator's LaunchAgent.
4535        // SAFETY: `getuid` reads the calling process's real user id and cannot
4536        // fail.
4537        let root = unsafe { libc::getuid() } == 0;
4538        if root != (drift.start_mode == StartMode::Boot) {
4539            return Err(ServiceError::Control {
4540                operation: "rewrite",
4541                name: self.identity.name().to_string(),
4542                manager: "launchd",
4543                detail: format!(
4544                    "the install record says --start-at {}, but this daemon runs as {}; the plist \
4545                     is left as it is",
4546                    drift.start_mode,
4547                    if root { "root" } else { "the operator" }
4548                ),
4549            });
4550        }
4551        replace_definition(self.identity.name(), drift)?;
4552        // The definition is now this build's, as after the WSL unit rewrite.
4553        // Best effort: the record says who wrote the plist and nothing reads it
4554        // to decide whether to repair.
4555        if let Ok(Some(mut record)) = InstallRecord::read(&self.paths) {
4556            record.installed_by_version = env!("CARGO_PKG_VERSION").to_string();
4557            let _ = record.write(&self.paths);
4558        }
4559        sys::reload_detached(&self.identity, drift.start_mode, &drift.path)
4560    }
4561
4562    /// Creates or reconciles the runner root this start mode's account needs.
4563    ///
4564    /// The account is not an argument: it is [`ServiceAccount::for_start_mode`],
4565    /// the same function the registration's own principal comes from, so the
4566    /// directory admits exactly the identity the definition registers and a mode
4567    /// change reconciles both together or neither.
4568    ///
4569    /// On macOS and Linux this is a no-op that returns
4570    /// [`RootAccessSummary::NotApplicable`]; see [`crate::runner_root_access`].
4571    fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
4572        #[cfg(not(windows))]
4573        {
4574            // `runner_root` is consumed here as well as in the Windows arm: a
4575            // field only one platform reads is a dead field on the other.
4576            let _ = (mode, &self.runner_root);
4577            Ok(RootAccessChange::not_applicable())
4578        }
4579        #[cfg(windows)]
4580        {
4581            let wrap = |source| ServiceError::RunnerRoot {
4582                source: Box::new(source),
4583            };
4584            // The test seam, and the two things allowed through it. `cfg!(test)`
4585            // is false in every shipped binary, so what a released build honours
4586            // is the fixture name alone — and a fixture name cannot be the
4587            // product's, which is what keeps a released `service install`
4588            // pointed at the platform default whatever a caller passes.
4589            //
4590            // An overridden root is always reconciled **for this account**,
4591            // which is the foreground admission — a real mode of the product
4592            // rather than a concession invented here. It has to be: a boot-mode
4593            // root admits `SY` and `BA` only, and a test process holding an
4594            // ordinary filtered token is neither, so it could not inspect the
4595            // temporary directory it just supplied nor delete it afterwards.
4596            // What that costs is that the *boot* descriptor is not proved
4597            // through this path; it is proved purely, by this module's
4598            // `the_runner_root_a_boot_registration_needs_admits_only_the_service`
4599            // and by `runner_root_access`'s own tests, and for real by the
4600            // privileged installer test, which runs elevated.
4601            if let Some(root) = self
4602                .runner_root
4603                .as_ref()
4604                .filter(|_| self.identity.is_fixture() || cfg!(test))
4605            {
4606                let admission = RootAdmission::of_this_account().map_err(wrap)?;
4607                return crate::runner_root_access::reconcile(&self.paths, root, &admission)
4608                    .map_err(wrap);
4609            }
4610
4611            let admission = match ServiceAccount::for_start_mode(mode) {
4612                // A boot registration runs as LocalSystem, which the constant
4613                // `SY` ace already names.
4614                ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
4615                // A login registration runs as this account, under a filtered
4616                // token in which Administrators is deny-only — so without an
4617                // ace of its own it would be admitted by nothing.
4618                ServiceAccount::InvokingUser | ServiceAccount::Root => {
4619                    RootAdmission::of_this_account().map_err(wrap)?
4620                }
4621            };
4622            crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
4623        }
4624    }
4625
4626    /// Takes the single-instance lock, or refuses with `d1`'s own message.
4627    fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
4628        HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
4629            |source| match source {
4630                held @ LockError::Held { .. } => ServiceError::LockHeld {
4631                    source: Box::new(held),
4632                },
4633                other => ServiceError::LockUnreadable {
4634                    source: Box::new(other),
4635                },
4636            },
4637        )
4638    }
4639
4640    /// The registration, in whichever domain holds it.
4641    fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
4642        for mode in [StartMode::Boot, StartMode::Login] {
4643            let control = self.controls.control(mode)?;
4644            if let Some(registration) = control.query(&self.identity)? {
4645                return Ok(Some((registration.start_mode, registration)));
4646            }
4647        }
4648        Ok(None)
4649    }
4650}
4651
4652// ---------------------------------------------------------------------------
4653// Status
4654// ---------------------------------------------------------------------------
4655
4656/// One thing `service status` has to report as wrong.
4657#[derive(Debug, Clone, PartialEq, Eq)]
4658pub struct StatusProblem {
4659    /// What it is about.
4660    pub subject: &'static str,
4661    /// What is wrong, and what to do.
4662    pub detail: String,
4663}
4664
4665impl fmt::Display for StatusProblem {
4666    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4667        write!(f, "{}: {}", self.subject, self.detail)
4668    }
4669}
4670
4671/// Everything `service status` reports.
4672///
4673/// Journey 5 step 4 names four of these — the start mode, the resolved binary
4674/// path, the diagnostic log path, and the last successful GitHub contact — and
4675/// the release gate adds the fifth: *"`service status` reports a stale binary
4676/// path as an error rather than appearing healthy"*. [`Self::is_healthy`] is
4677/// what an exit code should be derived from, and it is false whenever
4678/// [`Self::problems`] is non-empty.
4679#[derive(Debug, Clone)]
4680pub struct ServiceStatus {
4681    identity: ServiceIdentity,
4682    record: Option<InstallRecord>,
4683    registration: Option<Registration>,
4684    binary: Option<BinaryPath>,
4685    log_file: PathBuf,
4686    store: Option<crate::secrets::ActiveStore>,
4687    last_github_contact: Option<DateTime<Utc>>,
4688    runner_root: Option<(PathBuf, RootAccessReport)>,
4689    credential_rejected_since: Option<DateTime<Utc>>,
4690    launches_blocked: Option<crate::launch_health::LaunchesBlocked>,
4691    problems: Vec<StatusProblem>,
4692    notes: Vec<String>,
4693}
4694
4695impl ServiceStatus {
4696    /// `record_refused` carries a [`ServiceError::RecordNotPermitted`] when
4697    /// there **is** a record and this account may not read it, which is not the
4698    /// same state as having none and must not be reported as one. See
4699    /// [`Operations::status`].
4700    fn compose(
4701        identity: ServiceIdentity,
4702        record: Option<InstallRecord>,
4703        record_refused: Option<&ServiceError>,
4704        registration: Option<Registration>,
4705        last_github_contact: Option<DateTime<Utc>>,
4706        paths: &AppPaths,
4707    ) -> Self {
4708        let mut problems = Vec::new();
4709        let mut notes = Vec::new();
4710
4711        if let Some(refusal) = record_refused {
4712            // A problem rather than a note: `is_healthy` stays false, because
4713            // an operator who cannot read their own install record does have
4714            // something to fix. It is just not a reason to withhold everything
4715            // the service manager knows.
4716            problems.push(StatusProblem {
4717                subject: "install record",
4718                detail: refusal.to_string(),
4719            });
4720        }
4721
4722        let log_file = record.as_ref().map_or_else(
4723            || ServiceDirectories::of(paths).log_file(),
4724            |record| record.log_file.clone(),
4725        );
4726
4727        let binary = record.as_ref().map(|record| {
4728            inspect_binary(
4729                &record.binary,
4730                registration
4731                    .as_ref()
4732                    .and_then(Registration::binary)
4733                    .as_deref(),
4734            )
4735        });
4736        if let Some(state) = &binary
4737            && state.is_error()
4738        {
4739            problems.push(StatusProblem {
4740                subject: "binary",
4741                detail: state.to_string(),
4742            });
4743        }
4744
4745        match (&record, &registration) {
4746            (Some(_), None) => problems.push(StatusProblem {
4747                subject: "registration",
4748                detail: "this host has a service record but no service manager knows the \
4749                         registration. Run `service install` again; it deletes nothing."
4750                    .to_string(),
4751            }),
4752            // Not reported when the record was refused rather than absent: the
4753            // problem above already names the real state, and "there is no
4754            // install record" would be a second, wrong diagnosis of the same
4755            // file, with a `service uninstall` in its remedy.
4756            (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
4757                subject: "record",
4758                detail: format!(
4759                    "{} holds a registration for this service but there is no install record, so \
4760                     the path it was installed from and the directories it was installed against \
4761                     are unknown. Run `service uninstall` and `service install`.",
4762                    found.manager
4763                ),
4764            }),
4765            (None, Some(_)) => {}
4766            (Some(record), Some(found)) => {
4767                if record.start_mode != found.start_mode {
4768                    problems.push(StatusProblem {
4769                        subject: "start mode",
4770                        detail: format!(
4771                            "the record says {} and {} holds a {} registration. Switch the start \
4772                             mode again to make them agree.",
4773                            record.start_mode, found.manager, found.start_mode
4774                        ),
4775                    });
4776                }
4777                if record.start_mode == StartMode::Boot && !found.starts_automatically {
4778                    problems.push(StatusProblem {
4779                        subject: "start mode",
4780                        detail: format!(
4781                            "{} holds the registration but will not start it by itself, so this \
4782                             host does not resume work after a reboot.",
4783                            found.manager
4784                        ),
4785                    });
4786                }
4787                if let Some(actual) = found.restart_delay {
4788                    let expected = record.restart().effective_delay(found.manager);
4789                    if actual != expected {
4790                        problems.push(StatusProblem {
4791                            subject: "restart policy",
4792                            detail: format!(
4793                                "the record says the service restarts after {}s and {} reports \
4794                                 {}s. Something has edited the registration since it was \
4795                                 installed.",
4796                                expected.as_secs(),
4797                                found.manager,
4798                                actual.as_secs()
4799                            ),
4800                        });
4801                    } else if expected != record.restart().delay() {
4802                        // Not a fault. Task Scheduler expresses this in whole
4803                        // minutes, so the delay in force is longer than the one
4804                        // asked for -- never shorter, which is the direction
4805                        // the requirement cares about.
4806                        notes.push(format!(
4807                            "{} expresses the restart delay in whole minutes, so the {}s asked \
4808                             for is enforced as {}s. The service therefore never restarts faster \
4809                             than the configured bound.",
4810                            found.manager,
4811                            record.restart().delay().as_secs(),
4812                            expected.as_secs()
4813                        ));
4814                    }
4815                }
4816                if found.starts_automatically && !found.running {
4817                    problems.push(StatusProblem {
4818                        subject: "runtime",
4819                        detail: format!(
4820                            "{} holds an automatic registration, but the daemon is stopped. Run \
4821                             `runner-manager service install --start-at {}`; if the service \
4822                             manager denies replacement, retry from an elevated terminal. If it \
4823                             stops again, inspect {}.",
4824                            found.manager,
4825                            found.start_mode,
4826                            log_file.display()
4827                        ),
4828                    });
4829                }
4830            }
4831            (None, None) => {}
4832        }
4833
4834        // The two facts here really are independent, which is what `f1`'s
4835        // `status.rs` says this check needs and could not have there: the scope
4836        // is derived from the mode the **record** carries, and it is compared
4837        // against the mode the **service manager** actually holds.
4838        let store = record.as_ref().and_then(|record| {
4839            let registered_mode = registration
4840                .as_ref()
4841                .map_or(record.start_mode, |found| found.start_mode);
4842            crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
4843                .ok()
4844                .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
4845        });
4846        if let Some(store) = &store
4847            && !store.agrees_with_start_mode()
4848        {
4849            problems.push(StatusProblem {
4850                subject: "secret store",
4851                detail: format!(
4852                    "{store}. Run `auth login` again so the token is stored where the registered \
4853                     start mode can read it."
4854                ),
4855            });
4856        }
4857
4858        if record.as_ref().map(|record| record.start_mode) == Some(StartMode::Login) {
4859            notes.push(
4860                "This registration starts at login, so the agent does not run until the operator \
4861                 signs in; this host does not resume work after an unattended reboot."
4862                    .to_string(),
4863            );
4864        }
4865        if last_github_contact.is_none() {
4866            notes.push(
4867                "GitHub has not been reached successfully since this host's state directory was \
4868                 created."
4869                    .to_string(),
4870            );
4871        }
4872
4873        // The privileged inspection output. Read-only, tolerant of an account
4874        // that may not read the descriptor at all, and never a `problem`: a
4875        // root this account cannot inspect is a true statement about this
4876        // account's rights, not a fault in the registration. What refuses an
4877        // install is the preflight in `runner_root_access`, which runs as the
4878        // installing account and has the authority to know.
4879        let runner_root = crate::runner_root::default_runner_root(paths)
4880            .ok()
4881            .map(|root| {
4882                let path = root.as_path().to_path_buf();
4883                let report = crate::runner_root_access::report(&path);
4884                (path, report)
4885            });
4886        // Reported, and deliberately not a `problem`. The Definition of Done
4887        // asks for a broad root to be "reported and fail the security
4888        // preflight", and the security preflight is `install`'s -- which runs
4889        // as the installing account and refuses outright. `service status` runs
4890        // as whoever typed it, is expected to be readable on a host with
4891        // nothing installed at all, and drives an exit code; turning a
4892        // directory that predates this feature into a non-zero exit for a
4893        // machine that has never installed the service would report a fault
4894        // that is not this registration's.
4895        if let Some((
4896            path,
4897            RootAccessReport::Present {
4898                broad_write: true, ..
4899            },
4900        )) = &runner_root
4901        {
4902            notes.push(format!(
4903                "the platform default runner root {} can be written by ordinary local users, so \
4904                 it is not a safe place to run jobs. `service install` refuses it rather than \
4905                 tightening it, because the contents of a directory anybody could write cannot \
4906                 be trusted: remove or empty it, or choose another root with `runner-manager \
4907                 host set-runtime-root --path <PATH>`.",
4908                path.display()
4909            ));
4910        }
4911
4912        // The surface the agent has nowhere else to reach. A root that refuses
4913        // a launch does so before any attempt row exists, and the daemon's log
4914        // redacts the paths out of the sentence, so without this the operator
4915        // sees `runner_start_failed reason=other` once per poll and nothing
4916        // that names the directory or the fix.
4917        //
4918        // A **note** and not a problem, by the same rule the broad-write root
4919        // above obeys: `service status` runs as whoever typed it, is expected
4920        // to be readable on a host with nothing installed at all, and drives an
4921        // exit code. A problem here would fail that exit code on three hosts
4922        // that are not broken -- one whose root was fixed but which has had no
4923        // queued job since, one whose service was uninstalled without clearing
4924        // `state/`, and one that only ever ran the agent in the foreground --
4925        // and the printed remedy (`service uninstall && service install`) does
4926        // not touch `state/`, so following it would not clear the error either.
4927        // A note is read by the same operator at the same moment and traps
4928        // nobody in a loop.
4929        //
4930        // A read failure is reported and not swallowed, for the reason
4931        // `runner_root_refusals` documents: absence means "every policy is
4932        // placing runners", and answering a parse failure with that would be
4933        // the wrong answer to the question this row exists for.
4934        match runner_root_refusals(paths) {
4935            Ok(refusals) => {
4936                for refusal in refusals {
4937                    notes.push(format!(
4938                        "policy {} started no runner: its runner root {} refused the launch \
4939                         ({}), last at {}. {} This clears when that policy next places a \
4940                         runner.",
4941                        refusal.policy,
4942                        refusal.root,
4943                        refusal.kind,
4944                        refusal.at.to_rfc3339(),
4945                        refusal.detail,
4946                    ));
4947                }
4948            }
4949            Err(error) => notes.push(format!(
4950                "whether the agent could use its runner roots could not be read: {error}"
4951            )),
4952        }
4953
4954        Self {
4955            identity,
4956            record,
4957            registration,
4958            binary,
4959            log_file,
4960            store,
4961            last_github_contact,
4962            runner_root,
4963            credential_rejected_since: None,
4964            launches_blocked: None,
4965            problems,
4966            notes,
4967        }
4968    }
4969
4970    /// Reports a definition this build would render differently.
4971    #[must_use]
4972    pub fn with_definition_drift(mut self, drift: Option<DefinitionDrift>) -> Self {
4973        if let Some(drift) = drift {
4974            if drift.manager == DefinitionKind::LaunchdPlist {
4975                // A restart, not `service install`: the running daemon holds the
4976                // single-instance lock, which refuses an install, and a daemon
4977                // that starts with no runner repairs the plist itself.
4978                let restart = match drift.start_mode {
4979                    StartMode::Boot => format!(
4980                        "sudo launchctl kickstart -k system/{}",
4981                        self.identity.launchd_label()
4982                    ),
4983                    StartMode::Login => format!(
4984                        "launchctl kickstart -k gui/$(id -u)/{}",
4985                        self.identity.launchd_label()
4986                    ),
4987                };
4988                self.problems.push(StatusProblem {
4989                    subject: "definition",
4990                    detail: format!(
4991                        "{} was written by runner-manager {} with a `ProcessType` other than \
4992                         `{LAUNCHD_PROCESS_TYPE}`, which launchd applies to every runner the \
4993                         daemon starts, so their jobs run below normal priority. The daemon \
4994                         rewrites it and reloads itself when it starts with no runner, which it \
4995                         does after every upgrade; to do it now, restart the service while no \
4996                         job is running: `{restart}`. Configuration and credentials are kept.",
4997                        drift.path.display(),
4998                        drift.installed_by_version,
4999                    ),
5000                });
5001                return self;
5002            }
5003            let repair = match drift.start_mode {
5004                StartMode::Boot => {
5005                    "`sudo systemctl stop runner-manager && sudo runner-manager service install \
5006                     --start-at boot` (for a managed WSL host, run `runner-manager wsl install \
5007                     --distribution <name>` from Windows)"
5008                }
5009                StartMode::Login => {
5010                    "`systemctl --user stop runner-manager && runner-manager service install \
5011                     --start-at login`"
5012                }
5013            };
5014            self.problems.push(StatusProblem {
5015                subject: "definition",
5016                detail: format!(
5017                    "{} was written by runner-manager {} and differs from what {} renders, so \
5018                     the daemon runs under outdated settings (for example a credential frozen \
5019                     at service start). Re-register it with {repair}; configuration and \
5020                     credentials are kept.",
5021                    drift.path.display(),
5022                    drift.installed_by_version,
5023                    env!("CARGO_PKG_VERSION"),
5024                ),
5025            });
5026        }
5027        self
5028    }
5029
5030    /// Reports that GitHub is rejecting the daemon's credential.
5031    #[must_use]
5032    pub fn with_credential_rejection(mut self, since: Option<DateTime<Utc>>) -> Self {
5033        if let Some(since) = since {
5034            self.credential_rejected_since = Some(since);
5035            self.problems.push(StatusProblem {
5036                subject: "github credential",
5037                detail: format!(
5038                    "GitHub has rejected the service's credential since {}, so the service \
5039                     cannot see queued jobs or start runners. Run `runner-manager auth login` \
5040                     (for a managed WSL host, `runner-manager --host wsl:<name> auth login` \
5041                     from Windows).",
5042                    since.to_rfc3339()
5043                ),
5044            });
5045        }
5046        self
5047    }
5048
5049    /// Since when GitHub has rejected the service's credential, if it has.
5050    #[must_use]
5051    pub const fn credential_rejected_since(&self) -> Option<DateTime<Utc>> {
5052        self.credential_rejected_since
5053    }
5054
5055    /// Reports that the daemon starts no runner because its launches are
5056    /// blocked. See [`crate::launch_health`].
5057    #[must_use]
5058    pub fn with_launches_blocked(
5059        mut self,
5060        blocked: Option<crate::launch_health::LaunchesBlocked>,
5061    ) -> Self {
5062        if let Some(blocked) = &blocked {
5063            self.problems.push(StatusProblem {
5064                subject: LAUNCHES_BLOCKED_SUBJECT,
5065                detail: format!(
5066                    "the service has started no runner {blocked}. Fix: {}.",
5067                    blocked.remedy
5068                ),
5069            });
5070        }
5071        self.launches_blocked = blocked;
5072        self
5073    }
5074
5075    /// Why the service starts no runner, when its launches are blocked.
5076    #[must_use]
5077    pub const fn launches_blocked(&self) -> Option<&crate::launch_health::LaunchesBlocked> {
5078        self.launches_blocked.as_ref()
5079    }
5080
5081    /// Reports that the daemon cannot read its own stored credential, and the
5082    /// exact command that ends it. See [`record_credential_unreadable`].
5083    #[must_use]
5084    pub fn with_credential_unreadable(mut self, since: Option<DateTime<Utc>>) -> Self {
5085        if let Some(since) = since {
5086            let mode = self.start_mode().unwrap_or_default();
5087            self.problems.push(StatusProblem {
5088                subject: CREDENTIAL_UNREADABLE_SUBJECT,
5089                detail: format!(
5090                    "the service has been unable to read its stored GitHub credential since {}, \
5091                     so it starts no runner; the diagnostic log has the store's own answer.{} \
5092                     Sign in again: {}.",
5093                    since.to_rfc3339(),
5094                    if cfg!(target_os = "macos") {
5095                        " That is what a keychain item written by a different build looks like."
5096                    } else {
5097                        ""
5098                    },
5099                    sign_in_instruction(mode)
5100                ),
5101            });
5102        }
5103        self
5104    }
5105
5106    /// What this host's default runner root grants, and to whom.
5107    ///
5108    /// `None` when the platform default could not even be resolved. The
5109    /// descriptor inside has been through
5110    /// [`crate::runner_root_access::redact`], so it names the well-known
5111    /// trustees and says "an account" for everything else — no more identity
5112    /// than the `account` line above it already prints.
5113    #[must_use]
5114    pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
5115        self.runner_root
5116            .as_ref()
5117            .map(|(path, report)| (path.as_path(), report))
5118    }
5119
5120    /// Whether a registration exists at all.
5121    #[must_use]
5122    pub const fn is_installed(&self) -> bool {
5123        self.registration.is_some() || self.record.is_some()
5124    }
5125
5126    /// Whether the daemon is running now.
5127    #[must_use]
5128    pub fn is_running(&self) -> bool {
5129        self.registration
5130            .as_ref()
5131            .is_some_and(|registration| registration.running)
5132    }
5133
5134    /// **False whenever anything is wrong**, including a stale binary path.
5135    #[must_use]
5136    pub fn is_healthy(&self) -> bool {
5137        self.problems.is_empty()
5138    }
5139
5140    /// Everything that is wrong.
5141    #[must_use]
5142    pub fn problems(&self) -> &[StatusProblem] {
5143        &self.problems
5144    }
5145
5146    /// True statements that are not faults — a login-mode registration not
5147    /// running unattended, or an agent that has not yet reached GitHub.
5148    #[must_use]
5149    pub fn notes(&self) -> &[String] {
5150        &self.notes
5151    }
5152
5153    /// The recorded start mode. Journey 5 step 4.
5154    #[must_use]
5155    pub fn start_mode(&self) -> Option<StartMode> {
5156        self.record.as_ref().map(|record| record.start_mode)
5157    }
5158
5159    /// The resolved absolute binary path and what became of it. Journey 5
5160    /// step 4 and `05-infrastructure.md` item 6.
5161    #[must_use]
5162    pub const fn binary(&self) -> Option<&BinaryPath> {
5163        self.binary.as_ref()
5164    }
5165
5166    /// The diagnostic log path. `05-infrastructure.md` item 4.
5167    #[must_use]
5168    pub fn log_file(&self) -> &Path {
5169        &self.log_file
5170    }
5171
5172    /// The last successful GitHub contact. Journey 5 step 4.
5173    #[must_use]
5174    pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
5175        self.last_github_contact
5176    }
5177
5178    /// Which store the daemon reads, and whether that agrees with the
5179    /// registration.
5180    #[must_use]
5181    pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
5182        self.store.as_ref()
5183    }
5184
5185    /// The record `install` wrote.
5186    #[must_use]
5187    pub const fn record(&self) -> Option<&InstallRecord> {
5188        self.record.as_ref()
5189    }
5190
5191    /// What the service manager says.
5192    #[must_use]
5193    pub const fn registration(&self) -> Option<&Registration> {
5194        self.registration.as_ref()
5195    }
5196}
5197
5198impl fmt::Display for ServiceStatus {
5199    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5200        writeln!(f, "Service: {}", self.identity)?;
5201        match (&self.record, &self.registration) {
5202            (None, None) => {
5203                writeln!(
5204                    f,
5205                    "  installed                 no. `service install` registers `{} {}`.",
5206                    SERVICE_NAME,
5207                    DAEMON_ARGUMENTS.join(" ")
5208                )?;
5209            }
5210            _ => {
5211                let manager = self
5212                    .registration
5213                    .as_ref()
5214                    .map(|registration| registration.manager.manager());
5215                writeln!(
5216                    f,
5217                    "  installed                 {}",
5218                    manager.unwrap_or("yes, but no service manager knows it")
5219                )?;
5220                writeln!(
5221                    f,
5222                    "  state                     {}",
5223                    if self.is_running() {
5224                        "running"
5225                    } else {
5226                        "not running"
5227                    }
5228                )?;
5229            }
5230        }
5231        if let Some(record) = &self.record {
5232            writeln!(f, "  start mode                {}", record.start_mode)?;
5233            writeln!(f, "  account                   {}", record.account)?;
5234            writeln!(f, "  restart on failure        {}", record.restart())?;
5235            writeln!(
5236                f,
5237                "  arguments                 {}",
5238                record.arguments.join(" ")
5239            )?;
5240        }
5241        if let Some(binary) = &self.binary {
5242            writeln!(f, "  binary                    {binary}")?;
5243        }
5244        writeln!(f, "  diagnostic log            {}", self.log_file.display())?;
5245        if let Some((path, report)) = &self.runner_root {
5246            // Named as the *default* rather than as "the runner root", because
5247            // it is only the effective one until an operator runs
5248            // `host set-runtime-root`. This crate cannot see that setting — it
5249            // lives in the application's store — so an unqualified label here
5250            // would contradict the `runner root … (host-configured)` row that
5251            // `status` and `host show` print from the value that is in force.
5252            writeln!(f, "  default runner root       {}", path.display())?;
5253            if *report != RootAccessReport::NotApplicable {
5254                writeln!(f, "  default root access       {report}")?;
5255            }
5256        }
5257        if let Some(store) = &self.store {
5258            writeln!(f, "  secret store              {store}")?;
5259        }
5260        writeln!(
5261            f,
5262            "  last GitHub contact       {}",
5263            match self.last_github_contact {
5264                Some(at) => at.to_rfc3339(),
5265                None => "never".to_string(),
5266            }
5267        )?;
5268        for note in &self.notes {
5269            writeln!(f, "  note                      {note}")?;
5270        }
5271        for problem in &self.problems {
5272            writeln!(f, "  ERROR                     {problem}")?;
5273        }
5274        write!(
5275            f,
5276            "  verdict                   {}",
5277            if self.is_healthy() {
5278                "healthy"
5279            } else {
5280                "NOT healthy"
5281            }
5282        )
5283    }
5284}
5285
5286// ---------------------------------------------------------------------------
5287// The in-memory double
5288// ---------------------------------------------------------------------------
5289
5290/// A [`ControlFactory`] that registers nothing and remembers everything.
5291///
5292/// Public rather than `#[cfg(test)]` on purpose. `f3` builds three commands on
5293/// [`ServiceOperations`] and has to be able to test them without a service
5294/// manager, and a double that lived behind `#[cfg(test)]` here would be
5295/// invisible from another crate — which would leave `f3` either untested or
5296/// writing a second double that drifts from this one.
5297///
5298/// It is not a simulator. It stores what it was asked to register and reports it
5299/// back, which is exactly enough to exercise the logic that sits *above* a
5300/// service manager: the lock refusal, the record, the stale-path detection, the
5301/// start-mode switch, and the promise that uninstall deletes nothing else.
5302#[derive(Debug, Clone, Default)]
5303pub struct RecordingControls {
5304    state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
5305}
5306
5307#[derive(Debug, Default)]
5308struct RecordingState {
5309    registrations: BTreeMap<(StartMode, String), Registration>,
5310    definitions: BTreeMap<String, ServiceDefinition>,
5311    calls: Vec<String>,
5312    #[cfg(test)]
5313    install_failures: BTreeMap<StartMode, String>,
5314    #[cfg(test)]
5315    after_install: BTreeMap<StartMode, TestInstallSideEffect>,
5316}
5317
5318#[cfg(test)]
5319#[derive(Debug, Clone)]
5320enum TestInstallSideEffect {
5321    HideDirectory { directory: PathBuf, hidden: PathBuf },
5322}
5323
5324impl RecordingControls {
5325    /// A factory holding no registrations.
5326    #[must_use]
5327    pub fn new() -> Self {
5328        Self::default()
5329    }
5330
5331    /// Every call made through this factory, in order, as
5332    /// `"<operation> <name> (<mode>)"`.
5333    #[must_use]
5334    pub fn calls(&self) -> Vec<String> {
5335        self.state.lock().expect("not poisoned").calls.clone()
5336    }
5337
5338    /// Every registration currently held, with the domain it is in.
5339    #[must_use]
5340    pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
5341        self.state
5342            .lock()
5343            .expect("not poisoned")
5344            .registrations
5345            .iter()
5346            .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
5347            .collect()
5348    }
5349
5350    /// The definition applied for a registration, if it is held.
5351    #[must_use]
5352    pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
5353        self.state
5354            .lock()
5355            .expect("not poisoned")
5356            .definitions
5357            .get(name)
5358            .cloned()
5359    }
5360
5361    /// Edits a held registration, as something outside this product would.
5362    ///
5363    /// This is how the divergence and start-type problems are made reachable
5364    /// from a test: `sc config`, `launchctl` and `systemctl` can all change a
5365    /// registration after installation, and a status command that could not be
5366    /// shown detecting that would be a status command nobody had tested against
5367    /// the case it exists for.
5368    ///
5369    /// Does nothing when no registration of that name is held.
5370    pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
5371        let mut state = self.state.lock().expect("not poisoned");
5372        if let Some((_, registration)) = state
5373            .registrations
5374            .iter_mut()
5375            .find(|((_, held), _)| held == name)
5376        {
5377            edit(registration);
5378        }
5379    }
5380
5381    #[cfg(test)]
5382    fn fail_next_install(&self, mode: StartMode, detail: &str) {
5383        self.state
5384            .lock()
5385            .expect("not poisoned")
5386            .install_failures
5387            .insert(mode, detail.to_string());
5388    }
5389
5390    #[cfg(test)]
5391    fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
5392        self.state
5393            .lock()
5394            .expect("not poisoned")
5395            .after_install
5396            .insert(
5397                mode,
5398                TestInstallSideEffect::HideDirectory { directory, hidden },
5399            );
5400    }
5401}
5402
5403impl ControlFactory for RecordingControls {
5404    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5405        Ok(Box::new(RecordingControl {
5406            mode,
5407            state: std::sync::Arc::clone(&self.state),
5408        }))
5409    }
5410}
5411
5412#[derive(Debug)]
5413struct RecordingControl {
5414    mode: StartMode,
5415    state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
5416}
5417
5418impl RecordingControl {
5419    fn note(&self, operation: &str, name: &str) {
5420        self.state
5421            .lock()
5422            .expect("not poisoned")
5423            .calls
5424            .push(format!("{operation} {name} ({})", self.mode));
5425    }
5426}
5427
5428impl ServiceControl for RecordingControl {
5429    fn manager(&self) -> DefinitionKind {
5430        // The double reports the kind this host's real backend would, so a test
5431        // that asserts on the manager name asserts something true of the
5432        // platform it is running on.
5433        host_definition_kind(self.mode)
5434    }
5435
5436    fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5437        self.note("install", plan.identity().name());
5438        #[cfg(test)]
5439        if let Some(detail) = self
5440            .state
5441            .lock()
5442            .expect("not poisoned")
5443            .install_failures
5444            .remove(&self.mode)
5445        {
5446            return Err(ServiceError::Control {
5447                operation: "install",
5448                name: plan.identity().name().to_string(),
5449                manager: "recording control",
5450                detail,
5451            });
5452        }
5453        // The real definition, not a stub. Rendering is pure, so the double can
5454        // afford it -- and a stub would make `Installed::review` report that the
5455        // definition confirms nothing, which is exactly the shape of false
5456        // assurance a test double must never hand back.
5457        let definition = ServiceDefinition::for_host(plan)?;
5458        let mut state = self.state.lock().expect("not poisoned");
5459        state.registrations.insert(
5460            (self.mode, plan.identity().name().to_string()),
5461            Registration {
5462                manager: host_definition_kind(self.mode),
5463                start_mode: self.mode,
5464                command_line: plan.command_line(),
5465                account: Some(plan.account().as_str().to_string()),
5466                running: false,
5467                starts_automatically: true,
5468                restart_delay: Some(plan.restart().delay()),
5469            },
5470        );
5471        state
5472            .definitions
5473            .insert(plan.identity().name().to_string(), definition.clone());
5474        #[cfg(test)]
5475        let side_effect = state.after_install.remove(&self.mode);
5476        drop(state);
5477        #[cfg(test)]
5478        if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
5479            std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
5480            std::fs::write(&directory, b"blocks recreation")
5481                .expect("test fault can block record directory recreation");
5482        }
5483        Ok(definition)
5484    }
5485
5486    fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5487        self.note("uninstall", identity.name());
5488        let mut state = self.state.lock().expect("not poisoned");
5489        state.definitions.remove(identity.name());
5490        Ok(state
5491            .registrations
5492            .remove(&(self.mode, identity.name().to_string()))
5493            .is_some())
5494    }
5495
5496    fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
5497        self.note("query", identity.name());
5498        Ok(self
5499            .state
5500            .lock()
5501            .expect("not poisoned")
5502            .registrations
5503            .get(&(self.mode, identity.name().to_string()))
5504            .cloned())
5505    }
5506
5507    fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
5508        self.note("start", identity.name());
5509        let mut state = self.state.lock().expect("not poisoned");
5510        match state
5511            .registrations
5512            .get_mut(&(self.mode, identity.name().to_string()))
5513        {
5514            Some(registration) => {
5515                registration.running = true;
5516                Ok(())
5517            }
5518            None => Err(ServiceError::NotInstalled {
5519                name: identity.name().to_string(),
5520                operation: "start",
5521            }),
5522        }
5523    }
5524
5525    fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5526        self.note("stop", identity.name());
5527        let mut state = self.state.lock().expect("not poisoned");
5528        match state
5529            .registrations
5530            .get_mut(&(self.mode, identity.name().to_string()))
5531        {
5532            Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
5533            None => Err(ServiceError::NotInstalled {
5534                name: identity.name().to_string(),
5535                operation: "stop",
5536            }),
5537        }
5538    }
5539}
5540
5541impl ControlFactory for HostControls {
5542    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5543        sys::control(mode)
5544    }
5545
5546    fn installed_definition(&self, path: &Path) -> Option<String> {
5547        std::fs::read_to_string(path).ok()
5548    }
5549}
5550
5551/// The operator's home directory, when the platform reports one.
5552///
5553/// Only login-mode registrations need it — a LaunchAgent and a systemd user
5554/// unit both live under it — and it is resolved lazily so that a boot-mode
5555/// operation on an account with no profile is not refused for wanting something
5556/// it never uses.
5557fn host_home() -> Option<PathBuf> {
5558    directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
5559}
5560
5561/// The Unix backends' name for the same thing.
5562#[cfg(unix)]
5563fn home_directory() -> Option<PathBuf> {
5564    host_home()
5565}
5566
5567/// Runs a command and returns its exit status with both streams as text.
5568///
5569/// The three Unix backends and the Windows Task Scheduler backend all drive a
5570/// stock command-line tool, and all four want the same three things back. The
5571/// tools are chosen for machine-readable, locale-independent output wherever one
5572/// exists — `systemctl is-active`, `launchctl print`, `schtasks /XML` — because
5573/// parsing a localised human-facing table is how a status command starts lying
5574/// on somebody else's machine.
5575fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
5576    let output = std::process::Command::new(program)
5577        .args(arguments)
5578        .output()?;
5579    Ok((
5580        output.status.success(),
5581        String::from_utf8_lossy(&output.stdout).into_owned(),
5582        String::from_utf8_lossy(&output.stderr).into_owned(),
5583    ))
5584}
5585
5586/// Which manager holds a given start-mode domain on the platform this binary
5587/// was built for.
5588#[must_use]
5589pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
5590    if cfg!(windows) {
5591        match mode {
5592            StartMode::Boot => DefinitionKind::WindowsService,
5593            StartMode::Login => DefinitionKind::WindowsScheduledTask,
5594        }
5595    } else if cfg!(target_os = "macos") {
5596        DefinitionKind::LaunchdPlist
5597    } else {
5598        DefinitionKind::SystemdUnit
5599    }
5600}
5601
5602/// Enables a launchd label after it has been bootstrapped.
5603///
5604/// `launchctl bootstrap` deliberately preserves a label's disabled bit. An
5605/// `enable` failure therefore means the new registration cannot satisfy its
5606/// start policy. Compensate by booting it out and removing the plist so a
5607/// failed install cannot look installed to `service status`.
5608#[cfg(any(target_os = "macos", test))]
5609fn enable_launchd_registration(
5610    mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
5611    domain: &str,
5612    service_target: &str,
5613    plist: &Path,
5614    name: &str,
5615    elevation_remedy: &'static str,
5616) -> Result<(), ServiceError> {
5617    let (enabled, cause) = launchctl(&[
5618        std::ffi::OsStr::new("enable"),
5619        std::ffi::OsStr::new(service_target),
5620    ]);
5621    if enabled {
5622        return Ok(());
5623    }
5624
5625    let (booted_out, bootout_detail) = launchctl(&[
5626        std::ffi::OsStr::new("bootout"),
5627        std::ffi::OsStr::new(service_target),
5628    ]);
5629    let removed = std::fs::remove_file(plist);
5630    if !booted_out || removed.is_err() {
5631        return Err(ServiceError::Rollback {
5632            operation: "enable launchd registration",
5633            name: name.to_string(),
5634            cause,
5635            rollback: format!(
5636                "launchctl bootout {domain}: {}; remove {}: {}",
5637                if booted_out {
5638                    "succeeded".to_string()
5639                } else {
5640                    bootout_detail
5641                },
5642                plist.display(),
5643                removed
5644                    .err()
5645                    .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
5646            ),
5647        });
5648    }
5649
5650    if cause.to_ascii_lowercase().contains("permission denied") {
5651        Err(ServiceError::NeedsElevation {
5652            operation: "enable",
5653            name: name.to_string(),
5654            detail: cause,
5655            remedy: elevation_remedy,
5656        })
5657    } else {
5658        Err(ServiceError::Control {
5659            operation: "enable",
5660            name: name.to_string(),
5661            manager: "launchd",
5662            detail: cause,
5663        })
5664    }
5665}
5666
5667// ---------------------------------------------------------------------------
5668// Windows
5669// ---------------------------------------------------------------------------
5670
5671/// A stop request delivered by the Windows Service Control Manager.
5672///
5673/// The application owns the drain policy; this platform boundary only turns
5674/// `SERVICE_CONTROL_STOP`/`SHUTDOWN` into an awaitable notification.
5675#[derive(Debug, Clone)]
5676pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
5677
5678impl ServiceShutdown {
5679    /// Waits until SCM asks the service to stop or shut down.
5680    pub async fn wait(mut self) {
5681        if !*self.0.borrow() {
5682            let _ = self.0.changed().await;
5683        }
5684    }
5685}
5686
5687/// Runs the production process as a real Windows service host.
5688///
5689/// `StartServiceCtrlDispatcher` must run in the service process's main thread,
5690/// so the callback is handed through a process-global slot to the entrypoint
5691/// invoked by SCM. The slot is single-use by design: one process hosts exactly
5692/// one own-process service.
5693#[cfg(windows)]
5694pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
5695where
5696    F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
5697{
5698    windows_host::run(Box::new(run))
5699}
5700
5701#[cfg(windows)]
5702mod windows_host {
5703    use std::ffi::OsString;
5704    use std::sync::{Arc, Mutex, OnceLock, mpsc};
5705    use std::time::Duration;
5706
5707    use windows_service::service::{
5708        ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
5709        ServiceType,
5710    };
5711    use windows_service::service_control_handler::{
5712        self, ServiceControlHandlerResult, ServiceStatusHandle,
5713    };
5714
5715    use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
5716
5717    type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
5718
5719    struct Invocation {
5720        run: Runner,
5721        result: mpsc::SyncSender<Result<u8, String>>,
5722    }
5723
5724    static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
5725
5726    windows_service::define_windows_service!(ffi_service_main, service_main);
5727
5728    pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
5729        let (result_tx, result_rx) = mpsc::sync_channel(1);
5730        let slot = INVOCATION.get_or_init(|| Mutex::new(None));
5731        let mut invocation = slot
5732            .lock()
5733            .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
5734        if invocation.is_some() {
5735            return Err(host_error(
5736                "prepare",
5737                "the service-host slot was already used",
5738            ));
5739        }
5740        *invocation = Some(Invocation {
5741            run,
5742            result: result_tx,
5743        });
5744        drop(invocation);
5745
5746        windows_service::service_dispatcher::start("", ffi_service_main)
5747            .map_err(|error| host_error("connect", &error.to_string()))?;
5748        result_rx
5749            .recv()
5750            .map_err(|error| host_error("finish", &error.to_string()))?
5751            .map_err(|detail| host_error("run", &detail))
5752    }
5753
5754    fn service_main(_arguments: Vec<OsString>) {
5755        let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
5756            return;
5757        };
5758        let result = run_service(invocation.run);
5759        let _ = invocation.result.send(result);
5760    }
5761
5762    fn run_service(run: Runner) -> Result<u8, String> {
5763        let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
5764        let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
5765        let handler_status = Arc::clone(&status);
5766        let handler = move |control| match control {
5767            ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
5768            ServiceControl::Stop | ServiceControl::Shutdown => {
5769                if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
5770                    let _ = handle.set_service_status(service_status(
5771                        ServiceState::StopPending,
5772                        ServiceControlAccept::empty(),
5773                        1,
5774                        Duration::from_secs(300),
5775                        0,
5776                    ));
5777                }
5778                let _ = stop_tx.send(true);
5779                ServiceControlHandlerResult::NoError
5780            }
5781            _ => ServiceControlHandlerResult::NotImplemented,
5782        };
5783        let handle = service_control_handler::register("", handler)
5784            .map_err(|error| format!("cannot register the service control handler: {error}"))?;
5785        *status
5786            .lock()
5787            .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
5788        handle
5789            .set_service_status(service_status(
5790                ServiceState::Running,
5791                ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
5792                0,
5793                Duration::default(),
5794                0,
5795            ))
5796            .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
5797
5798        let exit = run(ServiceShutdown(stop_rx));
5799        handle
5800            .set_service_status(service_status(
5801                ServiceState::Stopped,
5802                ServiceControlAccept::empty(),
5803                0,
5804                Duration::default(),
5805                u32::from(exit),
5806            ))
5807            .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
5808        Ok(exit)
5809    }
5810
5811    fn service_status(
5812        state: ServiceState,
5813        accepted: ServiceControlAccept,
5814        checkpoint: u32,
5815        wait_hint: Duration,
5816        exit: u32,
5817    ) -> ServiceStatus {
5818        ServiceStatus {
5819            service_type: ServiceType::OWN_PROCESS,
5820            current_state: state,
5821            controls_accepted: accepted,
5822            exit_code: if exit == 0 {
5823                ServiceExitCode::Win32(0)
5824            } else {
5825                ServiceExitCode::ServiceSpecific(exit)
5826            },
5827            checkpoint,
5828            wait_hint,
5829            process_id: None,
5830        }
5831    }
5832
5833    fn host_error(operation: &'static str, detail: &str) -> ServiceError {
5834        ServiceError::Control {
5835            operation,
5836            name: SERVICE_NAME.to_string(),
5837            manager: "the Windows Service Control Manager",
5838            detail: detail.to_string(),
5839        }
5840    }
5841}
5842
5843#[cfg(windows)]
5844mod sys {
5845    //! Two managers, because Windows has two answers.
5846    //!
5847    //! `--start-at boot` is a service in the Service Control Manager, which is
5848    //! the only Windows facility that starts something before anybody logs in.
5849    //! `--start-at login` is a Task Scheduler task with a logon trigger, which
5850    //! is the only Windows facility that starts something when somebody does.
5851    //! There is no single mechanism that does both: service trigger-start has no
5852    //! logon trigger, and a scheduled task cannot run before a session exists.
5853
5854    use std::ffi::{OsStr, OsString};
5855    use std::time::{Duration, Instant};
5856
5857    use runner_manager_domain::model::StartMode;
5858    use windows_service::service::{
5859        ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
5860        ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
5861        ServiceState, ServiceType,
5862    };
5863    use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
5864
5865    use super::{
5866        DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
5867        ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
5868    };
5869
5870    /// `ERROR_SERVICE_DOES_NOT_EXIST`. Not an error here: it is the answer to
5871    /// "is this registered".
5872    const SERVICE_DOES_NOT_EXIST: i32 = 1060;
5873    /// `ERROR_SERVICE_MARKED_FOR_DELETE`. `DeleteService` is asynchronous: the
5874    /// registration remains in this state until every open service handle is
5875    /// closed, and callers must not mistake that short window for a leak.
5876    const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
5877    /// `ERROR_ACCESS_DENIED`.
5878    const ACCESS_DENIED: i32 = 5;
5879    const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
5880    const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
5881
5882    const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
5883         choose \"Run as administrator\".";
5884
5885    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5886        Ok(match mode {
5887            StartMode::Boot => Box::new(ScmControl),
5888            StartMode::Login => Box::new(TaskControl),
5889        })
5890    }
5891
5892    // -- the Service Control Manager -----------------------------------------
5893
5894    #[derive(Debug)]
5895    struct ScmControl;
5896
5897    /// Turns a `windows-service` failure into this module's error, keeping the
5898    /// "needs elevation" case separate: an operator told "access is denied" and
5899    /// an operator told "run this elevated" are not equally well served.
5900    fn scm_error(
5901        operation: &'static str,
5902        name: &str,
5903        error: &windows_service::Error,
5904    ) -> ServiceError {
5905        let raw = match error {
5906            windows_service::Error::Winapi(io) => io.raw_os_error(),
5907            _ => None,
5908        };
5909        let detail = match error {
5910            windows_service::Error::Winapi(io) => io.to_string(),
5911            other => other.to_string(),
5912        };
5913        if raw == Some(ACCESS_DENIED) {
5914            return ServiceError::NeedsElevation {
5915                operation,
5916                name: name.to_string(),
5917                detail,
5918                remedy: ELEVATION_REMEDY,
5919            };
5920        }
5921        ServiceError::Control {
5922            operation,
5923            name: name.to_string(),
5924            manager: "the Windows Service Control Manager",
5925            detail,
5926        }
5927    }
5928
5929    fn open_manager(
5930        access: ServiceManagerAccess,
5931        operation: &'static str,
5932        name: &str,
5933    ) -> Result<ServiceManager, ServiceError> {
5934        ServiceManager::local_computer(None::<&OsStr>, access)
5935            .map_err(|error| scm_error(operation, name, &error))
5936    }
5937
5938    impl ServiceControl for ScmControl {
5939        fn manager(&self) -> DefinitionKind {
5940            DefinitionKind::WindowsService
5941        }
5942
5943        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5944            let spec = windows_service_spec(plan);
5945            let manager = open_manager(
5946                ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
5947                "install",
5948                &spec.name,
5949            )?;
5950            let info = ServiceInfo {
5951                name: OsString::from(&spec.name),
5952                display_name: OsString::from(&spec.display_name),
5953                // Never INTERACTIVE_PROCESS: see `windows_service_descriptor`.
5954                service_type: ServiceType::OWN_PROCESS,
5955                start_type: if spec.automatic_start {
5956                    ServiceStartType::AutoStart
5957                } else {
5958                    ServiceStartType::OnDemand
5959                },
5960                error_control: ServiceErrorControl::Normal,
5961                executable_path: plan.binary().to_path_buf(),
5962                launch_arguments: plan.arguments().to_vec(),
5963                dependencies: Vec::new(),
5964                // `None` is LocalSystem, which is the account `d2`'s DACL admits.
5965                account_name: spec.account.as_ref().map(OsString::from),
5966                // No password is ever taken, asked for, or stored. An account
5967                // that needed one would be an account this installer refuses.
5968                account_password: None,
5969            };
5970            let service = manager
5971                .create_service(
5972                    &info,
5973                    ServiceAccess::CHANGE_CONFIG
5974                        | ServiceAccess::QUERY_CONFIG
5975                        | ServiceAccess::QUERY_STATUS
5976                        | ServiceAccess::START
5977                        | ServiceAccess::STOP
5978                        | ServiceAccess::DELETE,
5979                )
5980                .map_err(|error| scm_error("install", &spec.name, &error))?;
5981            service
5982                .set_description(&spec.description)
5983                .map_err(|error| scm_error("describe", &spec.name, &error))?;
5984            service
5985                .update_failure_actions(ServiceFailureActions {
5986                    reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
5987                    reboot_msg: None,
5988                    command: None,
5989                    // Three identical restart actions rather than one: the
5990                    // Service Control Manager applies the first action to the
5991                    // first failure, the second to the second, and the last to
5992                    // every failure after that. One action would leave the
5993                    // second and later failures unhandled, which is a service
5994                    // that comes back once and then stays down.
5995                    actions: Some(vec![
5996                        ServiceAction {
5997                            action_type: ServiceActionType::Restart,
5998                            delay: spec.restart.delay(),
5999                        };
6000                        3
6001                    ]),
6002                })
6003                .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
6004            // Without this the failure actions apply only to a crash, and a
6005            // daemon that exits non-zero is not a crash.
6006            service
6007                .set_failure_actions_on_non_crash_failures(true)
6008                .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
6009            Ok(ServiceDefinition::windows_service(plan))
6010        }
6011
6012        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6013            let manager =
6014                open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
6015            let service = match manager.open_service(
6016                identity.name(),
6017                ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
6018            ) {
6019                Ok(service) => Some(service),
6020                Err(error) if is_missing(&error) => return Ok(false),
6021                // A previous caller has already asked SCM to delete this
6022                // registration. That is the desired state, not an uninstall
6023                // failure. Wait for SCM to release the name so a following
6024                // `service install` can use it immediately.
6025                Err(error) if is_marked_for_delete(&error) => None,
6026                Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
6027            };
6028            if let Some(service) = service {
6029                // A running service can be deleted, but it lingers until it stops.
6030                // Stopping first is what makes `uninstall` followed by `install`
6031                // work in one sitting, which is what the start-mode switch needs.
6032                if let Ok(status) = service.query_status()
6033                    && status.current_state != ServiceState::Stopped
6034                {
6035                    let _ = service.stop();
6036                }
6037                match service.delete() {
6038                    Ok(()) => {}
6039                    // DeleteService is idempotent from the operator's point of
6040                    // view. Another process can mark the service between our
6041                    // OpenService and DeleteService calls.
6042                    Err(error) if is_marked_for_delete(&error) => {}
6043                    Err(error) => {
6044                        return Err(scm_error("uninstall", identity.name(), &error));
6045                    }
6046                }
6047                drop(service);
6048            }
6049
6050            // `DeleteService` marks a registration for deletion and returns;
6051            // SCM removes it only after the last service handle closes. Drop
6052            // ours before polling, then wait for the observable postcondition
6053            // promised by `uninstall`: an immediate status check must not see
6054            // a registration that is merely on its way out. This also keeps a
6055            // stop/uninstall/install sequence deterministic on busy hosts.
6056            let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
6057                match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
6058                    Ok(service) => {
6059                        drop(service);
6060                        Ok(false)
6061                    }
6062                    Err(error) if is_missing(&error) => Ok(true),
6063                    Err(error) if is_marked_for_delete(&error) => Ok(false),
6064                    Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
6065                }
6066            })?;
6067            if !absent {
6068                return Err(ServiceError::Control {
6069                    operation: "verify uninstall of",
6070                    name: identity.name().to_string(),
6071                    manager: "the Windows Service Control Manager",
6072                    detail: format!(
6073                        "the registration was still visible {} seconds after DeleteService; \
6074                         retry `service uninstall` from an elevated prompt",
6075                        DELETE_TIMEOUT.as_secs()
6076                    ),
6077                });
6078            }
6079            Ok(true)
6080        }
6081
6082        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6083            let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
6084            let service = match manager.open_service(
6085                identity.name(),
6086                ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
6087            ) {
6088                Ok(service) => service,
6089                Err(error) if is_missing(&error) => return Ok(None),
6090                Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
6091            };
6092            let config = service
6093                .query_config()
6094                .map_err(|error| scm_error("inspect", identity.name(), &error))?;
6095            let status = service
6096                .query_status()
6097                .map_err(|error| scm_error("inspect", identity.name(), &error))?;
6098            let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
6099                actions
6100                    .actions
6101                    .and_then(|actions| actions.into_iter().next())
6102                    .filter(|action| action.action_type == ServiceActionType::Restart)
6103                    .map(|action| action.delay)
6104            });
6105            Ok(Some(Registration {
6106                manager: DefinitionKind::WindowsService,
6107                start_mode: StartMode::Boot,
6108                // `lpBinaryPathName` holds the executable *and* its arguments
6109                // as one string, which is why this module carries its own
6110                // parser rather than trusting `executable_path`'s name.
6111                command_line: config.executable_path.to_string_lossy().into_owned(),
6112                account: config
6113                    .account_name
6114                    .map(|account| account.to_string_lossy().into_owned()),
6115                running: status.current_state == ServiceState::Running,
6116                starts_automatically: config.start_type == ServiceStartType::AutoStart,
6117                restart_delay,
6118            }))
6119        }
6120
6121        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6122            let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
6123            let service = manager
6124                .open_service(identity.name(), ServiceAccess::START)
6125                .map_err(|error| scm_error("start", identity.name(), &error))?;
6126            service
6127                .start::<&OsStr>(&[])
6128                .map_err(|error| scm_error("start", identity.name(), &error))
6129        }
6130
6131        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6132            let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
6133            let service = manager
6134                .open_service(
6135                    identity.name(),
6136                    ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
6137                )
6138                .map_err(|error| scm_error("stop", identity.name(), &error))?;
6139            let status = service
6140                .query_status()
6141                .map_err(|error| scm_error("stop", identity.name(), &error))?;
6142            if status.current_state == ServiceState::Stopped {
6143                return Ok(false);
6144            }
6145            service
6146                .stop()
6147                .map_err(|error| scm_error("stop", identity.name(), &error))?;
6148            Ok(true)
6149        }
6150    }
6151
6152    fn is_missing(error: &windows_service::Error) -> bool {
6153        matches!(error, windows_service::Error::Winapi(io)
6154            if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
6155    }
6156
6157    pub(super) fn is_marked_for_delete(error: &windows_service::Error) -> bool {
6158        matches!(error, windows_service::Error::Winapi(io)
6159            if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
6160    }
6161
6162    pub(super) fn wait_until_scm_absent(
6163        timeout: Duration,
6164        poll_interval: Duration,
6165        mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
6166    ) -> Result<bool, ServiceError> {
6167        let deadline = Instant::now() + timeout;
6168        loop {
6169            if probe_absent()? {
6170                return Ok(true);
6171            }
6172            if Instant::now() >= deadline {
6173                return Ok(false);
6174            }
6175            std::thread::sleep(poll_interval);
6176        }
6177    }
6178
6179    // -- Task Scheduler ------------------------------------------------------
6180
6181    #[derive(Debug)]
6182    struct TaskControl;
6183
6184    fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
6185        if detail.to_ascii_lowercase().contains("access is denied") {
6186            return ServiceError::NeedsElevation {
6187                operation,
6188                name: name.to_string(),
6189                detail,
6190                remedy: ELEVATION_REMEDY,
6191            };
6192        }
6193        ServiceError::Control {
6194            operation,
6195            name: name.to_string(),
6196            manager: "Windows Task Scheduler",
6197            detail,
6198        }
6199    }
6200
6201    fn schtasks(
6202        operation: &'static str,
6203        name: &str,
6204        arguments: &[&OsStr],
6205    ) -> Result<(bool, String), ServiceError> {
6206        match run("schtasks.exe", arguments) {
6207            Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
6208            Err(error) => Err(task_error(operation, name, error.to_string())),
6209        }
6210    }
6211
6212    /// Task Scheduler reads `/XML` files as UTF-16, so the document is written
6213    /// as UTF-16 little-endian with a byte-order mark rather than as UTF-8.
6214    fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
6215        let mut bytes = vec![0xFF, 0xFE];
6216        for unit in text.encode_utf16() {
6217            bytes.extend_from_slice(&unit.to_le_bytes());
6218        }
6219        std::fs::write(path, bytes)
6220    }
6221
6222    impl ServiceControl for TaskControl {
6223        fn manager(&self) -> DefinitionKind {
6224            DefinitionKind::WindowsScheduledTask
6225        }
6226
6227        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6228            let name = plan.identity().name().to_string();
6229            let principal = TaskPrincipal::current()?;
6230            let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
6231            let directory = tempfile::tempdir()
6232                .map_err(|error| task_error("install", &name, error.to_string()))?;
6233            let document = directory.path().join("task.xml");
6234            write_utf16(&document, definition.text())
6235                .map_err(|error| task_error("install", &name, error.to_string()))?;
6236            let (ok, message) = schtasks(
6237                "install",
6238                &name,
6239                &[
6240                    OsStr::new("/Create"),
6241                    OsStr::new("/TN"),
6242                    OsStr::new(&name),
6243                    OsStr::new("/XML"),
6244                    document.as_os_str(),
6245                ],
6246            )?;
6247            if !ok {
6248                return Err(task_error("install", &name, message));
6249            }
6250            Ok(definition)
6251        }
6252
6253        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6254            if self.query(identity)?.is_none() {
6255                return Ok(false);
6256            }
6257            let name = identity.name().to_string();
6258            let (ok, message) = schtasks(
6259                "uninstall",
6260                &name,
6261                &[
6262                    OsStr::new("/Delete"),
6263                    OsStr::new("/TN"),
6264                    OsStr::new(&name),
6265                    OsStr::new("/F"),
6266                ],
6267            )?;
6268            if !ok {
6269                return Err(task_error("uninstall", &name, message));
6270            }
6271            Ok(true)
6272        }
6273
6274        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6275            let name = identity.name().to_string();
6276            let (ok, document) = schtasks(
6277                "inspect",
6278                &name,
6279                &[
6280                    OsStr::new("/Query"),
6281                    OsStr::new("/TN"),
6282                    OsStr::new(&name),
6283                    OsStr::new("/XML"),
6284                    OsStr::new("ONE"),
6285                ],
6286            )?;
6287            if !ok {
6288                // `schtasks` reports a missing task and a broken Task Scheduler
6289                // the same way, with a non-zero exit; there is no distinct code.
6290                // Treating it as absence is the safe reading: the caller either
6291                // installs, which will then fail loudly, or reports "not
6292                // installed", which is what an operator with no task sees.
6293                return Ok(None);
6294            }
6295            let command = xml_value(&document, "Command").unwrap_or_default();
6296            let arguments = xml_value(&document, "Arguments").unwrap_or_default();
6297            let command_line = if arguments.is_empty() {
6298                super::quote_argument(&command)
6299            } else {
6300                format!("{} {arguments}", super::quote_argument(&command))
6301            };
6302            Ok(Some(Registration {
6303                manager: DefinitionKind::WindowsScheduledTask,
6304                start_mode: StartMode::Login,
6305                command_line,
6306                account: xml_value(&document, "UserId"),
6307                running: task_is_running(&name),
6308                starts_automatically: super::windows_login_task_starts_automatically(&document),
6309                restart_delay: xml_value(&document, "Interval")
6310                    .as_deref()
6311                    .and_then(parse_iso8601),
6312            }))
6313        }
6314
6315        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6316            let name = identity.name().to_string();
6317            let (ok, message) = schtasks(
6318                "start",
6319                &name,
6320                &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
6321            )?;
6322            if ok {
6323                Ok(())
6324            } else {
6325                Err(task_error("start", &name, message))
6326            }
6327        }
6328
6329        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6330            let running = self
6331                .query(identity)?
6332                .is_some_and(|registration| registration.running);
6333            if !running {
6334                return Ok(false);
6335            }
6336            let name = identity.name().to_string();
6337            let (ok, message) = schtasks(
6338                "stop",
6339                &name,
6340                &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
6341            )?;
6342            if ok {
6343                Ok(true)
6344            } else {
6345                Err(task_error("stop", &name, message))
6346            }
6347        }
6348    }
6349
6350    /// Whether Task Scheduler reports the task as running.
6351    ///
6352    /// **This is the one value in this module read from localised output.**
6353    /// `schtasks /Query /FO CSV` prints a `Status` column in the machine's
6354    /// display language, and Task Scheduler exposes no locale-independent
6355    /// equivalent short of COM. On a non-English Windows this therefore reports
6356    /// `false` for a task that is in fact running.
6357    ///
6358    /// That is stated rather than hidden because of what does *not* depend on
6359    /// it: Journey 5's liveness question is answered by the last successful
6360    /// GitHub contact, which is a timestamp this product writes itself and no
6361    /// locale can move. Nothing in the Definition of Done rests on this
6362    /// boolean.
6363    fn task_is_running(name: &str) -> bool {
6364        let Ok((true, stdout, _)) = run(
6365            "schtasks.exe",
6366            &[
6367                OsStr::new("/Query"),
6368                OsStr::new("/TN"),
6369                OsStr::new(name),
6370                OsStr::new("/FO"),
6371                OsStr::new("CSV"),
6372                OsStr::new("/NH"),
6373            ],
6374        ) else {
6375            return false;
6376        };
6377        stdout
6378            .lines()
6379            .filter_map(|line| line.rsplit(',').next())
6380            .any(|status| {
6381                status
6382                    .trim()
6383                    .trim_matches('"')
6384                    .eq_ignore_ascii_case("running")
6385            })
6386    }
6387
6388    /// Parses the `PT<n>M` shape this module writes, and the `PT<n>S` shape
6389    /// Task Scheduler would accept if it took seconds. Anything richer -- days,
6390    /// hours, a combination -- is `None` rather than a guess.
6391    fn parse_iso8601(value: &str) -> Option<Duration> {
6392        let rest = value.strip_prefix("PT")?;
6393        if let Some(minutes) = rest.strip_suffix('M') {
6394            return minutes
6395                .parse::<u64>()
6396                .ok()
6397                .map(|minutes| Duration::from_secs(minutes * 60));
6398        }
6399        rest.strip_suffix('S')?
6400            .parse::<u64>()
6401            .ok()
6402            .map(Duration::from_secs)
6403    }
6404}
6405
6406// ---------------------------------------------------------------------------
6407// Unix: shared plumbing
6408// ---------------------------------------------------------------------------
6409
6410/// Writes a definition file, creating its directory, and says when the refusal
6411/// was a permissions one.
6412///
6413/// Both Unix backends write a file into a directory only `root` can write —
6414/// `/Library/LaunchDaemons` and `/etc/systemd/system` — and an operator who ran
6415/// `service install` without `sudo` deserves to be told that rather than handed
6416/// an `EACCES`.
6417#[cfg(unix)]
6418fn write_definition(
6419    operation: &'static str,
6420    name: &str,
6421    path: &Path,
6422    text: &str,
6423    remedy: &'static str,
6424) -> Result<(), ServiceError> {
6425    if let Some(parent) = path.parent()
6426        && let Err(error) = std::fs::create_dir_all(parent)
6427        && error.kind() != std::io::ErrorKind::AlreadyExists
6428    {
6429        return Err(definition_error(operation, name, error, remedy, parent));
6430    }
6431    std::fs::write(path, text)
6432        .map_err(|error| definition_error(operation, name, error, remedy, path))
6433}
6434
6435/// Replaces an installed definition with [`DefinitionDrift::rendered`].
6436///
6437/// Through a temporary file in the same directory and a rename, so a failure
6438/// at any point leaves the old definition in place rather than a truncated
6439/// one. The mode is set to `0644` before the rename, as `service install`
6440/// leaves it: launchd refuses a plist that others can write.
6441#[cfg(all(unix, any(target_os = "macos", test)))]
6442fn replace_definition(name: &str, drift: &DefinitionDrift) -> Result<(), ServiceError> {
6443    use std::io::Write as _;
6444
6445    let path = drift.path.as_path();
6446    let directory = path.parent().unwrap_or_else(|| Path::new("/"));
6447    let failed = |error: std::io::Error, at: &Path| {
6448        definition_error("rewrite", name, error, SUDO_REMEDY, at)
6449    };
6450    let mut staged =
6451        tempfile::NamedTempFile::new_in(directory).map_err(|error| failed(error, directory))?;
6452    staged
6453        .write_all(drift.rendered.as_bytes())
6454        .and_then(|()| staged.as_file().sync_all())
6455        .map_err(|error| failed(error, staged.path()))?;
6456    std::fs::set_permissions(
6457        staged.path(),
6458        <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o644),
6459    )
6460    .map_err(|error| failed(error, path))?;
6461    staged
6462        .persist(path)
6463        .map(drop)
6464        .map_err(|error| failed(error.error, path))
6465}
6466
6467#[cfg(unix)]
6468fn definition_error(
6469    operation: &'static str,
6470    name: &str,
6471    error: std::io::Error,
6472    remedy: &'static str,
6473    path: &Path,
6474) -> ServiceError {
6475    let detail = format!("{}: {error}", path.display());
6476    if error.kind() == std::io::ErrorKind::PermissionDenied {
6477        ServiceError::NeedsElevation {
6478            operation,
6479            name: name.to_string(),
6480            detail,
6481            remedy,
6482        }
6483    } else {
6484        ServiceError::Control {
6485            operation,
6486            name: name.to_string(),
6487            manager: "the local service manager",
6488            detail,
6489        }
6490    }
6491}
6492
6493/// `sudo` is the remedy on both Unixes, and the message says which command.
6494#[cfg(unix)]
6495const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
6496     `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
6497     agent not running until you sign in.";
6498
6499// ---------------------------------------------------------------------------
6500// macOS
6501// ---------------------------------------------------------------------------
6502
6503#[cfg(target_os = "macos")]
6504mod sys {
6505    //! One manager, two domains. `--start-at boot` is a LaunchDaemon in
6506    //! `/Library/LaunchDaemons`, loaded into launchd's `system` domain;
6507    //! `--start-at login` is a LaunchAgent in the operator's own
6508    //! `~/Library/LaunchAgents`, loaded into `gui/<uid>`.
6509    //!
6510    //! `launchctl print` is the only inspection command here whose output is
6511    //! parsed, and it is English-only on every macOS release — launchd has no
6512    //! localisation for it. The registration's *existence* is decided by the
6513    //! plist file rather than by that output, so a launchd that changed its
6514    //! wording would cost this module the running/not-running line and nothing
6515    //! else.
6516
6517    use std::ffi::OsStr;
6518    use std::path::PathBuf;
6519    use std::time::Duration;
6520
6521    use runner_manager_domain::model::StartMode;
6522
6523    use super::{
6524        DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
6525        SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
6526        enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
6527        write_definition, xml_value,
6528    };
6529
6530    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6531        Ok(Box::new(LaunchdControl { mode }))
6532    }
6533
6534    #[derive(Debug)]
6535    struct LaunchdControl {
6536        mode: StartMode,
6537    }
6538
6539    impl LaunchdControl {
6540        /// launchd's domain target for this start mode.
6541        fn domain(&self) -> String {
6542            match self.mode {
6543                StartMode::Boot => "system".to_string(),
6544                // SAFETY: `getuid` reads the calling process's real user id and
6545                // cannot fail; it takes no arguments and touches no memory.
6546                StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
6547            }
6548        }
6549
6550        fn service_target(&self, identity: &ServiceIdentity) -> String {
6551            format!("{}/{}", self.domain(), identity.launchd_label())
6552        }
6553
6554        /// Where this domain's plist lives, when the domain has a home to put
6555        /// it in.
6556        fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
6557            let file = format!("{}.plist", identity.launchd_label());
6558            match self.mode {
6559                StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
6560                StartMode::Login => {
6561                    home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
6562                }
6563            }
6564        }
6565
6566        fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
6567            ServiceError::Control {
6568                operation,
6569                name: name.to_string(),
6570                manager: "launchd",
6571                detail,
6572            }
6573        }
6574
6575        fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
6576            match run("launchctl", arguments) {
6577                Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
6578                Err(error) => (false, error.to_string()),
6579            }
6580        }
6581    }
6582
6583    impl ServiceControl for LaunchdControl {
6584        fn manager(&self) -> DefinitionKind {
6585            DefinitionKind::LaunchdPlist
6586        }
6587
6588        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6589            let name = plan.identity().name().to_string();
6590            let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
6591            let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
6592                return Err(self.failed(
6593                    "install",
6594                    &name,
6595                    "this account has no home directory, so there is nowhere to put a \
6596                     LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
6597                     /Library/LaunchDaemons."
6598                        .to_string(),
6599                ));
6600            };
6601            write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
6602            let target = self.domain();
6603            let (ok, message) = self.launchctl(&[
6604                OsStr::new("bootstrap"),
6605                OsStr::new(&target),
6606                path.as_os_str(),
6607            ]);
6608            if !ok {
6609                // Leave nothing behind: a plist launchd refused is a file that
6610                // would make the next `service status` claim a registration
6611                // that does not exist.
6612                let _ = std::fs::remove_file(&path);
6613                if message.to_ascii_lowercase().contains("permission denied") {
6614                    return Err(ServiceError::NeedsElevation {
6615                        operation: "install",
6616                        name,
6617                        detail: message,
6618                        remedy: SUDO_REMEDY,
6619                    });
6620                }
6621                return Err(self.failed("install", &name, message));
6622            }
6623            // A previously disabled label stays disabled through a bootstrap,
6624            // which is a service that is installed and will never start.
6625            let service_target = self.service_target(plan.identity());
6626            enable_launchd_registration(
6627                |arguments| self.launchctl(arguments),
6628                &target,
6629                &service_target,
6630                &path,
6631                &name,
6632                SUDO_REMEDY,
6633            )?;
6634            Ok(definition)
6635        }
6636
6637        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6638            let Some(path) = self.plist_path(identity) else {
6639                return Ok(false);
6640            };
6641            if !path.exists() {
6642                return Ok(false);
6643            }
6644            let target = self.service_target(identity);
6645            let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
6646            if !ok
6647                && !message.to_ascii_lowercase().contains("no such process")
6648                && !message.contains("113")
6649            {
6650                if message.to_ascii_lowercase().contains("permission denied") {
6651                    return Err(ServiceError::NeedsElevation {
6652                        operation: "uninstall",
6653                        name: identity.name().to_string(),
6654                        detail: message,
6655                        remedy: SUDO_REMEDY,
6656                    });
6657                }
6658                return Err(self.failed("uninstall", identity.name(), message));
6659            }
6660            // Only the definition. `05-infrastructure.md` item 5: nothing else
6661            // on this host is touched here or anywhere below it.
6662            std::fs::remove_file(&path).map_err(|error| {
6663                super::definition_error(
6664                    "uninstall",
6665                    identity.name(),
6666                    error,
6667                    SUDO_REMEDY,
6668                    path.as_path(),
6669                )
6670            })?;
6671            Ok(true)
6672        }
6673
6674        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6675            let Some(path) = self.plist_path(identity) else {
6676                return Ok(None);
6677            };
6678            let Ok(document) = std::fs::read_to_string(&path) else {
6679                return Ok(None);
6680            };
6681            let target = self.service_target(identity);
6682            let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
6683            Ok(Some(Registration {
6684                manager: DefinitionKind::LaunchdPlist,
6685                start_mode: self.mode,
6686                command_line: program_arguments(&document),
6687                account: plist_string_value(&document, "UserName")
6688                    .or_else(|| Some("the invoking user".to_string())),
6689                running: loaded && printed.contains("state = running"),
6690                starts_automatically: document.contains("<key>RunAtLoad</key>")
6691                    && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
6692                restart_delay: xml_value(
6693                    super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
6694                    "integer",
6695                )
6696                .and_then(|value| value.parse::<u64>().ok())
6697                .map(Duration::from_secs),
6698            }))
6699        }
6700
6701        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6702            let target = self.service_target(identity);
6703            let (ok, message) = self.launchctl(&[
6704                OsStr::new("kickstart"),
6705                OsStr::new("-k"),
6706                OsStr::new(&target),
6707            ]);
6708            if ok {
6709                Ok(())
6710            } else {
6711                Err(self.failed("start", identity.name(), message))
6712            }
6713        }
6714
6715        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6716            let running = self
6717                .query(identity)?
6718                .is_some_and(|registration| registration.running);
6719            if !running {
6720                return Ok(false);
6721            }
6722            let target = self.service_target(identity);
6723            let (ok, message) = self.launchctl(&[
6724                OsStr::new("kill"),
6725                OsStr::new("SIGTERM"),
6726                OsStr::new(&target),
6727            ]);
6728            if ok {
6729                Ok(true)
6730            } else {
6731                Err(self.failed("stop", identity.name(), message))
6732            }
6733        }
6734    }
6735
6736    /// Boots the job out and bootstraps it again from `plist`, from a shell
6737    /// that outlives this process.
6738    ///
6739    /// `setsid` puts the shell in a session and process group of its own. On a
6740    /// bootout launchd sends SIGTERM to the job's whole process group, the
6741    /// process that asked for the bootout included, and spares a child in
6742    /// another session (measured on macOS 27). Without `setsid` the helper
6743    /// survived that only by luck: one run in four left the job unloaded. The
6744    /// work runs in a background subshell, so the outer shell exits at once and
6745    /// is reaped here rather than left behind. `bootout` blocks until the daemon
6746    /// has exited; the bootstrap is retried for a minute because launchd
6747    /// refuses it while the old job is still being torn down. When the job is
6748    /// still loaded after a failed bootout, every bootstrap fails and the
6749    /// running daemon carries on as it was.
6750    pub(super) fn reload_detached(
6751        identity: &ServiceIdentity,
6752        mode: StartMode,
6753        plist: &std::path::Path,
6754    ) -> Result<(), ServiceError> {
6755        use std::os::unix::process::CommandExt;
6756
6757        const SCRIPT: &str = r#"(
6758  sleep 1
6759  /bin/launchctl bootout "$1"
6760  tries=0
6761  until /bin/launchctl bootstrap "$2" "$3"; do
6762    tries=$((tries + 1))
6763    if [ "$tries" -ge 60 ]; then
6764      echo "runner-manager: launchd did not load $3 again; run: $4" >&2
6765      exit 1
6766    fi
6767    sleep 1
6768  done
6769) </dev/null &"#;
6770
6771        let control = LaunchdControl { mode };
6772        // Once the job is booted out nothing holds the single-instance lock,
6773        // so a reinstall in the same start mode is the remedy.
6774        let reinstall = match mode {
6775            StartMode::Boot => "sudo runner-manager service install --start-at boot",
6776            StartMode::Login => "runner-manager service install --start-at login",
6777        };
6778        let mut command = std::process::Command::new("/bin/sh");
6779        command
6780            .args(["-c", SCRIPT, "runner-manager-reload"])
6781            .arg(control.service_target(identity))
6782            .arg(control.domain())
6783            .arg(plist)
6784            .arg(reinstall)
6785            .stdin(std::process::Stdio::null());
6786        // SAFETY: one async-signal-safe system call between `fork` and `exec`,
6787        // touching no memory, allocator or lock.
6788        unsafe {
6789            command.pre_exec(|| {
6790                if libc::setsid() == -1 {
6791                    return Err(std::io::Error::last_os_error());
6792                }
6793                Ok(())
6794            });
6795        }
6796        let status = command.status().map_err(|error| {
6797            control.failed("reload", identity.name(), format!("/bin/sh: {error}"))
6798        })?;
6799        if status.success() {
6800            Ok(())
6801        } else {
6802            Err(control.failed(
6803                "reload",
6804                identity.name(),
6805                format!("the reload helper exited with {status}"),
6806            ))
6807        }
6808    }
6809
6810    /// Rebuilds the command line from a plist's `ProgramArguments` array.
6811    fn program_arguments(document: &str) -> String {
6812        let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
6813            return String::new();
6814        };
6815        let Some(end) = rest.find("</array>") else {
6816            return String::new();
6817        };
6818        let mut out = Vec::new();
6819        let mut cursor = &rest[..end];
6820        while let Some(open) = cursor.find("<string>") {
6821            let after = &cursor[open + "<string>".len()..];
6822            let Some(close) = after.find("</string>") else {
6823                break;
6824            };
6825            out.push(quote_argument(&super::xml_unescape(&after[..close])));
6826            cursor = &after[close..];
6827        }
6828        out.join(" ")
6829    }
6830}
6831
6832// ---------------------------------------------------------------------------
6833// Linux and the other Unixes
6834// ---------------------------------------------------------------------------
6835
6836#[cfg(all(unix, not(target_os = "macos")))]
6837mod sys {
6838    //! One manager, two domains. `--start-at boot` is a system unit in
6839    //! `/etc/systemd/system`, wanted by `multi-user.target`; `--start-at login`
6840    //! is a user unit in `~/.config/systemd/user`, wanted by `default.target`.
6841    //!
6842    //! Every inspection here goes through `systemctl is-active` and
6843    //! `systemctl is-enabled`, whose output is a fixed machine-readable word
6844    //! rather than a sentence, so nothing in this module depends on the
6845    //! machine's display language.
6846
6847    use std::ffi::OsStr;
6848    use std::path::PathBuf;
6849    use std::time::Duration;
6850
6851    use runner_manager_domain::model::StartMode;
6852
6853    use super::{
6854        DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
6855        SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
6856        home_directory, ini_directives, run, write_definition,
6857    };
6858
6859    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6860        Ok(Box::new(SystemdControl { mode }))
6861    }
6862
6863    #[derive(Debug)]
6864    struct SystemdControl {
6865        mode: StartMode,
6866    }
6867
6868    impl SystemdControl {
6869        fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
6870            let file = identity.systemd_unit();
6871            match self.mode {
6872                StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
6873                StartMode::Login => {
6874                    home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
6875                }
6876            }
6877        }
6878
6879        /// `systemctl`, with `--user` for the login domain.
6880        fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
6881            let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
6882            if self.mode == StartMode::Login {
6883                all.push(OsStr::new("--user"));
6884            }
6885            all.extend(arguments.iter().map(OsStr::new));
6886            match run("systemctl", &all) {
6887                Ok((ok, stdout, stderr)) => (
6888                    ok,
6889                    if stdout.trim().is_empty() {
6890                        stderr
6891                    } else {
6892                        stdout
6893                    },
6894                ),
6895                Err(error) => (false, error.to_string()),
6896            }
6897        }
6898
6899        fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
6900            ServiceError::Control {
6901                operation,
6902                name: name.to_string(),
6903                manager: "systemd",
6904                detail,
6905            }
6906        }
6907    }
6908
6909    impl ServiceControl for SystemdControl {
6910        fn manager(&self) -> DefinitionKind {
6911            DefinitionKind::SystemdUnit
6912        }
6913
6914        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6915            let name = plan.identity().name().to_string();
6916            let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
6917            let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
6918                return Err(self.failed(
6919                    "install",
6920                    &name,
6921                    "this account has no home directory, so there is nowhere to put a systemd \
6922                     user unit. Use --start-at boot, which installs a system unit under \
6923                     /etc/systemd/system."
6924                        .to_string(),
6925                ));
6926            };
6927            write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
6928            let unit = plan.identity().systemd_unit();
6929            let (reloaded, message) = self.systemctl(&["daemon-reload"]);
6930            if !reloaded {
6931                let _ = std::fs::remove_file(&path);
6932                return Err(self.failed("install", &name, message));
6933            }
6934            let (enabled, message) = self.systemctl(&["enable", &unit]);
6935            if !enabled {
6936                let _ = std::fs::remove_file(&path);
6937                let _ = self.systemctl(&["daemon-reload"]);
6938                return Err(self.failed("install", &name, message));
6939            }
6940            Ok(definition)
6941        }
6942
6943        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6944            let Some(path) = self.unit_path(identity) else {
6945                return Ok(false);
6946            };
6947            if !path.exists() {
6948                return Ok(false);
6949            }
6950            let unit = identity.systemd_unit();
6951            // `--now` stops it as well as disabling it. A failure here is not
6952            // fatal: the unit file is about to go, and refusing to remove it
6953            // because `systemctl` disliked something would leave the host with
6954            // a unit nothing manages.
6955            let _ = self.systemctl(&["disable", "--now", &unit]);
6956            std::fs::remove_file(&path).map_err(|error| {
6957                super::definition_error(
6958                    "uninstall",
6959                    identity.name(),
6960                    error,
6961                    SUDO_REMEDY,
6962                    path.as_path(),
6963                )
6964            })?;
6965            let _ = self.systemctl(&["daemon-reload"]);
6966            Ok(true)
6967        }
6968
6969        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6970            let Some(path) = self.unit_path(identity) else {
6971                return Ok(None);
6972            };
6973            let Ok(document) = std::fs::read_to_string(&path) else {
6974                return Ok(None);
6975            };
6976            let unit = identity.systemd_unit();
6977            let directives = ini_directives(&document, "Service");
6978            let (_, active) = self.systemctl(&["is-active", &unit]);
6979            let (_, enabled) = self.systemctl(&["is-enabled", &unit]);
6980            Ok(Some(Registration {
6981                manager: DefinitionKind::SystemdUnit,
6982                start_mode: self.mode,
6983                command_line: directives.get("ExecStart").cloned().unwrap_or_default(),
6984                account: directives.get("User").cloned().or_else(|| {
6985                    Some(match self.mode {
6986                        StartMode::Boot => "root".to_string(),
6987                        StartMode::Login => "the invoking user".to_string(),
6988                    })
6989                }),
6990                running: active.trim() == "active",
6991                starts_automatically: enabled.trim() == "enabled",
6992                restart_delay: directives
6993                    .get("RestartSec")
6994                    .and_then(|value| value.trim().trim_end_matches('s').parse::<u64>().ok())
6995                    .map(Duration::from_secs),
6996            }))
6997        }
6998
6999        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7000            let unit = identity.systemd_unit();
7001            let (ok, message) = self.systemctl(&["start", &unit]);
7002            if ok {
7003                Ok(())
7004            } else {
7005                Err(self.failed("start", identity.name(), message))
7006            }
7007        }
7008
7009        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7010            let running = self
7011                .query(identity)?
7012                .is_some_and(|registration| registration.running);
7013            if !running {
7014                return Ok(false);
7015            }
7016            let unit = identity.systemd_unit();
7017            let (ok, message) = self.systemctl(&["stop", &unit]);
7018            if ok {
7019                Ok(true)
7020            } else {
7021                Err(self.failed("stop", identity.name(), message))
7022            }
7023        }
7024    }
7025}
7026
7027/// What the macOS tests that load real launch agents share.
7028#[cfg(all(test, target_os = "macos"))]
7029pub(crate) mod launchd_fixture {
7030    use std::path::{Path, PathBuf};
7031
7032    /// Opts a macOS host into the tests that load launch agents into the
7033    /// caller's GUI session. CI's macOS leg sets it, and
7034    /// `privileged_tests_are_wired_into_ci.rs` asserts that it still does.
7035    const ACCEPTANCE: &str = "RUNNER_MANAGER_LAUNCHD_ACCEPTANCE";
7036
7037    /// Whether this run may load launch agents. Says so when it may not, since
7038    /// the test then passes having measured nothing.
7039    pub(crate) fn enabled() -> bool {
7040        let enabled = std::env::var_os(ACCEPTANCE).is_some_and(|value| value == "1");
7041        if !enabled {
7042            eprintln!("skipped: set {ACCEPTANCE}=1 to load test launch agents");
7043        }
7044        enabled
7045    }
7046
7047    /// A copy of the running test binary in `directory`, for a launchd job to
7048    /// run as its program.
7049    ///
7050    /// A copy, because a launchd job whose program lives on an external volume
7051    /// waits on a privacy approval nobody gives before `main` runs (measured
7052    /// with a target directory under `/Volumes`), and the temporary directory
7053    /// is on the boot volume.
7054    pub(crate) fn job_binary(directory: &Path) -> PathBuf {
7055        let binary = directory.join("launch-agent-job");
7056        std::fs::copy(std::env::current_exe().expect("test binary"), &binary)
7057            .expect("test binary copied");
7058        binary
7059    }
7060
7061    /// A launch agent loaded into the caller's GUI domain, booted out on drop.
7062    pub(crate) struct LoadedAgent(String);
7063
7064    impl LoadedAgent {
7065        /// Bootstraps `plist`, whose `Label` is `label`, and fails the test when
7066        /// launchd refuses it.
7067        pub(crate) fn bootstrap(label: &str, plist: &Path) -> Self {
7068            // SAFETY: `getuid` reads the calling process's real user id and
7069            // cannot fail.
7070            let domain = format!("gui/{}", unsafe { libc::getuid() });
7071            // Armed before the bootstrap, so a half-loaded job is removed too.
7072            let loaded = Self(format!("{domain}/{label}"));
7073            let bootstrap = std::process::Command::new("launchctl")
7074                .arg("bootstrap")
7075                .arg(&domain)
7076                .arg(plist)
7077                .output()
7078                .expect("launchctl runs");
7079            assert!(
7080                bootstrap.status.success(),
7081                "launchctl bootstrap {domain} refused {label}, so nothing was measured: {}",
7082                String::from_utf8_lossy(&bootstrap.stderr)
7083            );
7084            loaded
7085        }
7086
7087        /// `gui/<uid>/<label>`.
7088        pub(crate) fn target(&self) -> &str {
7089            &self.0
7090        }
7091    }
7092
7093    impl Drop for LoadedAgent {
7094        fn drop(&mut self) {
7095            let _ = std::process::Command::new("launchctl")
7096                .args(["bootout", &self.0])
7097                .output();
7098        }
7099    }
7100}
7101
7102#[cfg(test)]
7103mod tests {
7104    use super::*;
7105
7106    use std::collections::BTreeMap;
7107
7108    #[test]
7109    fn task_scheduler_omitting_default_enabled_still_means_automatic() {
7110        assert!(windows_login_task_starts_automatically(
7111            "<Task><Triggers><LogonTrigger></LogonTrigger></Triggers></Task>"
7112        ));
7113        assert!(windows_login_task_starts_automatically(
7114            "<Task><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Task>"
7115        ));
7116        assert!(!windows_login_task_starts_automatically(
7117            "<Task><LogonTrigger><Enabled>false</Enabled></LogonTrigger></Task>"
7118        ));
7119        assert!(!windows_login_task_starts_automatically(
7120            "<Task><BootTrigger></BootTrigger></Task>"
7121        ));
7122    }
7123
7124    // -----------------------------------------------------------------------
7125    // Fixtures
7126    // -----------------------------------------------------------------------
7127
7128    /// A plan against paths that exist on no platform, so that every renderer
7129    /// can be asserted on every leg of the CI matrix.
7130    fn linux_plan(mode: StartMode) -> InstallPlan {
7131        InstallPlan::unchecked(
7132            ServiceIdentity::product(),
7133            mode,
7134            "/opt/runner-manager/bin/runner-manager",
7135            ServiceDirectories {
7136                config: PathBuf::from("/var/lib/runner-manager/config"),
7137                state: PathBuf::from("/var/lib/runner-manager/state"),
7138                runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
7139                logs: PathBuf::from("/var/log/runner-manager"),
7140            },
7141        )
7142        .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
7143    }
7144
7145    fn windows_plan(mode: StartMode) -> InstallPlan {
7146        InstallPlan::unchecked(
7147            ServiceIdentity::product(),
7148            mode,
7149            "C:\\Program Files\\runner-manager\\runner-manager.exe",
7150            ServiceDirectories {
7151                config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
7152                state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
7153                runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
7154                logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
7155            },
7156        )
7157    }
7158
7159    /// Replaces one fragment of a rendered definition, and **fails the test if
7160    /// the fragment was not there**.
7161    ///
7162    /// Without the assertion a renderer change would quietly turn every
7163    /// "widened definition is rejected" test below into a test that reviews the
7164    /// unmodified definition and passes for the wrong reason. This is the
7165    /// single guard that keeps that class of vacuity out of this file.
7166    fn edited(text: &str, from: &str, to: &str) -> String {
7167        assert!(
7168            text.contains(from),
7169            "the rendered definition does not contain `{from}`, so this test would assert \
7170             nothing about a widened one"
7171        );
7172        text.replace(from, to)
7173    }
7174
7175    /// Every file under `root`, keyed by its path relative to `root`.
7176    fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
7177        fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
7178            let Ok(entries) = std::fs::read_dir(directory) else {
7179                return;
7180            };
7181            for entry in entries.flatten() {
7182                let path = entry.path();
7183                if path.is_dir() {
7184                    walk(&path, out);
7185                } else if let Ok(bytes) = std::fs::read(&path) {
7186                    out.insert(path, bytes);
7187                }
7188            }
7189        }
7190        let mut out = BTreeMap::new();
7191        for root in roots {
7192            walk(root, &mut out);
7193        }
7194        out
7195    }
7196
7197    struct Host {
7198        _root: tempfile::TempDir,
7199        paths: AppPaths,
7200        binary: PathBuf,
7201        /// A runner root inside this host's own temporary tree.
7202        ///
7203        /// Without it every `install` in this module would create and
7204        /// re-permission the **real** `%SystemDrive%\rman` on the machine
7205        /// running the tests, and would fail outright on any machine where that
7206        /// directory already exists with the access control `C:\` gives it.
7207        /// A sibling of the four application-data directories rather than a
7208        /// child of one, so `b1`'s overlap check has nothing to object to.
7209        runner_root: LocalAbsolutePath,
7210        controls: RecordingControls,
7211    }
7212
7213    impl Host {
7214        fn new() -> Self {
7215            let root = tempfile::tempdir().expect("a temporary directory");
7216            let paths = AppPaths::rooted_at(root.path());
7217            paths.create_all().expect("the four directories");
7218            let binary = root.path().join(if cfg!(windows) {
7219                "runner-manager.exe"
7220            } else {
7221                "runner-manager"
7222            });
7223            std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
7224            let runner_root = LocalAbsolutePath::new(
7225                root.path()
7226                    .join("runner-root")
7227                    .to_str()
7228                    .expect("a unicode temporary path"),
7229            )
7230            .expect("a local absolute path");
7231            Self {
7232                _root: root,
7233                paths,
7234                binary,
7235                runner_root,
7236                controls: RecordingControls::new(),
7237            }
7238        }
7239
7240        fn operations(&self) -> ServiceOperations {
7241            ServiceOperations::with_controls(
7242                self.paths.clone(),
7243                ServiceIdentity::product(),
7244                std::sync::Arc::new(self.controls.clone()),
7245            )
7246            .with_runner_root(self.runner_root.clone())
7247        }
7248
7249        fn request(&self, mode: StartMode) -> InstallRequest {
7250            InstallRequest::new(mode).for_binary(&self.binary)
7251        }
7252    }
7253
7254    #[cfg(windows)]
7255    #[test]
7256    fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
7257        let probes = std::cell::Cell::new(0);
7258        let absent =
7259            super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
7260                let next = probes.get() + 1;
7261                probes.set(next);
7262                Ok(next == 3)
7263            })
7264            .expect("the simulated SCM probe succeeds");
7265
7266        assert!(absent);
7267        assert_eq!(
7268            probes.get(),
7269            3,
7270            "uninstall must recheck after transient presence instead of treating it as a leak"
7271        );
7272    }
7273
7274    #[cfg(windows)]
7275    #[test]
7276    fn windows_recognises_an_already_pending_service_deletion() {
7277        let error = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(1072));
7278
7279        assert!(super::sys::is_marked_for_delete(&error));
7280    }
7281
7282    #[test]
7283    fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
7284        let root = tempfile::tempdir().expect("a temporary directory");
7285        let plist = root.path().join("fixture.plist");
7286        std::fs::write(&plist, b"fixture").expect("a plist fixture");
7287        let calls = std::cell::RefCell::new(Vec::new());
7288
7289        let error = enable_launchd_registration(
7290            |arguments| {
7291                let call = arguments
7292                    .iter()
7293                    .map(|argument| argument.to_string_lossy().into_owned())
7294                    .collect::<Vec<_>>();
7295                let operation = call[0].clone();
7296                calls.borrow_mut().push(call);
7297                if operation == "enable" {
7298                    (false, "label remains disabled".to_string())
7299                } else {
7300                    (true, String::new())
7301                }
7302            },
7303            "system",
7304            "system/com.openai.runner-manager-selftest",
7305            &plist,
7306            "runner-manager-selftest",
7307            "rerun with administrative rights",
7308        )
7309        .expect_err("enable failure must fail the install");
7310
7311        assert!(
7312            matches!(
7313                error,
7314                ServiceError::Control {
7315                    operation: "enable",
7316                    ..
7317                }
7318            ),
7319            "{error}"
7320        );
7321        assert_eq!(calls.borrow().len(), 2);
7322        assert_eq!(calls.borrow()[0][0], "enable");
7323        assert_eq!(calls.borrow()[1][0], "bootout");
7324        assert!(!plist.exists(), "rollback must remove the plist");
7325    }
7326
7327    // -----------------------------------------------------------------------
7328    // Quoting, and reading a command line back
7329    // -----------------------------------------------------------------------
7330
7331    #[test]
7332    fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
7333        let plan = windows_plan(StartMode::Boot);
7334        let command_line = plan.command_line();
7335        assert!(
7336            command_line.starts_with('"'),
7337            "a path with a space must be quoted, got {command_line}"
7338        );
7339        assert_eq!(
7340            executable_from_command_line(&command_line).as_deref(),
7341            Some(plan.binary())
7342        );
7343    }
7344
7345    #[test]
7346    fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
7347        let plan = linux_plan(StartMode::Boot);
7348        let command_line = plan.command_line();
7349        assert!(!command_line.starts_with('"'), "got {command_line}");
7350        assert_eq!(
7351            executable_from_command_line(&command_line).as_deref(),
7352            Some(plan.binary())
7353        );
7354    }
7355
7356    #[test]
7357    fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
7358        // Not a path anybody has, but it is the input that separates a real
7359        // implementation of Windows' quoting rules from a `split_whitespace`.
7360        let awkward = r#"C:\odd "name"\rm.exe"#;
7361        let quoted = quote_argument(awkward);
7362        assert_eq!(
7363            executable_from_command_line(&format!("{quoted} daemon run"))
7364                .as_deref()
7365                .map(Path::to_string_lossy)
7366                .as_deref(),
7367            Some(awkward)
7368        );
7369    }
7370
7371    #[test]
7372    fn an_empty_command_line_has_no_executable() {
7373        assert_eq!(executable_from_command_line("   "), None);
7374        assert_eq!(executable_from_command_line(""), None);
7375    }
7376
7377    #[test]
7378    fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
7379        let awkward = r#"DOMAIN\R&D <team> "ops""#;
7380        assert_eq!(
7381            xml_escape(awkward),
7382            "DOMAIN\\R&amp;D &lt;team&gt; &quot;ops&quot;"
7383        );
7384        assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
7385    }
7386
7387    // -----------------------------------------------------------------------
7388    // The restart policy is bounded at both ends
7389    // -----------------------------------------------------------------------
7390
7391    #[test]
7392    fn a_restart_delay_under_the_floor_is_refused() {
7393        let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
7394            .expect_err("half a second is under the one-second floor");
7395        assert!(
7396            matches!(error, ServiceError::RestartDelay { .. }),
7397            "{error}"
7398        );
7399    }
7400
7401    #[test]
7402    fn a_restart_delay_over_the_ceiling_is_refused() {
7403        let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
7404            .expect_err("an hour is over the five-minute ceiling");
7405        assert!(
7406            matches!(error, ServiceError::RestartDelay { .. }),
7407            "{error}"
7408        );
7409    }
7410
7411    #[test]
7412    fn a_reset_window_no_longer_than_the_delay_is_refused() {
7413        let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
7414            .expect_err("a window equal to the delay can never elapse between restarts");
7415        assert!(
7416            matches!(error, ServiceError::RestartResetWindow { .. }),
7417            "{error}"
7418        );
7419    }
7420
7421    #[test]
7422    fn a_delay_inside_the_bound_is_accepted() {
7423        let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
7424            .expect("twenty seconds is inside the bound");
7425        assert_eq!(policy.delay(), Duration::from_secs(20));
7426        assert_eq!(policy.reset_after(), Duration::from_secs(300));
7427    }
7428
7429    // -----------------------------------------------------------------------
7430    // Identity
7431    // -----------------------------------------------------------------------
7432
7433    #[test]
7434    fn a_fixture_identity_can_never_be_the_product_identity() {
7435        let fixture = ServiceIdentity::fixture("abc123");
7436        assert!(fixture.is_fixture());
7437        assert!(!ServiceIdentity::product().is_fixture());
7438        assert_ne!(fixture.name(), ServiceIdentity::product().name());
7439        assert_ne!(
7440            fixture.launchd_label(),
7441            ServiceIdentity::product().launchd_label()
7442        );
7443        assert_ne!(
7444            fixture.systemd_unit(),
7445            ServiceIdentity::product().systemd_unit()
7446        );
7447    }
7448
7449    #[test]
7450    fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
7451        let fixture = ServiceIdentity::fixture("A b/c:\\d");
7452        assert!(
7453            fixture
7454                .name()
7455                .chars()
7456                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
7457            "got {}",
7458            fixture.name()
7459        );
7460    }
7461
7462    // -----------------------------------------------------------------------
7463    // The systemd unit
7464    // -----------------------------------------------------------------------
7465
7466    #[test]
7467    fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
7468        let unit = systemd_unit(&linux_plan(StartMode::Boot));
7469        assert!(unit.contains("KillMode=process\n"), "{unit}");
7470        assert!(unit.contains("Restart=on-failure\n"), "{unit}");
7471        assert!(unit.contains("RestartSec=15\n"), "{unit}");
7472        assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
7473        assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
7474        assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
7475    }
7476
7477    #[test]
7478    fn the_boot_unit_reads_the_live_store_instead_of_a_frozen_systemd_copy() {
7479        let unit = systemd_unit(&linux_plan(StartMode::Boot));
7480        assert!(
7481            !unit.contains("LoadCredential="),
7482            "a startup snapshot would shadow every rotated credential until restart:\n{unit}"
7483        );
7484    }
7485
7486    #[test]
7487    fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
7488        let unit = systemd_unit(&linux_plan(StartMode::Login));
7489        assert!(
7490            !unit.contains("LoadCredential="),
7491            "a user unit must not name a root-owned credential file, got:\n{unit}"
7492        );
7493        assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
7494    }
7495
7496    #[test]
7497    fn the_unit_can_atomically_replace_the_credential_and_write_only_required_directories() {
7498        let plan = linux_plan(StartMode::Boot);
7499        let unit = systemd_unit(&plan);
7500        let directives = ini_directives(&unit, "Service");
7501        let listed = split_quoted(
7502            directives
7503                .get("ReadWritePaths")
7504                .expect("the unit names its writable paths"),
7505        );
7506        assert_eq!(listed.len(), 5, "{listed:?}");
7507        for path in plan.directories().all() {
7508            assert!(
7509                listed.iter().any(|entry| entry == &path.to_string_lossy()),
7510                "{} is missing from {listed:?}",
7511                path.display()
7512            );
7513        }
7514        assert!(
7515            listed
7516                .iter()
7517                .any(|entry| entry == "/var/lib/runner-manager/secrets"),
7518            "atomic credential replacement needs its parent directory in {listed:?}"
7519        );
7520    }
7521
7522    #[test]
7523    fn the_unit_records_the_absolute_binary_path() {
7524        let plan = linux_plan(StartMode::Boot);
7525        let unit = systemd_unit(&plan);
7526        assert!(
7527            unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
7528            "{unit}"
7529        );
7530    }
7531
7532    // -----------------------------------------------------------------------
7533    // The launchd property list
7534    // -----------------------------------------------------------------------
7535
7536    #[test]
7537    fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
7538        let plist = launchd_plist(&linux_plan(StartMode::Boot));
7539        // A bare `KeepAlive` would also restart a job the operator stopped
7540        // deliberately, which turns `service stop` into a fight with launchd.
7541        assert!(
7542            plist.contains(
7543                "<key>KeepAlive</key>\n  <dict>\n    <key>SuccessfulExit</key>\n    <false/>\n"
7544            ),
7545            "{plist}"
7546        );
7547        assert!(
7548            plist.contains("<key>ThrottleInterval</key>\n  <integer>15</integer>"),
7549            "{plist}"
7550        );
7551    }
7552
7553    #[test]
7554    fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
7555        let daemon = launchd_plist(&linux_plan(StartMode::Boot));
7556        assert_eq!(
7557            plist_string_value(&daemon, "UserName").as_deref(),
7558            Some("root")
7559        );
7560        assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
7561
7562        let agent = launchd_plist(&linux_plan(StartMode::Login));
7563        assert_eq!(
7564            plist_string_value(&agent, "UserName"),
7565            None,
7566            "a LaunchAgent already runs as the operator:\n{agent}"
7567        );
7568    }
7569
7570    #[test]
7571    fn the_plist_runs_the_daemon_and_its_runners_as_an_interactive_job() {
7572        for mode in [StartMode::Boot, StartMode::Login] {
7573            let plan = linux_plan(mode);
7574            let plist = launchd_plist(&plan);
7575            assert_eq!(
7576                plist_string_value(&plist, "ProcessType").as_deref(),
7577                Some("Interactive"),
7578                "launchd applies ProcessType to every runner the daemon starts:\n{plist}"
7579            );
7580            let review = review_least_privilege(
7581                &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, plist),
7582                &plan,
7583            );
7584            assert!(
7585                review
7586                    .controls()
7587                    .iter()
7588                    .any(|control| control == "ProcessType=Interactive"),
7589                "{review}"
7590            );
7591            assert!(
7592                !review
7593                    .findings()
7594                    .iter()
7595                    .any(|finding| finding.subject == "ProcessType"),
7596                "{review}"
7597            );
7598        }
7599    }
7600
7601    /// The value 0.4.31 and earlier installed, and launchd's default, both
7602    /// throttle every runner. That is a shortfall the update repairs, not an
7603    /// excess: a scheduling class grants no authority.
7604    #[test]
7605    fn a_plist_that_throttles_its_runners_is_a_shortfall() {
7606        let plan = linux_plan(StartMode::Login);
7607        let rendered = launchd_plist(&plan);
7608        let interactive = "<key>ProcessType</key>\n  <string>Interactive</string>\n";
7609        for (label, throttled) in [
7610            (
7611                "Background",
7612                edited(
7613                    &rendered,
7614                    interactive,
7615                    "<key>ProcessType</key>\n  <string>Background</string>\n",
7616                ),
7617            ),
7618            ("absent", edited(&rendered, interactive, "")),
7619        ] {
7620            let review = review_least_privilege(
7621                &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, throttled),
7622                &plan,
7623            );
7624            assert!(review.is_least_privilege(), "{label}: {review}");
7625            assert!(
7626                review.findings().iter().any(|finding| {
7627                    finding.kind == FindingKind::Shortfall
7628                        && finding.subject == "ProcessType"
7629                        && finding.detail.contains(label)
7630                }),
7631                "{label}: {review}"
7632            );
7633        }
7634    }
7635
7636    #[test]
7637    fn a_plist_with_an_old_process_type_is_drift_and_an_operator_edit_is_not() {
7638        let host = Host::new();
7639        let identity = ServiceIdentity::product();
7640        let plan = InstallPlan::resolve(
7641            identity.clone(),
7642            &host.request(StartMode::Login),
7643            ServiceDirectories::of(&host.paths),
7644        )
7645        .expect("a resolvable plan");
7646        let installed = launchd_plist(&plan);
7647        let definition =
7648            ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, installed.clone());
7649        let mut record = InstallRecord::of(&plan, &definition, Utc::now());
7650        record.installed_by_version = "0.4.31".into();
7651        let path = PathBuf::from("/Users/op/Library/LaunchAgents/runner-manager.plist");
7652
7653        assert_eq!(
7654            launchd_definition_drift(&identity, &record, path.clone(), &installed),
7655            None
7656        );
7657        let edited_by_operator = edited(
7658            &installed,
7659            "</dict>\n</plist>\n",
7660            "  <key>Nice</key>\n  <integer>0</integer>\n</dict>\n</plist>\n",
7661        );
7662        assert_eq!(
7663            launchd_definition_drift(&identity, &record, path.clone(), &edited_by_operator),
7664            None,
7665            "the daemon repairs this drift itself, so it must not key on edits it did not cause"
7666        );
7667
7668        let written_by_0_4_31 = edited(
7669            &installed,
7670            "<string>Interactive</string>",
7671            "<string>Background</string>",
7672        );
7673        let drift = launchd_definition_drift(&identity, &record, path, &written_by_0_4_31)
7674            .expect("a Background plist is drift");
7675        assert_eq!(drift.manager, DefinitionKind::LaunchdPlist);
7676        assert_eq!(drift.start_mode, StartMode::Login);
7677        assert_eq!(
7678            drift.rendered, installed,
7679            "the repair must write exactly what `service install` writes"
7680        );
7681
7682        let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
7683            .with_definition_drift(Some(drift));
7684        let problem = status
7685            .problems()
7686            .iter()
7687            .find(|problem| problem.subject == "definition")
7688            .expect("the drift is reported");
7689        // The remedy is a restart: a running daemon holds the lock that refuses
7690        // `service install`.
7691        assert!(
7692            problem.detail.contains("0.4.31")
7693                && problem.detail.contains(
7694                    "launchctl kickstart -k gui/$(id -u)/io.github.IvanMurzak.runner-manager`"
7695                )
7696                && !problem.detail.contains("service install")
7697                && !problem.detail.contains("systemctl")
7698                && !problem.detail.contains("sudo"),
7699            "{}",
7700            problem.detail
7701        );
7702    }
7703
7704    #[cfg(unix)]
7705    #[test]
7706    fn a_replaced_definition_is_the_rendering_and_readable_by_launchd() {
7707        use std::os::unix::fs::PermissionsExt as _;
7708
7709        let root = tempfile::tempdir().expect("a temporary directory");
7710        let path = root
7711            .path()
7712            .join("io.github.IvanMurzak.runner-manager.plist");
7713        std::fs::write(&path, "<string>Background</string>").expect("old plist");
7714        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
7715        let drift = DefinitionDrift {
7716            path: path.clone(),
7717            installed_by_version: "0.4.31".into(),
7718            rendered: launchd_plist(&linux_plan(StartMode::Login)),
7719            start_mode: StartMode::Login,
7720            manager: DefinitionKind::LaunchdPlist,
7721        };
7722
7723        replace_definition("runner-manager", &drift).expect("replaced");
7724
7725        assert_eq!(std::fs::read_to_string(&path).unwrap(), drift.rendered);
7726        assert_eq!(
7727            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
7728            0o644
7729        );
7730        assert_eq!(
7731            std::fs::read_dir(root.path()).unwrap().count(),
7732            1,
7733            "no staging file is left beside the plist"
7734        );
7735    }
7736
7737    /// The job of [`a_job_reloads_itself_from_its_rewritten_plist`], run by
7738    /// launchd and by nothing else.
7739    ///
7740    /// It does what the daemon does when it starts: when its own plist (kept
7741    /// in its working directory) does not carry [`LAUNCHD_PROCESS_TYPE`], it
7742    /// rewrites the plist and asks for the reload from inside the job that the
7743    /// reload boots out. Either way it then stays up, to be booted out by the
7744    /// helper or, once reloaded, by the test.
7745    #[cfg(target_os = "macos")]
7746    #[test]
7747    #[ignore = "run only by launchd, as the job of the detached-reload test"]
7748    fn detached_reload_job() {
7749        let label = std::env::var("XPC_SERVICE_NAME").unwrap_or_default();
7750        let tag = label
7751            .split_once("-selftest-")
7752            .map(|(_, tag)| tag.to_string())
7753            .unwrap_or_else(|| panic!("run only by launchd for the reload test, not {label:?}"));
7754        let identity = ServiceIdentity::fixture(&tag);
7755        assert_eq!(identity.launchd_label(), label);
7756        let path = std::env::current_dir()
7757            .expect("working directory")
7758            .join(format!("{label}.plist"));
7759        let installed = std::fs::read_to_string(&path).expect("own plist");
7760        if plist_string_value(&installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE) {
7761            let drift = DefinitionDrift {
7762                path: path.clone(),
7763                installed_by_version: "0.4.31".into(),
7764                rendered: installed.replace(
7765                    "<string>Background</string>",
7766                    &format!("<string>{LAUNCHD_PROCESS_TYPE}</string>"),
7767                ),
7768                start_mode: StartMode::Login,
7769                manager: DefinitionKind::LaunchdPlist,
7770            };
7771            replace_definition(identity.name(), &drift).expect("plist replaced");
7772            super::sys::reload_detached(&identity, StartMode::Login, &path)
7773                .expect("helper started");
7774        }
7775        std::thread::sleep(Duration::from_secs(120));
7776    }
7777
7778    /// A job that rewrites its own plist and asks for the reload comes back
7779    /// under the rewritten plist.
7780    ///
7781    /// The request comes from inside the job, as the daemon's does, so the
7782    /// helper has to outlive the bootout of the very job that started it.
7783    /// launchd sends SIGTERM to the job's whole process group, so a helper that
7784    /// stayed in it would sometimes die before its bootstrap and leave the job
7785    /// unloaded. Measured with `setsid` removed, that failed one run in four
7786    /// here: this test catches it, though not on every run.
7787    #[cfg(target_os = "macos")]
7788    #[test]
7789    fn a_job_reloads_itself_from_its_rewritten_plist() {
7790        if !super::launchd_fixture::enabled() {
7791            return;
7792        }
7793        let root = tempfile::tempdir().expect("a temporary directory");
7794        let identity = ServiceIdentity::fixture(&format!("reload-{}", std::process::id()));
7795        let label = identity.launchd_label();
7796        let path = root.path().join(format!("{label}.plist"));
7797        let binary = super::launchd_fixture::job_binary(root.path());
7798        let mut plist = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
7799        plist.push_str("<plist version=\"1.0\">\n<dict>\n");
7800        plist.push_str(&plist_string("Label", &label));
7801        plist.push_str("  <key>ProgramArguments</key>\n  <array>\n");
7802        for argument in [
7803            binary.to_string_lossy().as_ref(),
7804            "--ignored",
7805            "--exact",
7806            "service::tests::detached_reload_job",
7807            "--test-threads=1",
7808        ] {
7809            plist.push_str(&format!("    <string>{}</string>\n", xml_escape(argument)));
7810        }
7811        plist.push_str("  </array>\n  <key>RunAtLoad</key>\n  <true/>\n");
7812        plist.push_str(&plist_string(
7813            "WorkingDirectory",
7814            &root.path().to_string_lossy(),
7815        ));
7816        plist.push_str(&plist_string("ProcessType", "Background"));
7817        plist.push_str("</dict>\n</plist>\n");
7818        std::fs::write(&path, plist).expect("plist written");
7819
7820        let loaded = super::launchd_fixture::LoadedAgent::bootstrap(&label, &path);
7821        let spawn_type = || {
7822            let printed = std::process::Command::new("launchctl")
7823                .args(["print", loaded.target()])
7824                .output()
7825                .expect("launchctl runs");
7826            String::from_utf8_lossy(&printed.stdout)
7827                .lines()
7828                .find_map(|line| {
7829                    line.trim()
7830                        .strip_prefix("spawn type = ")
7831                        .map(str::to_string)
7832                })
7833        };
7834        assert_eq!(spawn_type().as_deref(), Some("background (5)"));
7835
7836        let deadline = std::time::Instant::now() + Duration::from_secs(90);
7837        while spawn_type().as_deref() != Some("interactive (4)") {
7838            assert!(
7839                std::time::Instant::now() < deadline,
7840                "the job did not come back under its rewritten plist; launchd reports {:?}",
7841                spawn_type()
7842            );
7843            std::thread::sleep(Duration::from_millis(250));
7844        }
7845        let rewritten = std::fs::read_to_string(&path).expect("plist readable");
7846        assert_eq!(
7847            plist_string_value(&rewritten, "ProcessType").as_deref(),
7848            Some(LAUNCHD_PROCESS_TYPE)
7849        );
7850    }
7851
7852    #[test]
7853    fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
7854        let plist = launchd_plist(&linux_plan(StartMode::Boot));
7855        assert!(
7856            plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
7857            "{plist}"
7858        );
7859        assert!(plist.contains("<string>daemon</string>"), "{plist}");
7860        assert!(plist.contains("<string>run</string>"), "{plist}");
7861    }
7862
7863    #[test]
7864    fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
7865        assert_eq!(
7866            ServiceIdentity::product().launchd_label(),
7867            "io.github.IvanMurzak.runner-manager"
7868        );
7869    }
7870
7871    // -----------------------------------------------------------------------
7872    // The Task Scheduler document
7873    // -----------------------------------------------------------------------
7874
7875    #[test]
7876    fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
7877        let xml = windows_scheduled_task_xml(
7878            &windows_plan(StartMode::Login),
7879            &TaskPrincipal::named("HOST\\operator"),
7880        );
7881        assert!(xml.contains("<LogonTrigger>"), "{xml}");
7882        assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
7883        assert!(
7884            xml.contains("<LogonType>InteractiveToken</LogonType>"),
7885            "{xml}"
7886        );
7887        assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
7888        assert!(
7889            xml.contains("<Interval>PT1M</Interval>"),
7890            "Task Scheduler takes whole minutes only, and rejects the registration outright \
7891             for anything finer:\n{xml}"
7892        );
7893    }
7894
7895    #[test]
7896    fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
7897        // Measured against the real Task Scheduler, which answers `PT15S` with
7898        // "The task XML contains a value which is incorrectly formatted or out
7899        // of range". Rounding *up* is what keeps "does not restart-loop faster
7900        // than that bound" true on this manager.
7901        for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
7902            let policy =
7903                RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
7904                    .expect("inside the supported range");
7905            assert_eq!(
7906                policy
7907                    .effective_delay(DefinitionKind::WindowsScheduledTask)
7908                    .as_secs(),
7909                enforced,
7910                "a {asked}s delay must be enforced as {enforced}s"
7911            );
7912            assert!(
7913                policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
7914                "rounding must never shorten the bound"
7915            );
7916        }
7917    }
7918
7919    #[test]
7920    fn every_other_manager_enforces_the_delay_exactly_as_configured() {
7921        let policy = RestartPolicy::default();
7922        for kind in [
7923            DefinitionKind::WindowsService,
7924            DefinitionKind::LaunchdPlist,
7925            DefinitionKind::SystemdUnit,
7926        ] {
7927            assert_eq!(
7928                policy.effective_delay(kind),
7929                policy.delay(),
7930                "{kind:?} takes seconds and enforces exactly what it is given"
7931            );
7932        }
7933    }
7934
7935    #[test]
7936    fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
7937        let host = Host::new();
7938        let operations = host.operations();
7939        operations
7940            .install(&host.request(StartMode::Login))
7941            .expect("an install at login");
7942        // What a real Task Scheduler reports back for a 15-second policy.
7943        host.controls.edit("runner-manager", |registration| {
7944            registration.manager = DefinitionKind::WindowsScheduledTask;
7945            registration.restart_delay = Some(Duration::from_secs(60));
7946        });
7947
7948        let status = operations.status().expect("a status");
7949        assert!(
7950            status.is_healthy(),
7951            "minute granularity is the manager's, not a mis-registration: {status}"
7952        );
7953        assert!(
7954            status
7955                .notes()
7956                .iter()
7957                .any(|note| note.contains("whole minutes")),
7958            "but the operator must be told why 15 became 60: {status}"
7959        );
7960
7961        // The discriminator: a delay that is neither the configured one nor its
7962        // rounding is still a fault.
7963        host.controls.edit("runner-manager", |registration| {
7964            registration.restart_delay = Some(Duration::from_secs(1));
7965        });
7966        assert!(
7967            !operations.status().expect("a status").is_healthy(),
7968            "a one-second delay is not what any manager was asked for"
7969        );
7970    }
7971
7972    #[test]
7973    fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
7974        let plan = windows_plan(StartMode::Login);
7975        let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
7976        assert_eq!(
7977            xml_value(&xml, "Command").as_deref(),
7978            Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
7979            "{xml}"
7980        );
7981        assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
7982    }
7983
7984    #[test]
7985    fn an_account_name_holding_xml_punctuation_is_escaped() {
7986        let xml = windows_scheduled_task_xml(
7987            &windows_plan(StartMode::Login),
7988            &TaskPrincipal::named("R&D\\ops"),
7989        );
7990        assert!(xml.contains("<UserId>R&amp;D\\ops</UserId>"), "{xml}");
7991        assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
7992    }
7993
7994    // -----------------------------------------------------------------------
7995    // The Windows service descriptor
7996    // -----------------------------------------------------------------------
7997
7998    #[test]
7999    fn the_service_starts_automatically_under_the_account_the_store_admits() {
8000        let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
8001        let directives = ini_directives(&text, "windows-service");
8002        assert_eq!(
8003            directives.get("StartType").map(String::as_str),
8004            Some("AutoStart")
8005        );
8006        assert_eq!(
8007            directives.get("Account").map(String::as_str),
8008            Some("NT AUTHORITY\\SYSTEM")
8009        );
8010        assert_eq!(
8011            directives.get("ServiceType").map(String::as_str),
8012            Some("OWN_PROCESS")
8013        );
8014        assert_eq!(
8015            directives
8016                .get("FailureActionRestartDelaySecs")
8017                .map(String::as_str),
8018            Some("15")
8019        );
8020        assert_eq!(
8021            directives
8022                .get("FailureActionsOnNonCrashFailures")
8023                .map(String::as_str),
8024            Some("true"),
8025            "without this flag a non-zero exit is not a failure the manager restarts"
8026        );
8027    }
8028
8029    #[test]
8030    fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
8031        let spec = windows_service_spec(&windows_plan(StartMode::Boot));
8032        assert_eq!(spec.account, None);
8033        assert!(spec.automatic_start);
8034        assert!(
8035            spec.command_line.contains("daemon run"),
8036            "{}",
8037            spec.command_line
8038        );
8039    }
8040
8041    // -----------------------------------------------------------------------
8042    // Where each definition is installed
8043    // -----------------------------------------------------------------------
8044
8045    #[test]
8046    fn each_definition_goes_where_its_platform_expects_it() {
8047        let home = PathBuf::from("/home/op");
8048        assert_eq!(
8049            ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
8050            Some(Path::new(
8051                "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
8052            ))
8053        );
8054        assert_eq!(
8055            ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
8056            Some(Path::new(
8057                "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
8058            ))
8059        );
8060        assert_eq!(
8061            ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
8062            Some(Path::new("/etc/systemd/system/runner-manager.service"))
8063        );
8064        assert_eq!(
8065            ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
8066            Some(Path::new(
8067                "/home/op/.config/systemd/user/runner-manager.service"
8068            ))
8069        );
8070        assert_eq!(
8071            ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
8072            None,
8073            "the Service Control Manager has no file"
8074        );
8075    }
8076
8077    #[test]
8078    fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
8079        assert_eq!(
8080            ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
8081            None
8082        );
8083        assert_eq!(
8084            ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
8085            None
8086        );
8087    }
8088
8089    // -----------------------------------------------------------------------
8090    // The least-privilege review: the passing case, then every failing one
8091    // -----------------------------------------------------------------------
8092
8093    #[test]
8094    fn the_rendered_definitions_are_all_least_privilege() {
8095        let linux = linux_plan(StartMode::Boot);
8096        let windows = windows_plan(StartMode::Boot);
8097        for (definition, plan) in [
8098            (ServiceDefinition::systemd(&linux, None), &linux),
8099            (ServiceDefinition::launchd(&linux, None), &linux),
8100            (ServiceDefinition::windows_service(&windows), &windows),
8101        ] {
8102            let review = review_least_privilege(&definition, plan);
8103            assert!(
8104                review.is_least_privilege(),
8105                "{:?} should be least privilege, got:\n{review}",
8106                definition.kind()
8107            );
8108            assert!(
8109                !review.controls().is_empty(),
8110                "a review that confirms nothing proves nothing: {:?}",
8111                definition.kind()
8112            );
8113        }
8114    }
8115
8116    #[test]
8117    fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
8118        let plan = windows_plan(StartMode::Login);
8119        let definition =
8120            ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
8121        let review = review_least_privilege(&definition, &plan);
8122        assert!(review.is_least_privilege(), "{review}");
8123        assert!(
8124            review
8125                .controls()
8126                .iter()
8127                .any(|control| control.contains("LeastPrivilege")),
8128            "{review}"
8129        );
8130    }
8131
8132    #[test]
8133    fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
8134        let plan = linux_plan(StartMode::Boot);
8135        let rendered = systemd_unit(&plan);
8136        let widened = edited(
8137            &rendered,
8138            "ReadWritePaths=/var/lib/runner-manager/config",
8139            "ReadWritePaths=/etc /var/lib/runner-manager/config",
8140        );
8141        let review = review_least_privilege(
8142            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
8143            &plan,
8144        );
8145        assert!(!review.is_least_privilege(), "{review}");
8146        assert!(
8147            review
8148                .excesses()
8149                .iter()
8150                .any(|finding| finding.detail.contains("/etc")),
8151            "the review must name the directory it objects to: {review}"
8152        );
8153    }
8154
8155    #[test]
8156    fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
8157        let plan = linux_plan(StartMode::Boot);
8158        let rendered = systemd_unit(&plan);
8159        let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
8160        let review = review_least_privilege(
8161            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
8162            &plan,
8163        );
8164        assert!(!review.is_least_privilege(), "{review}");
8165        assert!(
8166            review
8167                .excesses()
8168                .iter()
8169                .any(|finding| finding.subject == "NoNewPrivileges"),
8170            "{review}"
8171        );
8172    }
8173
8174    #[test]
8175    fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
8176        let plan = linux_plan(StartMode::Boot);
8177        let rendered = systemd_unit(&plan);
8178        let widened = edited(
8179            &rendered,
8180            "CapabilityBoundingSet=\n",
8181            "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
8182        );
8183        let review = review_least_privilege(
8184            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
8185            &plan,
8186        );
8187        assert!(!review.is_least_privilege(), "{review}");
8188        assert!(
8189            review
8190                .excesses()
8191                .iter()
8192                .any(|finding| finding.subject == "CapabilityBoundingSet"),
8193            "{review}"
8194        );
8195    }
8196
8197    #[test]
8198    fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
8199        let plan = linux_plan(StartMode::Boot);
8200        let rendered = systemd_unit(&plan);
8201        let widened = edited(
8202            &rendered,
8203            "[Install]",
8204            "ListenStream=127.0.0.1:9000\n\n[Install]",
8205        );
8206        let review = review_least_privilege(
8207            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
8208            &plan,
8209        );
8210        assert!(
8211            !review.is_least_privilege(),
8212            "07-security.md rule 2 forbids any inbound surface: {review}"
8213        );
8214    }
8215
8216    #[test]
8217    fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
8218        let plan = linux_plan(StartMode::Boot);
8219        let rendered = systemd_unit(&plan);
8220        let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
8221        let review = review_least_privilege(
8222            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
8223            &plan,
8224        );
8225        assert!(
8226            review.is_least_privilege(),
8227            "too little authority is not an excess: {review}"
8228        );
8229        assert!(
8230            review
8231                .findings()
8232                .iter()
8233                .any(|finding| finding.kind == FindingKind::Shortfall
8234                    && finding.detail.contains("runtime")),
8235            "{review}"
8236        );
8237    }
8238
8239    #[test]
8240    fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
8241        let plan = linux_plan(StartMode::Login);
8242        let rendered = launchd_plist(&plan);
8243        let widened = edited(
8244            &rendered,
8245            "<key>ProcessType</key>",
8246            "<key>UserName</key>\n  <string>root</string>\n  <key>ProcessType</key>",
8247        );
8248        let review = review_least_privilege(
8249            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
8250            &plan,
8251        );
8252        assert!(!review.is_least_privilege(), "{review}");
8253        assert!(
8254            review
8255                .excesses()
8256                .iter()
8257                .any(|finding| finding.subject == "UserName"),
8258            "{review}"
8259        );
8260    }
8261
8262    #[test]
8263    fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
8264        let plan = linux_plan(StartMode::Boot);
8265        let rendered = launchd_plist(&plan);
8266        let widened = edited(
8267            &rendered,
8268            "<key>SessionCreate</key>\n  <false/>",
8269            "<key>SessionCreate</key>\n  <true/>",
8270        );
8271        let review = review_least_privilege(
8272            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
8273            &plan,
8274        );
8275        assert!(!review.is_least_privilege(), "{review}");
8276        assert!(
8277            review
8278                .excesses()
8279                .iter()
8280                .any(|finding| finding.subject == "SessionCreate"),
8281            "{review}"
8282        );
8283    }
8284
8285    #[test]
8286    fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
8287        let plan = linux_plan(StartMode::Boot);
8288        let rendered = launchd_plist(&plan);
8289        let widened = edited(
8290            &rendered,
8291            "<key>ProcessType</key>",
8292            "<key>MachServices</key>\n  <dict/>\n  <key>ProcessType</key>",
8293        );
8294        let review = review_least_privilege(
8295            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
8296            &plan,
8297        );
8298        assert!(!review.is_least_privilege(), "{review}");
8299    }
8300
8301    #[test]
8302    fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
8303        let plan = windows_plan(StartMode::Login);
8304        let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
8305        let widened = edited(
8306            &rendered,
8307            "<RunLevel>LeastPrivilege</RunLevel>",
8308            "<RunLevel>HighestAvailable</RunLevel>",
8309        );
8310        let review = review_least_privilege(
8311            &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
8312            &plan,
8313        );
8314        assert!(!review.is_least_privilege(), "{review}");
8315        assert!(
8316            review
8317                .excesses()
8318                .iter()
8319                .any(|finding| finding.subject == "RunLevel"),
8320            "{review}"
8321        );
8322    }
8323
8324    #[test]
8325    fn a_task_that_would_store_a_password_is_not_least_privilege() {
8326        let plan = windows_plan(StartMode::Login);
8327        let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
8328        let widened = edited(
8329            &rendered,
8330            "<LogonType>InteractiveToken</LogonType>",
8331            "<LogonType>Password</LogonType>",
8332        );
8333        let review = review_least_privilege(
8334            &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
8335            &plan,
8336        );
8337        assert!(!review.is_least_privilege(), "{review}");
8338    }
8339
8340    #[test]
8341    fn an_interactive_windows_service_is_not_least_privilege() {
8342        let plan = windows_plan(StartMode::Boot);
8343        let rendered = windows_service_descriptor(&plan);
8344        let widened = edited(
8345            &rendered,
8346            "ServiceType=OWN_PROCESS",
8347            "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
8348        );
8349        let review = review_least_privilege(
8350            &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
8351            &plan,
8352        );
8353        assert!(!review.is_least_privilege(), "{review}");
8354        assert!(
8355            review
8356                .excesses()
8357                .iter()
8358                .any(|finding| finding.subject == "ServiceType"),
8359            "{review}"
8360        );
8361    }
8362
8363    #[test]
8364    fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
8365        let plan = windows_plan(StartMode::Boot);
8366        let rendered = windows_service_descriptor(&plan);
8367        let changed = edited(
8368            &rendered,
8369            "Account=NT AUTHORITY\\SYSTEM",
8370            "Account=NT AUTHORITY\\LocalService",
8371        );
8372        let review = review_least_privilege(
8373            &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
8374            &plan,
8375        );
8376        // LocalService is *less* privileged, so this is not an excess. It is
8377        // still wrong, because `d2`'s DACL names SY, BA and OW and nothing else:
8378        // the daemon would start and find no credential. Reporting it as a
8379        // shortfall rather than as an excess is the difference between a check
8380        // that understands the requirement and one that counts adjectives.
8381        assert!(review.is_least_privilege(), "{review}");
8382        assert!(
8383            review.findings().iter().any(|finding| {
8384                finding.kind == FindingKind::Shortfall && finding.subject == "Account"
8385            }),
8386            "{review}"
8387        );
8388    }
8389
8390    // -----------------------------------------------------------------------
8391    // The recorded absolute path, and what became of it
8392    // -----------------------------------------------------------------------
8393
8394    #[test]
8395    fn a_recorded_path_that_is_still_there_is_current() {
8396        let host = Host::new();
8397        let state = inspect_binary(&host.binary, Some(&host.binary));
8398        assert!(!state.is_error(), "{state}");
8399        assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
8400    }
8401
8402    #[test]
8403    fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
8404        let host = Host::new();
8405        // An `npm i -g` binary lives under the active Node installation's
8406        // global prefix. Switching Node versions moves the prefix, and the
8407        // recorded path stops existing while the registration survives.
8408        let recorded = host.binary.clone();
8409        let healthy = inspect_binary(&recorded, Some(&recorded));
8410        assert!(
8411            !healthy.is_error(),
8412            "the discriminator: before the binary moves, this must be healthy"
8413        );
8414
8415        std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
8416
8417        let state = inspect_binary(&recorded, Some(&recorded));
8418        assert!(state.is_error(), "{state}");
8419        assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
8420        assert!(
8421            state.to_string().contains("npm"),
8422            "the message must name the cause an operator will not otherwise connect: {state}"
8423        );
8424    }
8425
8426    #[test]
8427    fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
8428        let root = tempfile::tempdir().expect("a temporary directory");
8429        let state = inspect_binary(root.path(), None);
8430        assert!(state.is_error(), "{state}");
8431        assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
8432    }
8433
8434    #[test]
8435    fn a_registration_naming_a_different_binary_is_a_divergence() {
8436        let host = Host::new();
8437        let other = host.binary.with_file_name("something-else");
8438        let state = inspect_binary(&host.binary, Some(&other));
8439        assert!(state.is_error(), "{state}");
8440        assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
8441    }
8442
8443    #[test]
8444    fn absence_is_reported_before_divergence() {
8445        let host = Host::new();
8446        let recorded = host.binary.clone();
8447        std::fs::remove_file(&recorded).expect("removable");
8448        let other = recorded.with_file_name("something-else");
8449        // Both faults are true at once. The operator needs to hear about the
8450        // missing file, not about a disagreement between two paths of which one
8451        // does not exist.
8452        assert!(matches!(
8453            inspect_binary(&recorded, Some(&other)),
8454            BinaryPath::Missing { .. }
8455        ));
8456    }
8457
8458    // -----------------------------------------------------------------------
8459    // The record
8460    // -----------------------------------------------------------------------
8461
8462    #[test]
8463    fn the_record_round_trips_through_toml() {
8464        let host = Host::new();
8465        let plan = InstallPlan::resolve(
8466            ServiceIdentity::product(),
8467            &host.request(StartMode::Boot),
8468            ServiceDirectories::of(&host.paths),
8469        )
8470        .expect("a resolvable plan");
8471        let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
8472        let record = InstallRecord::of(&plan, &definition, Utc::now());
8473        record.write(&host.paths).expect("a writable record");
8474        let read = InstallRecord::read(&host.paths)
8475            .expect("a readable record")
8476            .expect("a record is there");
8477        assert_eq!(read, record);
8478        assert_eq!(read.binary, host.binary);
8479        assert!(read.binary.is_absolute());
8480    }
8481
8482    #[test]
8483    fn a_unit_written_by_an_older_build_is_reported_and_a_current_one_is_not() {
8484        let host = Host::new();
8485        let identity = ServiceIdentity::product();
8486        let plan = InstallPlan::resolve(
8487            identity.clone(),
8488            &host.request(StartMode::Boot),
8489            ServiceDirectories::of(&host.paths),
8490        )
8491        .expect("a resolvable plan");
8492        let installed = systemd_unit(&plan);
8493        let definition =
8494            ServiceDefinition::from_text(DefinitionKind::SystemdUnit, installed.clone());
8495        let mut record = InstallRecord::of(&plan, &definition, Utc::now());
8496        let path = PathBuf::from("/etc/systemd/system/runner-manager.service");
8497
8498        assert_eq!(
8499            systemd_definition_drift(&identity, &record, path.clone(), &installed),
8500            None,
8501            "the record must rebuild exactly the unit `service install` wrote"
8502        );
8503
8504        record.installed_by_version = "0.4.7".into();
8505        let frozen = installed.replace(
8506            "[Service]\n",
8507            "[Service]\nLoadCredential=runner-manager.user-access-token:/var/lib/runner-manager/secrets/user-access-token\n",
8508        );
8509        let drift = systemd_definition_drift(&identity, &record, path, &frozen)
8510            .expect("an outdated unit is drift");
8511        assert_eq!(drift.installed_by_version, "0.4.7");
8512        assert_eq!(drift.rendered, installed);
8513
8514        let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
8515            .with_definition_drift(Some(drift));
8516        assert!(!status.is_healthy());
8517        assert!(
8518            status
8519                .problems()
8520                .iter()
8521                .any(|problem| problem.subject == "definition" && problem.detail.contains("0.4.7")),
8522            "{status}"
8523        );
8524    }
8525
8526    #[test]
8527    fn a_rejected_credential_keeps_its_first_moment_and_makes_the_service_unhealthy() {
8528        let host = Host::new();
8529        assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
8530        let first = Utc::now() - chrono::Duration::hours(9);
8531        record_github_credential_rejected(&host.paths, first).unwrap();
8532        record_github_credential_rejected(&host.paths, Utc::now()).unwrap();
8533        let since = github_credential_rejected_since(&host.paths).unwrap();
8534        assert_eq!(since, Some(first));
8535
8536        let status = ServiceStatus::compose(
8537            ServiceIdentity::product(),
8538            None,
8539            None,
8540            None,
8541            None,
8542            &host.paths,
8543        )
8544        .with_credential_rejection(since);
8545        assert!(!status.is_healthy());
8546        assert_eq!(status.credential_rejected_since(), Some(first));
8547        assert!(status.to_string().contains("auth login"), "{status}");
8548
8549        clear_github_credential_rejection(&host.paths).unwrap();
8550        clear_github_credential_rejection(&host.paths).unwrap();
8551        assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
8552    }
8553
8554    /// A guest that started nothing for 13 days while this said `healthy`.
8555    #[test]
8556    fn blocked_launches_make_the_service_unhealthy_with_their_remedy() {
8557        use crate::launch_health::{
8558            BlockCause, Viewpoint, clear_launches_blocked, launches_blocked,
8559            record_launches_blocked,
8560        };
8561
8562        let host = Host::new();
8563        let compose = || {
8564            ServiceStatus::compose(
8565                ServiceIdentity::product(),
8566                None,
8567                None,
8568                None,
8569                None,
8570                &host.paths,
8571            )
8572            .with_launches_blocked(launches_blocked(&host.paths, Utc::now()))
8573        };
8574        assert!(
8575            !compose()
8576                .problems()
8577                .iter()
8578                .any(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
8579        );
8580        let blocked = BlockCause::FenceHeld {
8581            directory: PathBuf::from("launch-fence"),
8582            owner: None,
8583        }
8584        .blocked_since(Utc::now(), Viewpoint::Guest);
8585        record_launches_blocked(&host.paths, &blocked, Utc::now()).unwrap();
8586
8587        let status = compose();
8588        assert!(!status.is_healthy());
8589        assert_eq!(status.launches_blocked(), Some(&blocked));
8590        let problem = status
8591            .problems()
8592            .iter()
8593            .find(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
8594            .expect("reported under its own subject");
8595        assert!(problem.detail.contains("systemctl restart"), "{status}");
8596
8597        clear_launches_blocked(&host.paths).unwrap();
8598        assert_eq!(compose().launches_blocked(), None);
8599    }
8600
8601    /// The daemon's own "I cannot read my credential", which used to exist
8602    /// only in its log while `service status` said `healthy`.
8603    #[test]
8604    fn an_unreadable_credential_keeps_its_first_moment_and_names_the_exact_command() {
8605        let host = Host::new();
8606        assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
8607        let first = Utc::now() - chrono::Duration::minutes(40);
8608        record_credential_unreadable(&host.paths, first).unwrap();
8609        record_credential_unreadable(&host.paths, Utc::now()).unwrap();
8610        let since = credential_unreadable_since(&host.paths).unwrap();
8611        assert_eq!(since, Some(first), "a restart loop must not move the start");
8612
8613        let status = ServiceStatus::compose(
8614            ServiceIdentity::product(),
8615            None,
8616            None,
8617            None,
8618            None,
8619            &host.paths,
8620        )
8621        .with_credential_unreadable(since);
8622        assert!(!status.is_healthy());
8623        let problem = status
8624            .problems()
8625            .iter()
8626            .find(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT)
8627            .expect("reported under its own subject");
8628        // No record, so the default start mode, whose exact command is named.
8629        assert!(
8630            problem
8631                .detail
8632                .contains(&sign_in_instruction(StartMode::default())),
8633            "{status}"
8634        );
8635
8636        clear_credential_unreadable(&host.paths).unwrap();
8637        clear_credential_unreadable(&host.paths).unwrap();
8638        assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
8639        let healthy = ServiceStatus::compose(
8640            ServiceIdentity::product(),
8641            None,
8642            None,
8643            None,
8644            None,
8645            &host.paths,
8646        )
8647        .with_credential_unreadable(None);
8648        assert!(
8649            !healthy
8650                .problems()
8651                .iter()
8652                .any(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT),
8653            "{healthy}"
8654        );
8655    }
8656
8657    #[test]
8658    fn the_sign_in_instruction_names_the_start_mode_it_writes() {
8659        assert!(sign_in_instruction(StartMode::Login).contains("auth login --start-at login`"));
8660        assert!(sign_in_instruction(StartMode::Boot).contains("auth login --start-at boot`"));
8661        assert_eq!(
8662            sign_in_instruction(StartMode::Boot).starts_with("`sudo "),
8663            cfg!(unix),
8664            "a machine-scoped store needs root on Unix"
8665        );
8666        assert_eq!(
8667            sign_in_instruction(StartMode::Login).contains("SSH"),
8668            cfg!(target_os = "macos"),
8669            "only the macOS login keychain is out of an SSH session's reach"
8670        );
8671    }
8672
8673    /// The record is readable by the account whose directory it is in.
8674    ///
8675    /// A boot-mode `service install` runs under `sudo`, and `NamedTempFile`
8676    /// creates at `0600`, so the record landed `root`-owned and unreadable by
8677    /// the operator — `service status` then failed on their own host with
8678    /// `Permission denied`. Ownership cannot be reproduced in an unprivileged
8679    /// test, but the mode that made ownership fatal can, and it is the half
8680    /// this code controls.
8681    #[cfg(unix)]
8682    #[test]
8683    fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
8684        use std::os::unix::fs::PermissionsExt as _;
8685
8686        let host = Host::new();
8687        let plan = InstallPlan::resolve(
8688            ServiceIdentity::product(),
8689            &host.request(StartMode::Boot),
8690            ServiceDirectories::of(&host.paths),
8691        )
8692        .expect("a resolvable plan");
8693        let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
8694        InstallRecord::of(&plan, &definition, Utc::now())
8695            .write(&host.paths)
8696            .expect("a writable record");
8697
8698        let mode = std::fs::metadata(InstallRecord::path(&host.paths))
8699            .expect("the record is there")
8700            .permissions()
8701            .mode()
8702            & 0o777;
8703        assert_eq!(
8704            mode, 0o644,
8705            "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
8706             service install` wrote, and `service status` fails on their own host. It holds no \
8707             credential and sits in a 0700 directory, so 0644 discloses nothing"
8708        );
8709    }
8710
8711    /// A record written before the service ran a copy of its own must still
8712    /// load. It reads as `None`, which is the truth about it: that registration
8713    /// names the package manager's file and cannot be upgraded under itself.
8714    #[test]
8715    fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
8716        let host = Host::new();
8717        let path = InstallRecord::path(&host.paths);
8718        std::fs::write(
8719            &path,
8720            format!(
8721                "schema_version = {RECORD_SCHEMA_VERSION}
8722service_name = \"runner-manager\"
8723                 manager = \"systemd\"
8724start_mode = \"boot\"
8725account = \"root\"
8726                 binary = \"/x\"
8727arguments = []
8728restart_delay_secs = 15
8729                 restart_reset_secs = 600
8730log_file = \"/x\"
8731                 installed_at = \"2026-01-01T00:00:00Z\"
8732installed_by_version = \"0.1.0\"
8733                 [directories]
8734config = \"/a\"
8735state = \"/b\"
8736runtime = \"/c\"
8737logs = \"/d\"
8738"
8739            ),
8740        )
8741        .expect("a writable record");
8742        let read = InstallRecord::read(&host.paths)
8743            .expect("a record missing an optional field is still readable")
8744            .expect("a record is there");
8745        assert_eq!(
8746            read.source_binary, None,
8747            "the legacy layout has no source, and must not invent one"
8748        );
8749    }
8750
8751    /// The field that makes an upgrade possible survives the write.
8752    #[test]
8753    fn a_registration_remembers_the_file_it_was_copied_from() {
8754        let host = Host::new();
8755        let source = host.binary.with_file_name("npm-installed-runner-manager");
8756        std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
8757        let plan = InstallPlan::resolve(
8758            ServiceIdentity::product(),
8759            &host.request(StartMode::Boot).copied_from(&source),
8760            ServiceDirectories::of(&host.paths),
8761        )
8762        .expect("a resolvable plan");
8763        let definition = ServiceDefinition::from_text(
8764            DefinitionKind::SystemdUnit,
8765            "[Service]
8766",
8767        );
8768        let record = InstallRecord::of(&plan, &definition, Utc::now());
8769        record.write(&host.paths).expect("a writable record");
8770
8771        let read = InstallRecord::read(&host.paths)
8772            .expect("a readable record")
8773            .expect("a record is there");
8774        assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
8775        assert_ne!(
8776            read.source_binary.as_deref(),
8777            Some(read.binary.as_path()),
8778            "the whole point is that the two are different files: one the service holds open,              one the package manager is free to replace"
8779        );
8780    }
8781
8782    #[test]
8783    fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
8784        let host = Host::new();
8785        let path = InstallRecord::path(&host.paths);
8786        std::fs::write(
8787            &path,
8788            format!(
8789                "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
8790                 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
8791                 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
8792                 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
8793                 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
8794                RECORD_SCHEMA_VERSION + 1
8795            ),
8796        )
8797        .expect("a writable record");
8798        let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
8799        assert!(
8800            matches!(error, ServiceError::RecordUnreadable { .. }),
8801            "{error}"
8802        );
8803        assert!(
8804            error.to_string().contains("service uninstall"),
8805            "the message must say how to recover: {error}"
8806        );
8807    }
8808
8809    #[test]
8810    fn no_record_is_not_an_error() {
8811        let host = Host::new();
8812        assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
8813        assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
8814    }
8815
8816    // -----------------------------------------------------------------------
8817    // The last successful GitHub contact
8818    // -----------------------------------------------------------------------
8819
8820    #[test]
8821    fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
8822        let host = Host::new();
8823        assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
8824    }
8825
8826    #[test]
8827    fn the_heartbeat_round_trips_to_the_second() {
8828        let host = Host::new();
8829        let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
8830            .expect("a valid timestamp")
8831            .with_timezone(&Utc);
8832        record_github_contact(&host.paths, at).expect("a writable heartbeat");
8833        assert_eq!(
8834            last_github_contact(&host.paths).expect("readable"),
8835            Some(at)
8836        );
8837    }
8838
8839    #[test]
8840    fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
8841        let host = Host::new();
8842        std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
8843        let error = last_github_contact(&host.paths)
8844            .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
8845        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
8846    }
8847
8848    /// The surface the three-hour outage did not have.
8849    ///
8850    /// A root that refuses a launch does so before any attempt row exists, and
8851    /// the daemon's log scrubs the paths out of the sentence, so this file is
8852    /// the only place the directory and the remediation can reach the operator.
8853    #[test]
8854    fn a_runner_root_refusal_round_trips_for_service_status() {
8855        let host = Host::new();
8856        let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
8857
8858        assert!(
8859            runner_root_refusals(&host.paths)
8860                .expect("readable")
8861                .is_empty(),
8862            "no record means every policy is placing runners"
8863        );
8864
8865        record_runner_root_refusal(
8866            &host.paths,
8867            "policy-a",
8868            at,
8869            "denied_by_privacy_policy",
8870            "/Volumes/NVME/runners",
8871            "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
8872        )
8873        .expect("a writable record");
8874
8875        let refusals = runner_root_refusals(&host.paths).expect("readable");
8876        assert_eq!(refusals.len(), 1);
8877        assert_eq!(refusals[0].policy, "policy-a");
8878        assert_eq!(refusals[0].at, at);
8879        assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
8880        assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
8881        assert!(
8882            refusals[0].detail.contains("/Volumes/NVME/runners")
8883                && refusals[0].detail.contains("Full Disk Access"),
8884            "the path and the remediation are the whole point of this file: {refusals:?}"
8885        );
8886    }
8887
8888    /// The regression that would put the outage straight back.
8889    ///
8890    /// A host runs several policies and they do not share a fate. When the
8891    /// record was one host-wide slot, the policy that placed a runner deleted
8892    /// the record of the policy that could not — on the same reconcile pass —
8893    /// and `service status` reported a healthy host that had started zero
8894    /// runners for an entire target.
8895    #[test]
8896    fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
8897        let host = Host::new();
8898        let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
8899        record_runner_root_refusal(
8900            &host.paths,
8901            "broken",
8902            at,
8903            "denied_by_privacy_policy",
8904            "/Volumes/NVME/runners",
8905            "detail",
8906        )
8907        .expect("a writable record");
8908        record_runner_root_refusal(
8909            &host.paths,
8910            "also-broken",
8911            at,
8912            "not_writable",
8913            "/srv/other",
8914            "detail",
8915        )
8916        .expect("a writable record");
8917
8918        // The policy with its own working root succeeds and clears only itself.
8919        clear_runner_root_refusal(&host.paths, "healthy")
8920            .expect("clearing an absent policy is fine");
8921        clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
8922
8923        let refusals = runner_root_refusals(&host.paths).expect("readable");
8924        assert_eq!(
8925            refusals
8926                .iter()
8927                .map(|r| r.policy.as_str())
8928                .collect::<Vec<_>>(),
8929            vec!["broken"],
8930            "the policy that is still refused must keep its record"
8931        );
8932    }
8933
8934    /// Clearing is what keeps a fixed host from reporting a stale fault, and the
8935    /// file goes when the last entry does.
8936    #[test]
8937    fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
8938        let host = Host::new();
8939        record_runner_root_refusal(
8940            &host.paths,
8941            "p",
8942            Utc::now(),
8943            "not_writable",
8944            "/srv/x",
8945            "detail",
8946        )
8947        .expect("a writable record");
8948
8949        clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
8950        assert!(
8951            !root_refusal_path(&host.paths).exists(),
8952            "a host with nothing refused leaves nothing behind"
8953        );
8954        clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
8955    }
8956
8957    /// Same rule as the heartbeat: absence means "every policy is placing
8958    /// runners", so a record that cannot be parsed must not be reported as one.
8959    #[test]
8960    fn a_malformed_refusal_is_an_error_and_not_silently_none() {
8961        let host = Host::new();
8962        std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
8963        let error = runner_root_refusals(&host.paths)
8964            .expect_err("an unparseable record is not the same as no record");
8965        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
8966    }
8967
8968    /// The half of this feature that turns the file into a diagnosis, and the
8969    /// half that had no test: `service status` must actually read it.
8970    ///
8971    /// A **note** and not a problem, deliberately. `service status` runs as
8972    /// whoever typed it, must stay readable on a host with nothing installed,
8973    /// and drives an exit code -- and nothing an operator can type clears this
8974    /// record, so a problem would fail that exit code on a host whose root was
8975    /// fixed but which has had no queued job since, with a printed remedy
8976    /// (`service uninstall && service install`) that does not touch `state/`.
8977    #[test]
8978    fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
8979        let host = Host::new();
8980        record_runner_root_refusal(
8981            &host.paths,
8982            "policy-a",
8983            DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
8984            "denied_by_privacy_policy",
8985            "/Volumes/NVME/runners",
8986            "Grant Full Disk Access to the program that runs the service",
8987        )
8988        .expect("a writable record");
8989
8990        let status = host.operations().status().expect("a readable status");
8991        let notes = status.notes().join("\n");
8992
8993        assert!(
8994            notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
8995            "the directory and the remediation the log had to scrub must appear here: {notes}"
8996        );
8997        assert!(
8998            notes.contains("policy-a"),
8999            "the operator has to know which target placed no runner: {notes}"
9000        );
9001        assert!(
9002            !status
9003                .problems()
9004                .iter()
9005                .any(|problem| problem.subject == "runner root"),
9006            "a record nothing an operator types can clear must not drive the exit code"
9007        );
9008    }
9009
9010    // -----------------------------------------------------------------------
9011    // -----------------------------------------------------------------------
9012    // Install
9013    // -----------------------------------------------------------------------
9014
9015    #[test]
9016    fn install_records_the_absolute_binary_path_and_the_four_directories() {
9017        let host = Host::new();
9018        let installed = host
9019            .operations()
9020            .install(&host.request(StartMode::Boot))
9021            .expect("an install against the recording controls");
9022
9023        assert_eq!(installed.record.binary, host.binary);
9024        assert!(installed.record.binary.is_absolute());
9025        assert_eq!(installed.record.start_mode, StartMode::Boot);
9026        assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
9027        assert_eq!(
9028            installed.record.directories,
9029            ServiceDirectories::of(&host.paths)
9030        );
9031        assert_eq!(
9032            installed.record.log_file,
9033            host.paths.logs_dir().join(LOG_FILE_STEM)
9034        );
9035        assert_eq!(installed.record.restart_delay_secs, 15);
9036
9037        let registrations = host.controls.registrations();
9038        assert_eq!(registrations.len(), 1);
9039        assert_eq!(registrations[0].0, StartMode::Boot);
9040        assert_eq!(registrations[0].1, "runner-manager");
9041    }
9042
9043    #[test]
9044    fn install_is_refused_while_the_single_instance_lock_is_held() {
9045        let host = Host::new();
9046        // The discriminator: with the lock free, the same call succeeds.
9047        {
9048            let operations = host.operations();
9049            operations
9050                .install(&host.request(StartMode::Boot))
9051                .expect("an install with the lock free");
9052            operations.uninstall().expect("a clean slate");
9053        }
9054
9055        let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
9056            .expect("this process takes the lock first");
9057
9058        let error = host
9059            .operations()
9060            .install(&host.request(StartMode::Boot))
9061            .expect_err("a second agent must not be registered while one is running");
9062        assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
9063        assert!(
9064            error.to_string().contains("already running"),
9065            "the message must be actionable: {error}"
9066        );
9067        assert!(
9068            host.controls.registrations().is_empty(),
9069            "a refused install must register nothing"
9070        );
9071        assert_eq!(
9072            InstallRecord::read(&host.paths).expect("readable"),
9073            None,
9074            "a refused install must write no record"
9075        );
9076    }
9077
9078    /// Installing over the same start mode replaces the registration.
9079    ///
9080    /// It used to be refused, and the refusal is what broke a real host: the
9081    /// caller has already swapped the binary the service runs by the time this
9082    /// is reached — that is the documented way to upgrade by hand — so refusing
9083    /// left a daemon running an unregistered binary and, on macOS, one the
9084    /// stored credential's keychain grant did not name.
9085    #[test]
9086    fn installing_over_the_same_start_mode_replaces_the_registration() {
9087        let host = Host::new();
9088        let operations = host.operations();
9089        operations
9090            .install(&host.request(StartMode::Boot))
9091            .expect("the first install");
9092
9093        let again = operations
9094            .install(&host.request(StartMode::Boot))
9095            .expect("an install over the same mode replaces rather than refusing");
9096
9097        assert!(
9098            again.replaced_existing,
9099            "the operator is told this replaced something rather than made it"
9100        );
9101        assert_eq!(
9102            host.controls.registrations().len(),
9103            1,
9104            "replacing must not leave two registrations behind"
9105        );
9106        assert_eq!(
9107            InstallRecord::read(&host.paths)
9108                .expect("readable")
9109                .expect("a record")
9110                .start_mode,
9111            StartMode::Boot
9112        );
9113    }
9114
9115    /// The *other* mode is still refused, because changing it moves the
9116    /// registration between two service managers and changes the account and
9117    /// the secret store with it. `set_start_mode` is what does that.
9118    #[test]
9119    fn installing_over_the_other_start_mode_is_refused() {
9120        let host = Host::new();
9121        let operations = host.operations();
9122        operations
9123            .install(&host.request(StartMode::Boot))
9124            .expect("the first install");
9125
9126        let error = operations
9127            .install(&host.request(StartMode::Login))
9128            .expect_err("a mode change is not an install");
9129        assert!(
9130            matches!(
9131                error,
9132                ServiceError::AlreadyInstalled {
9133                    existing: StartMode::Boot,
9134                    requested: StartMode::Login,
9135                    ..
9136                }
9137            ),
9138            "{error}"
9139        );
9140        assert!(
9141            !error
9142                .to_string()
9143                .contains("switch the start mode in place,"),
9144            "the old remedy named a capability no command offers; the terminal UI is where \
9145             the start mode moves: {error}"
9146        );
9147        assert_eq!(host.controls.registrations().len(), 1);
9148    }
9149
9150    #[test]
9151    fn install_rolls_back_the_registration_when_record_persistence_fails() {
9152        let host = Host::new();
9153        let record_path = InstallRecord::path(&host.paths);
9154        std::fs::create_dir(&record_path).expect("a directory blocks the record file");
9155
9156        let error = host
9157            .operations()
9158            .install(&host.request(StartMode::Boot))
9159            .expect_err("record persistence must fail");
9160
9161        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
9162        assert!(
9163            host.controls.registrations().is_empty(),
9164            "a failed install must not leave a live unrecorded registration"
9165        );
9166        assert!(
9167            host.controls
9168                .calls()
9169                .iter()
9170                .any(|call| call == "uninstall runner-manager (boot)"),
9171            "the registration must be explicitly rolled back: {:?}",
9172            host.controls.calls()
9173        );
9174        assert!(
9175            !host.runner_root.as_path().exists(),
9176            "the rollback must take the runner root this install created with it; a directory \
9177             prepared for a registration that does not exist is litter, and on Windows it is \
9178             litter with a security descriptor"
9179        );
9180    }
9181
9182    // -----------------------------------------------------------------------
9183    // The runner root (b2)
9184    // -----------------------------------------------------------------------
9185
9186    #[test]
9187    fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
9188        use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
9189
9190        // The mapping `prepare_runner_root` reads, asserted against the same
9191        // function the registration's own principal comes from — which is the
9192        // point of deriving it there rather than restating it.
9193        assert_eq!(
9194            ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
9195            ServiceAccount::LocalSystem
9196        );
9197        assert_eq!(
9198            ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
9199            ServiceAccount::InvokingUser
9200        );
9201
9202        let boot = default_root_sddl(&RootAdmission::LocalSystem);
9203        assert!(!grants_broad_write(&boot), "{boot}");
9204        assert!(
9205            !boot.contains("S-1-5-21"),
9206            "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
9207        );
9208
9209        // A login task runs under a *filtered* token — `RunLevel` is
9210        // `LeastPrivilege`, see `windows_scheduled_task_xml` — in which
9211        // Administrators is deny-only. Without an ace of its own the account
9212        // the task runs as would be admitted by nothing at all.
9213        let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
9214        assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
9215        assert!(!grants_broad_write(&login), "{login}");
9216    }
9217
9218    #[test]
9219    fn an_install_reports_the_runner_root_it_prepared() {
9220        let host = Host::new();
9221        let installed = host
9222            .operations()
9223            .install(&host.request(StartMode::Boot))
9224            .expect("an install");
9225        let rendered = installed.runner_root.to_string();
9226        assert!(
9227            !rendered.contains("S-1-5-21"),
9228            "the report must add no identity to the output: {rendered}"
9229        );
9230        if cfg!(windows) {
9231            assert_eq!(
9232                installed.runner_root.path(),
9233                Some(host.runner_root.as_path())
9234            );
9235            assert!(
9236                host.runner_root.as_path().is_dir(),
9237                "the directory jobs would run in has to exist once the service is registered"
9238            );
9239        } else {
9240            assert_eq!(
9241                installed.runner_root,
9242                crate::runner_root_access::RootAccessSummary::NotApplicable,
9243                "macOS and Linux keep the runtime directory they have always used"
9244            );
9245        }
9246    }
9247
9248    #[test]
9249    fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
9250        let host = Host::new();
9251        let operations = host.operations();
9252        operations
9253            .install(&host.request(StartMode::Boot))
9254            .expect("an install at boot");
9255
9256        let change = operations
9257            .set_start_mode(StartMode::Login)
9258            .expect("a switch to login");
9259
9260        assert!(change.changed);
9261        if cfg!(windows) {
9262            assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
9263            assert!(
9264                host.runner_root.as_path().is_dir(),
9265                "the switch must not remove the directory it reconciled"
9266            );
9267        }
9268        // The operator is told, because the account that may write there has
9269        // moved with the mode.
9270        assert!(
9271            change.to_string().contains("runner root"),
9272            "{}",
9273            change.to_string()
9274        );
9275    }
9276
9277    #[test]
9278    fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
9279        let host = Host::new();
9280        let operations = host.operations();
9281        operations
9282            .install(&host.request(StartMode::Boot))
9283            .expect("an install at boot");
9284
9285        let change = operations
9286            .set_start_mode(StartMode::Boot)
9287            .expect("a switch to the mode already in force");
9288
9289        assert!(!change.changed);
9290        assert_eq!(
9291            change.runner_root,
9292            crate::runner_root_access::RootAccessSummary::NotApplicable,
9293            "nothing moves, so nothing about the root's access control has to; reconciling here \
9294             would turn a no-op command into one that can fail on a permission it does not need"
9295        );
9296    }
9297
9298    #[cfg(windows)]
9299    #[test]
9300    fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
9301        let host = Host::new();
9302        host.controls
9303            .fail_next_install(StartMode::Boot, "injected registration failure");
9304
9305        let error = host
9306            .operations()
9307            .install(&host.request(StartMode::Boot))
9308            .expect_err("the manager refuses the registration");
9309
9310        assert!(matches!(error, ServiceError::Control { .. }), "{error}");
9311        assert!(
9312            !host.runner_root.as_path().exists(),
9313            "the directory was created for a registration that does not exist"
9314        );
9315    }
9316
9317    #[cfg(windows)]
9318    #[test]
9319    fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
9320        let host = Host::new();
9321        // What a directory created below `C:\` with inheritance left on looks
9322        // like, built deliberately because a per-account `%TEMP%` never
9323        // produces one by accident.
9324        crate::runner_root_access::create_with_descriptor_for_tests(
9325            host.runner_root.as_path(),
9326            "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
9327        )
9328        .expect("a deliberately open runner root");
9329        let before = crate::runner_root_access::report(host.runner_root.as_path());
9330
9331        let error = host
9332            .operations()
9333            .install(&host.request(StartMode::Boot))
9334            .expect_err("an open runner root is refused");
9335
9336        assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
9337        assert!(
9338            error.to_string().contains("nothing was registered"),
9339            "{error}"
9340        );
9341        assert!(
9342            host.controls.registrations().is_empty(),
9343            "the refusal has to come before the platform is asked to register anything: {:?}",
9344            host.controls.calls()
9345        );
9346        assert_eq!(
9347            crate::runner_root_access::report(host.runner_root.as_path()),
9348            before,
9349            "an open directory is refused rather than tightened: its contents cannot be trusted, \
9350             so adopting it would be worse than declining it"
9351        );
9352    }
9353
9354    #[cfg(windows)]
9355    #[test]
9356    fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
9357        let host = Host::new();
9358        let operations = host.operations();
9359        operations
9360            .install(&host.request(StartMode::Boot))
9361            .expect("an install");
9362        assert!(host.runner_root.as_path().is_dir());
9363
9364        operations.uninstall().expect("an uninstall");
9365
9366        assert!(
9367            host.runner_root.as_path().is_dir(),
9368            "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
9369             runner root may hold an operator's retained workspaces."
9370        );
9371    }
9372
9373    #[test]
9374    fn install_reviews_what_it_registered() {
9375        let host = Host::new();
9376        let installed = host
9377            .operations()
9378            .install(&host.request(StartMode::Boot))
9379            .expect("an install");
9380        assert!(
9381            installed.review.is_least_privilege(),
9382            "{}",
9383            installed.review
9384        );
9385        assert!(
9386            !installed.review.controls().is_empty(),
9387            "a review that confirms nothing proves nothing: {}",
9388            installed.review
9389        );
9390        assert_eq!(
9391            installed.review.kind(),
9392            host_definition_kind(StartMode::Boot),
9393            "the review must be of the definition this host's manager was given"
9394        );
9395        assert!(
9396            !installed.review.account().justification().is_empty(),
9397            "a privileged account with no stated reason is an unreviewed one"
9398        );
9399    }
9400
9401    // -----------------------------------------------------------------------
9402    // Uninstall deletes nothing else
9403    // -----------------------------------------------------------------------
9404
9405    #[test]
9406    fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
9407        let host = Host::new();
9408        let operations = host.operations();
9409        operations
9410            .install(&host.request(StartMode::Boot))
9411            .expect("an install");
9412
9413        // The files `05-infrastructure.md` says must survive. Written *after*
9414        // the install so that the snapshot below is of a host in the state an
9415        // operator's would be in.
9416        let config = host.paths.config_dir();
9417        std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
9418        std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
9419        std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
9420        std::fs::write(
9421            host.paths
9422                .state_dir()
9423                .join("packages/2.330.0/runner.tar.gz"),
9424            b"cached package",
9425        )
9426        .expect("writable");
9427        std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
9428        std::fs::write(
9429            host.paths.state_dir().join("secrets/user-access-token"),
9430            b"a stand-in for the stored credential",
9431        )
9432        .expect("writable");
9433        std::fs::write(
9434            host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
9435            b"diagnostics",
9436        )
9437        .expect("writable");
9438
9439        let roots: Vec<PathBuf> = host
9440            .paths
9441            .all()
9442            .iter()
9443            .map(|(_, path)| (*path).to_path_buf())
9444            .collect();
9445        let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
9446        let before = snapshot(&roots);
9447
9448        // Non-vacuity: a comparison of two empty maps would pass whatever
9449        // `uninstall` did.
9450        assert!(
9451            before.len() >= 6,
9452            "the fixture must actually contain the files this test is about, got {before:#?}"
9453        );
9454        let record_path = InstallRecord::path(&host.paths);
9455        assert!(
9456            before.contains_key(&record_path),
9457            "the install record must be present before uninstall"
9458        );
9459
9460        let uninstalled = operations.uninstall().expect("an uninstall");
9461        assert!(uninstalled.removed_registration);
9462        assert!(uninstalled.removed_record);
9463
9464        let after = snapshot(&roots);
9465
9466        // Exactly one thing changed, and it is the registration's own record.
9467        let mut expected = before.clone();
9468        expected.remove(&record_path);
9469        assert_eq!(
9470            after, expected,
9471            "uninstall must remove its own record and nothing else"
9472        );
9473        assert!(
9474            !record_path.exists(),
9475            "the record itself must go, or `uninstall` did nothing at all"
9476        );
9477        assert!(
9478            uninstalled
9479                .preserved
9480                .iter()
9481                .all(|path| roots.contains(&path.as_path())),
9482            "the preserved list must name the four directories: {uninstalled}"
9483        );
9484    }
9485
9486    #[test]
9487    fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
9488        let host = Host::new();
9489        let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
9490        assert!(!uninstalled.removed_registration);
9491        assert!(!uninstalled.removed_record);
9492    }
9493
9494    #[test]
9495    fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
9496        let host = Host::new();
9497        let operations = host.operations();
9498        operations
9499            .install(&host.request(StartMode::Boot))
9500            .expect("an install");
9501        std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
9502
9503        let uninstalled = operations.uninstall().expect("an uninstall");
9504        assert!(
9505            uninstalled.removed_registration,
9506            "a lost record must not strand a registration"
9507        );
9508        assert!(host.controls.registrations().is_empty());
9509    }
9510
9511    // -----------------------------------------------------------------------
9512    // Switching start mode
9513    // -----------------------------------------------------------------------
9514
9515    #[test]
9516    fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
9517        let host = Host::new();
9518        let operations = host.operations();
9519        operations
9520            .install(&host.request(StartMode::Boot))
9521            .expect("an install at boot");
9522
9523        // The discriminator. If the switch re-resolved the binary the way
9524        // `install` does, it would either fail here or silently record the test
9525        // binary instead. Removing the file makes the difference visible.
9526        std::fs::remove_file(&host.binary).expect("the installed binary goes away");
9527
9528        let change = operations
9529            .set_start_mode(StartMode::Login)
9530            .expect("a switch that does not reinstall the product");
9531        assert!(change.changed);
9532        assert_eq!(change.from, StartMode::Boot);
9533        assert_eq!(change.to, StartMode::Login);
9534        assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
9535
9536        let record = InstallRecord::read(&host.paths)
9537            .expect("readable")
9538            .expect("a record");
9539        assert_eq!(record.start_mode, StartMode::Login);
9540        assert_eq!(
9541            record.binary, host.binary,
9542            "the recorded path must survive the switch untouched"
9543        );
9544
9545        let registrations = host.controls.registrations();
9546        assert_eq!(registrations.len(), 1, "{registrations:?}");
9547        assert_eq!(registrations[0].0, StartMode::Login);
9548        assert!(
9549            registrations[0]
9550                .2
9551                .command_line
9552                .contains(&host.binary.to_string_lossy().into_owned()),
9553            "{:?}",
9554            registrations[0].2
9555        );
9556    }
9557
9558    #[test]
9559    fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
9560        let host = Host::new();
9561        let operations = host.operations();
9562        operations
9563            .install(&host.request(StartMode::Boot))
9564            .expect("an install at boot");
9565        let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
9566        host.controls
9567            .fail_next_install(StartMode::Login, "injected target failure");
9568
9569        let error = operations
9570            .set_start_mode(StartMode::Login)
9571            .expect_err("the target manager refuses the install");
9572
9573        assert!(matches!(error, ServiceError::Control { .. }), "{error}");
9574        assert_eq!(
9575            std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
9576            record_before
9577        );
9578        let registrations = host.controls.registrations();
9579        assert_eq!(registrations.len(), 1, "{registrations:?}");
9580        assert_eq!(registrations[0].0, StartMode::Boot);
9581    }
9582
9583    #[test]
9584    fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
9585        let host = Host::new();
9586        let operations = host.operations();
9587        operations
9588            .install(&host.request(StartMode::Boot))
9589            .expect("an install at boot");
9590        let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
9591        let config = host.paths.config_dir().to_path_buf();
9592        let hidden = config.with_file_name("config-hidden-by-fault");
9593        host.controls.hide_directory_after_install(
9594            StartMode::Login,
9595            config.clone(),
9596            hidden.clone(),
9597        );
9598
9599        let error = operations
9600            .set_start_mode(StartMode::Login)
9601            .expect_err("the injected filesystem fault prevents persistence");
9602
9603        std::fs::remove_file(&config).expect("remove the injected blocker");
9604        std::fs::rename(&hidden, &config).expect("restore the record directory");
9605        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
9606        assert_eq!(
9607            std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
9608            record_before
9609        );
9610        let registrations = host.controls.registrations();
9611        assert_eq!(registrations.len(), 1, "{registrations:?}");
9612        assert_eq!(registrations[0].0, StartMode::Boot);
9613        assert!(
9614            host.controls
9615                .calls()
9616                .iter()
9617                .any(|call| call == "uninstall runner-manager (login)"),
9618            "the target must be rolled back: {:?}",
9619            host.controls.calls()
9620        );
9621    }
9622
9623    #[test]
9624    fn switching_to_the_mode_already_in_force_registers_nothing_again() {
9625        let host = Host::new();
9626        let operations = host.operations();
9627        operations
9628            .install(&host.request(StartMode::Boot))
9629            .expect("an install");
9630        let before = host.controls.calls().len();
9631
9632        let change = operations
9633            .set_start_mode(StartMode::Boot)
9634            .expect("a no-op switch");
9635        assert!(!change.changed);
9636        assert_eq!(
9637            host.controls.calls().len(),
9638            before,
9639            "a no-op switch must not touch the service manager"
9640        );
9641    }
9642
9643    #[test]
9644    fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
9645        let host = Host::new();
9646        let error = host
9647            .operations()
9648            .set_start_mode(StartMode::Login)
9649            .expect_err("there is nothing to switch");
9650        assert!(
9651            matches!(error, ServiceError::NotInstalled { .. }),
9652            "{error}"
9653        );
9654    }
9655
9656    // -----------------------------------------------------------------------
9657    // Status
9658    // -----------------------------------------------------------------------
9659
9660    #[test]
9661    fn status_reports_the_four_facts_journey_five_asks_for() {
9662        let host = Host::new();
9663        let operations = host.operations();
9664        operations
9665            .install(&host.request(StartMode::Boot))
9666            .expect("an install");
9667        let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
9668            .expect("a valid timestamp")
9669            .with_timezone(&Utc);
9670        record_github_contact(&host.paths, at).expect("a heartbeat");
9671
9672        let status = operations.status().expect("a status");
9673        assert_eq!(status.start_mode(), Some(StartMode::Boot));
9674        assert_eq!(
9675            status.binary().map(BinaryPath::recorded),
9676            Some(host.binary.as_path())
9677        );
9678        assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
9679        assert_eq!(status.last_github_contact(), Some(at));
9680        assert!(status.is_installed());
9681        assert!(status.is_healthy(), "{status}");
9682
9683        let printed = status.to_string();
9684        for fragment in [
9685            "start mode",
9686            "diagnostic log",
9687            "last GitHub contact",
9688            "binary",
9689        ] {
9690            assert!(printed.contains(fragment), "{printed}");
9691        }
9692    }
9693
9694    /// A record this account may not read is reported, and `status` keeps
9695    /// going.
9696    ///
9697    /// It used to end the command with `Permission denied`, which is exactly
9698    /// what an operator met on a boot-mode host: `sudo service install` wrote
9699    /// the record `0600` and `root`-owned, and `service status` — a command
9700    /// that asks no privilege of anybody — then printed nothing at all about a
9701    /// registration the service manager could describe perfectly well.
9702    ///
9703    /// Ownership cannot be reproduced without privileges; the mode that made it
9704    /// fatal can.
9705    #[cfg(unix)]
9706    #[test]
9707    fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
9708        use std::os::unix::fs::PermissionsExt as _;
9709
9710        // `root` is not subject to the mode bits and would read the record
9711        // regardless, leaving the assertions below testing nothing.
9712        // SAFETY: `geteuid` takes no argument and cannot fail.
9713        if unsafe { libc::geteuid() } == 0 {
9714            return;
9715        }
9716
9717        let host = Host::new();
9718        let operations = host.operations();
9719        operations
9720            .install(&host.request(StartMode::Boot))
9721            .expect("an install");
9722        assert!(
9723            operations.status().expect("a status").is_healthy(),
9724            "the discriminator: healthy before the record is made unreadable"
9725        );
9726
9727        let record = InstallRecord::path(&host.paths);
9728        std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
9729            .expect("the mode is applied");
9730
9731        let status = operations
9732            .status()
9733            .expect("a record this account may not read is reported, not thrown");
9734        assert!(status.is_installed(), "{status}");
9735        assert!(!status.is_healthy(), "{status}");
9736
9737        let printed = status.to_string();
9738        assert!(
9739            printed.contains("this account may not read it"),
9740            "the operator is told which of the two states this is: {printed}"
9741        );
9742        assert!(
9743            !printed.contains("there is no install record"),
9744            "a record that is there and unreadable is not a record that is missing, and the \
9745             missing one's remedy starts with `service uninstall`: {printed}"
9746        );
9747
9748        // Left readable so the fixture's own cleanup is not the thing that
9749        // fails on a host where TempDir removal walks the tree.
9750        std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
9751            .expect("the mode is restored");
9752    }
9753
9754    #[test]
9755    fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
9756        let host = Host::new();
9757        let operations = host.operations();
9758        operations
9759            .install(&host.request(StartMode::Boot))
9760            .expect("an install");
9761
9762        // The discriminator: healthy first, so a `is_healthy` that always
9763        // returned false could not pass this test.
9764        assert!(
9765            operations.status().expect("a status").is_healthy(),
9766            "the freshly installed host must be healthy"
9767        );
9768
9769        std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
9770
9771        let status = operations.status().expect("a status");
9772        assert!(!status.is_healthy(), "{status}");
9773        assert!(
9774            status
9775                .problems()
9776                .iter()
9777                .any(|problem| problem.subject == "binary"),
9778            "{status}"
9779        );
9780        assert!(status.to_string().contains("STALE"), "{status}");
9781    }
9782
9783    #[test]
9784    fn status_reports_a_registration_that_would_not_start_at_boot() {
9785        let host = Host::new();
9786        let operations = host.operations();
9787        operations
9788            .install(&host.request(StartMode::Boot))
9789            .expect("an install");
9790        assert!(operations.status().expect("a status").is_healthy());
9791
9792        host.controls.edit("runner-manager", |registration| {
9793            registration.starts_automatically = false;
9794        });
9795
9796        let status = operations.status().expect("a status");
9797        assert!(!status.is_healthy(), "{status}");
9798        assert!(
9799            status
9800                .problems()
9801                .iter()
9802                .any(|problem| problem.detail.contains("after a reboot")),
9803            "{status}"
9804        );
9805    }
9806
9807    #[test]
9808    fn install_starts_the_registration_and_a_later_stop_is_unhealthy() {
9809        let host = Host::new();
9810        let operations = host.operations();
9811        operations
9812            .install(&host.request(StartMode::Login))
9813            .expect("install and immediate start");
9814
9815        assert!(
9816            operations.status().expect("running status").is_running(),
9817            "install must not wait for the next login trigger"
9818        );
9819        operations.stop().expect("stop the registration");
9820        let status = operations.status().expect("stopped status");
9821        assert!(!status.is_healthy(), "{status}");
9822        assert!(
9823            status
9824                .problems()
9825                .iter()
9826                .any(|problem| problem.subject == "runtime"),
9827            "{status}"
9828        );
9829        let rendered = status.to_string();
9830        assert!(
9831            rendered.contains("runner-manager service install --start-at login"),
9832            "{rendered}"
9833        );
9834        assert!(
9835            !rendered.contains("runner-manager service start"),
9836            "{rendered}"
9837        );
9838    }
9839
9840    #[test]
9841    fn status_reports_a_restart_policy_something_else_edited() {
9842        let host = Host::new();
9843        let operations = host.operations();
9844        operations
9845            .install(&host.request(StartMode::Boot))
9846            .expect("an install");
9847        assert!(operations.status().expect("a status").is_healthy());
9848
9849        host.controls.edit("runner-manager", |registration| {
9850            registration.restart_delay = Some(Duration::from_secs(1));
9851        });
9852
9853        let status = operations.status().expect("a status");
9854        assert!(!status.is_healthy(), "{status}");
9855        assert!(
9856            status
9857                .problems()
9858                .iter()
9859                .any(|problem| problem.subject == "restart policy"),
9860            "{status}"
9861        );
9862    }
9863
9864    #[test]
9865    fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
9866        let host = Host::new();
9867        let operations = host.operations();
9868        operations
9869            .install(&host.request(StartMode::Boot))
9870            .expect("an install");
9871        let other = host.binary.with_file_name("someone-elses.exe");
9872        std::fs::write(&other, b"x").expect("writable");
9873
9874        host.controls.edit("runner-manager", |registration| {
9875            registration.command_line = quote_argument(&other.to_string_lossy());
9876        });
9877
9878        let status = operations.status().expect("a status");
9879        assert!(!status.is_healthy(), "{status}");
9880        assert!(
9881            matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
9882            "{status}"
9883        );
9884    }
9885
9886    #[test]
9887    fn status_reports_a_record_no_service_manager_knows_about() {
9888        let host = Host::new();
9889        let operations = host.operations();
9890        operations
9891            .install(&host.request(StartMode::Boot))
9892            .expect("an install");
9893        // Something removed the registration behind this product's back.
9894        for mode in [StartMode::Boot, StartMode::Login] {
9895            host.controls
9896                .control(mode)
9897                .expect("a control")
9898                .uninstall(&ServiceIdentity::product())
9899                .expect("removed");
9900        }
9901
9902        let status = operations.status().expect("a status");
9903        assert!(!status.is_healthy(), "{status}");
9904        assert!(
9905            status
9906                .problems()
9907                .iter()
9908                .any(|problem| problem.subject == "registration"),
9909            "{status}"
9910        );
9911    }
9912
9913    #[test]
9914    fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
9915        let host = Host::new();
9916        let status = host.operations().status().expect("a status");
9917        assert!(!status.is_installed());
9918        assert!(
9919            status.is_healthy(),
9920            "a host that never installed the service has no fault to report: {status}"
9921        );
9922        assert!(status.to_string().contains("installed"), "{status}");
9923    }
9924
9925    #[test]
9926    fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
9927        let host = Host::new();
9928        let operations = host.operations();
9929        operations
9930            .install(&host.request(StartMode::Login))
9931            .expect("an install at login");
9932        let status = operations.status().expect("a status");
9933        assert!(
9934            status
9935                .notes()
9936                .iter()
9937                .any(|note| note.contains("does not run until the operator signs in")),
9938            "05-infrastructure.md requires `service status` to say so: {status}"
9939        );
9940    }
9941
9942    // -----------------------------------------------------------------------
9943    // Start and stop
9944    // -----------------------------------------------------------------------
9945
9946    #[test]
9947    fn start_and_stop_reach_the_domain_that_holds_the_registration() {
9948        let host = Host::new();
9949        let operations = host.operations();
9950        operations
9951            .install(&host.request(StartMode::Login))
9952            .expect("an install at login");
9953        operations.start().expect("a start");
9954        assert!(operations.status().expect("a status").is_running());
9955        assert!(operations.stop().expect("a stop"));
9956        assert!(!operations.status().expect("a status").is_running());
9957    }
9958
9959    #[test]
9960    fn starting_a_host_with_no_registration_is_refused() {
9961        let host = Host::new();
9962        let error = host.operations().start().expect_err("nothing to start");
9963        assert!(
9964            matches!(error, ServiceError::NotInstalled { .. }),
9965            "{error}"
9966        );
9967    }
9968
9969    // -----------------------------------------------------------------------
9970    // The seam with `d2`
9971    // -----------------------------------------------------------------------
9972
9973    /// Requirement 2 is *"an account that can **read the secret store**"*, and
9974    /// on Windows what an account may read is decided by a DACL in a file this
9975    /// task does not own. Asserting the account name alone would be asserting
9976    /// this module against itself; this reads `d2`'s protection back off a real
9977    /// store and checks that it admits the account this installer registers.
9978    ///
9979    /// No privileges are needed. `d2` documents that a rooted machine-scoped
9980    /// store *"is protected and encrypted exactly as the standard one is"* —
9981    /// the Windows backend picks its DACL from the scope, not from the site —
9982    /// so a store in a temporary directory carries the access control the real
9983    /// one does.
9984    #[cfg(windows)]
9985    #[test]
9986    fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
9987        use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
9988
9989        let root = tempfile::tempdir().expect("a temporary directory");
9990        let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
9991            .expect("a rooted machine-scoped store");
9992        store
9993            .store(&secrecy::SecretString::from("a stand-in for the token"))
9994            .expect("the store accepts a value");
9995        let protection = store.protection().expect("the DACL can be read back");
9996
9997        assert!(
9998            protection.description().contains(";;;SY)"),
9999            "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
10000             read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
10001            protection.description()
10002        );
10003        assert_eq!(
10004            ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
10005            ServiceAccount::LocalSystem,
10006            "and that is the account this installer registers, which is why SY is what matters"
10007        );
10008        assert!(
10009            !protection.readable_by_other_local_users(),
10010            "the same DACL must still exclude ordinary local users: {}",
10011            protection.description()
10012        );
10013
10014        // The other half of the analysis in `docs/service-account.md`, and the
10015        // reason LocalService and NetworkService are not used: the DACL names
10016        // three trustees and neither of them is among them.
10017        for rejected in [";;;LS)", ";;;NS)"] {
10018            assert!(
10019                !protection.description().contains(rejected),
10020                "if the store ever admitted {rejected}, the least-privilege analysis in \
10021                 docs/service-account.md would need redoing: {}",
10022                protection.description()
10023            );
10024        }
10025    }
10026}