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, BTreeSet};
91use std::ffi::OsString;
92use std::fmt;
93use std::path::{Path, PathBuf};
94use std::time::Duration;
95
96use chrono::{DateTime, Utc};
97use runner_manager_domain::model::StartMode;
98use runner_manager_domain::path::LocalAbsolutePath;
99use serde::{Deserialize, Serialize};
100
101use crate::lock::{HostLock, LockError, LockKind};
102use crate::paths::AppPaths;
103#[cfg(windows)]
104use crate::runner_root_access::RootAdmission;
105use crate::runner_root_access::{
106    Reversal, RootAccessChange, RootAccessError, RootAccessReport, RootAccessSummary,
107};
108
109// ---------------------------------------------------------------------------
110// Names
111// ---------------------------------------------------------------------------
112
113/// The product's own service name, on every platform that wants a short one.
114pub const SERVICE_NAME: &str = "runner-manager";
115/// Hidden CLI marker carried only by Windows boot-service registrations.
116///
117/// Application-data arguments are shared by every service manager and cannot
118/// distinguish SCM from Task Scheduler. This marker is the durable routing
119/// contract between the Windows installer and the shipping binary.
120pub const WINDOWS_SCM_HOST_ARGUMENT: &str = "--windows-service-host";
121
122/// What an operator sees in `services.msc`, `launchctl list`, or
123/// `systemctl status`.
124pub const DISPLAY_NAME: &str = "GitHub Actions Runner Manager";
125
126/// One line of explanation, for the same three places.
127pub const DESCRIPTION: &str =
128    "Starts ephemeral GitHub Actions self-hosted runners on this machine on demand.";
129
130/// The arguments the installed command line carries.
131///
132/// `05-infrastructure.md`: *"`service install` registers `daemon run` for the
133/// current host"*. Spelled once, here, so the installer and `f3`'s command
134/// surface cannot disagree about what was registered.
135pub const DAEMON_ARGUMENTS: [&str; 2] = ["daemon", "run"];
136
137/// The file `install` writes and `status` reads, inside `config/`.
138///
139/// `config/` and not `state/`: `05-infrastructure.md` gives `config/` to
140/// *"non-secret TOML"*, and this is exactly that — a record of what was
141/// registered, holding no credential. It is also the one file `uninstall`
142/// removes, which is what keeps "uninstall deletes no configuration" honest:
143/// the record is not configuration, it is the registration's own footprint.
144pub const RECORD_FILE: &str = "service.toml";
145
146/// The file the daemon touches after every successful GitHub call, inside
147/// `state/`.
148///
149/// See [`record_github_contact`] for the contract; `service status` reads it
150/// and Journey 5 step 4 requires it.
151pub const CONTACT_FILE: &str = "github-contact.toml";
152
153/// Present while GitHub is rejecting the daemon's credential, inside `state/`.
154///
155/// See [`record_github_credential_rejected`] for the contract.
156pub const CREDENTIAL_REJECTION_FILE: &str = "github-credential-rejected.toml";
157
158/// Present while the daemon cannot read its own stored credential, inside
159/// `state/`.
160///
161/// See [`record_credential_unreadable`] for the contract.
162pub const CREDENTIAL_UNREADABLE_FILE: &str = "credential-unreadable.toml";
163
164/// The `service status` subject a [`CREDENTIAL_UNREADABLE_FILE`] is reported
165/// under, which the TUI matches to offer the same command.
166pub const CREDENTIAL_UNREADABLE_SUBJECT: &str = "stored credential";
167
168/// The `service status` subject blocked runner launches are reported under.
169pub const LAUNCHES_BLOCKED_SUBJECT: &str = "runner launches";
170
171/// The [`StatusProblem::subject`] of a daemon that has stopped making progress.
172pub const DAEMON_STALLED_SUBJECT: &str = "daemon progress";
173
174/// The [`StatusProblem::subject`] of a daemon that refuses to start runners
175/// because a required host check fails from its own session.
176pub const HOST_UNFIT_SUBJECT: &str = "host fitness";
177
178/// The agent's last runner-root refusal, for `service status` to report.
179///
180/// See [`record_runner_root_refusal`] for the contract.
181pub const ROOT_REFUSAL_FILE: &str = "runner-root-refusal.toml";
182
183/// The rotating diagnostic log the **daemon** writes, inside `logs/`.
184///
185/// The appender adds its own date suffix, so this is the stem rather than a
186/// file that exists. `service status` reports the directory and the stem,
187/// because that is what an operator needs in order to find today's file and
188/// yesterday's.
189///
190/// It is [`crate::logging::SERVICE_LOG_STEM`] and not the operator's, because a
191/// boot-mode daemon runs under a different account and writes a different file.
192/// [`crate::logging::LogRole`] says why the two were separated.
193pub const LOG_FILE_STEM: &str = crate::logging::SERVICE_LOG_STEM;
194
195// ---------------------------------------------------------------------------
196// Identity
197// ---------------------------------------------------------------------------
198
199/// What the operating system calls this registration.
200///
201/// A type rather than three constants, for one reason that is not tidiness: the
202/// privileged installer tests register a **real** service on a **real** machine,
203/// and they must not be able to collide with — or remove — an operator's
204/// installation. [`ServiceIdentity::fixture`] produces a name that is
205/// unmistakably a test artefact, and every backend takes its name from here, so
206/// there is no path by which a test reaches the product's own registration.
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct ServiceIdentity {
209    name: String,
210    display_name: String,
211    description: String,
212}
213
214impl ServiceIdentity {
215    /// The registration an operator installs.
216    #[must_use]
217    pub fn product() -> Self {
218        Self {
219            name: SERVICE_NAME.to_string(),
220            display_name: DISPLAY_NAME.to_string(),
221            description: DESCRIPTION.to_string(),
222        }
223    }
224
225    /// A disposable registration for a privileged test, named so that it cannot
226    /// be mistaken for — or collide with — [`ServiceIdentity::product`].
227    ///
228    /// `tag` distinguishes concurrent runs; it is reduced to ASCII alphanumerics
229    /// and `-` so that it is a legal service name, launchd label, systemd unit
230    /// name, and Task Scheduler task name at once.
231    #[must_use]
232    pub fn fixture(tag: &str) -> Self {
233        let tag: String = tag
234            .chars()
235            .map(|c| {
236                if c.is_ascii_alphanumeric() {
237                    c.to_ascii_lowercase()
238                } else {
239                    '-'
240                }
241            })
242            .collect();
243        let name = format!("{SERVICE_NAME}-selftest-{tag}");
244        Self {
245            display_name: format!("{DISPLAY_NAME} (self-test fixture {tag})"),
246            description: "Disposable fixture created by runner-manager's own installer tests. \
247                          Safe to remove."
248                .to_string(),
249            name,
250        }
251    }
252
253    /// Whether this identity is a disposable test fixture.
254    ///
255    /// The privileged tests assert this before they delete anything, which is
256    /// the mechanical half of *"never touch a service you did not create"*.
257    #[must_use]
258    pub fn is_fixture(&self) -> bool {
259        self.name.starts_with(&format!("{SERVICE_NAME}-selftest-"))
260    }
261
262    /// The Windows service name, the systemd unit stem, and the Task Scheduler
263    /// task name.
264    #[must_use]
265    pub fn name(&self) -> &str {
266        &self.name
267    }
268
269    /// The human-readable name.
270    #[must_use]
271    pub fn display_name(&self) -> &str {
272        &self.display_name
273    }
274
275    /// One line of explanation.
276    #[must_use]
277    pub fn description(&self) -> &str {
278        &self.description
279    }
280
281    /// The launchd label — reverse-domain, as launchd expects.
282    ///
283    /// Built from the same three product segments [`crate::paths`] resolves the
284    /// application-data directories with, so a label and a directory cannot
285    /// drift apart.
286    #[must_use]
287    pub fn launchd_label(&self) -> String {
288        format!(
289            "{}.{}.{}",
290            crate::paths::QUALIFIER,
291            crate::paths::ORGANIZATION,
292            self.name
293        )
294    }
295
296    /// The systemd unit file name.
297    #[must_use]
298    pub fn systemd_unit(&self) -> String {
299        format!("{}.service", self.name)
300    }
301
302    /// The service manager's own command that restarts this registration.
303    ///
304    /// The product has no `service stop` or `service start`, and
305    /// `service install` is no restart either: it refuses while the running
306    /// daemon holds the agent lock. So a remedy that restarts the daemon names
307    /// the service manager's command. A forced restart does not wait for jobs;
308    /// it is for a daemon that has stopped making progress anyway.
309    #[must_use]
310    pub fn restart_command(&self, mode: StartMode) -> String {
311        let name = &self.name;
312        if cfg!(windows) {
313            match mode {
314                StartMode::Boot => {
315                    format!("Restart-Service -Name {name}")
316                }
317                StartMode::Login => format!("schtasks /End /TN {name}; schtasks /Run /TN {name}"),
318            }
319        } else if cfg!(target_os = "macos") {
320            let label = self.launchd_label();
321            match mode {
322                StartMode::Boot => format!("sudo launchctl kickstart -k system/{label}"),
323                StartMode::Login => format!("launchctl kickstart -k gui/$(id -u)/{label}"),
324            }
325        } else {
326            let unit = self.systemd_unit();
327            match mode {
328                StartMode::Boot => format!("sudo systemctl restart {unit}"),
329                StartMode::Login => format!("systemctl --user restart {unit}"),
330            }
331        }
332    }
333}
334
335impl fmt::Display for ServiceIdentity {
336    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
337        f.write_str(&self.name)
338    }
339}
340
341// ---------------------------------------------------------------------------
342// Restart policy
343// ---------------------------------------------------------------------------
344
345/// The restart-on-failure policy, and the bound on how fast it may retry.
346///
347/// `05-infrastructure.md` item 3: *"set a restart-on-failure policy with bounded
348/// delay"*. Both halves matter and they are different requirements. A service
349/// that does not come back after a crash defeats Journey 5; a service that comes
350/// back instantly, forever, turns one bad configuration into a fork bomb against
351/// GitHub's rate limit. So the delay has a floor as well as a ceiling, and the
352/// floor is what `does not restart-loop faster than that bound` is measured
353/// against.
354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355pub struct RestartPolicy {
356    delay: Duration,
357    reset_after: Duration,
358}
359
360impl RestartPolicy {
361    /// The shortest delay this will accept.
362    ///
363    /// One second is not a tuning preference. Below it, launchd's own throttle
364    /// and systemd's start-limit logic both take over and the configured number
365    /// stops being the number in force, so a policy under this floor would be a
366    /// value `service status` reported and the platform ignored.
367    pub const MIN_DELAY: Duration = Duration::from_secs(1);
368
369    /// The longest delay this will accept. Past five minutes an operator
370    /// watching a restart would reasonably conclude the service is simply gone.
371    pub const MAX_DELAY: Duration = Duration::from_secs(300);
372
373    /// What `service install` uses when nothing is said.
374    pub const DEFAULT_DELAY: Duration = Duration::from_secs(15);
375
376    /// How long the service must stay up before its failure count is forgotten.
377    pub const DEFAULT_RESET_AFTER: Duration = Duration::from_secs(600);
378
379    /// # Errors
380    ///
381    /// [`ServiceError::RestartDelay`] when `delay` is outside
382    /// [`RestartPolicy::MIN_DELAY`]..=[`RestartPolicy::MAX_DELAY`], or when
383    /// `reset_after` is not longer than `delay` — a reset window shorter than
384    /// the delay can never elapse between two restarts, so the failure count
385    /// would reset on every attempt and no start-limit could ever trip.
386    pub fn new(delay: Duration, reset_after: Duration) -> Result<Self, ServiceError> {
387        if delay < Self::MIN_DELAY || delay > Self::MAX_DELAY {
388            return Err(ServiceError::RestartDelay {
389                requested_secs: delay.as_secs(),
390                min_secs: Self::MIN_DELAY.as_secs(),
391                max_secs: Self::MAX_DELAY.as_secs(),
392            });
393        }
394        if reset_after <= delay {
395            return Err(ServiceError::RestartResetWindow {
396                reset_secs: reset_after.as_secs(),
397                delay_secs: delay.as_secs(),
398            });
399        }
400        Ok(Self { delay, reset_after })
401    }
402
403    /// How long the service manager waits before restarting a failed service.
404    #[must_use]
405    pub const fn delay(&self) -> Duration {
406        self.delay
407    }
408
409    /// How long the service must run before its failure count resets.
410    #[must_use]
411    pub const fn reset_after(&self) -> Duration {
412        self.reset_after
413    }
414
415    /// The delay a given manager can actually express.
416    ///
417    /// Three of the four take seconds and enforce exactly what they are given.
418    /// **Windows Task Scheduler does not**: `RestartOnFailure/Interval` is
419    /// expressed in whole minutes with a one-minute floor, and it *rejects* the
420    /// registration outright rather than rounding — `PT15S` comes back as
421    /// "The task XML contains a value which is incorrectly formatted or out of
422    /// range", which is how this was found.
423    ///
424    /// So the interval is rounded **up**, never down, and never below one
425    /// minute. The direction is the point: the requirement is that the service
426    /// *"does not restart-loop faster than that bound"*, and a delay longer
427    /// than the configured one still satisfies it, while a shorter one would
428    /// not. `service status` reports the difference as a note so that an
429    /// operator reading `15s` in the record and `60s` from the manager is told
430    /// why rather than left to wonder.
431    #[must_use]
432    pub const fn effective_delay(&self, kind: DefinitionKind) -> Duration {
433        match kind {
434            DefinitionKind::WindowsScheduledTask => {
435                let seconds = self.delay.as_secs();
436                let minutes = seconds.div_ceil(60);
437                Duration::from_secs(if minutes == 0 { 60 } else { minutes * 60 })
438            }
439            _ => self.delay,
440        }
441    }
442}
443
444impl Default for RestartPolicy {
445    fn default() -> Self {
446        Self {
447            delay: Self::DEFAULT_DELAY,
448            reset_after: Self::DEFAULT_RESET_AFTER,
449        }
450    }
451}
452
453impl fmt::Display for RestartPolicy {
454    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
455        write!(
456            f,
457            "restart after {}s, failure count resets after {}s",
458            self.delay.as_secs(),
459            self.reset_after.as_secs()
460        )
461    }
462}
463
464// ---------------------------------------------------------------------------
465// The account
466// ---------------------------------------------------------------------------
467
468/// The account a registration runs under.
469///
470/// Not an operator choice. It is a function of the start mode and the platform,
471/// because the start mode decides which secret store the daemon must read and
472/// the store's own access control decides which accounts can read it. See
473/// `docs/service-account.md`, and [`review_least_privilege`] for the check.
474#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
475#[serde(rename_all = "snake_case")]
476pub enum ServiceAccount {
477    /// `NT AUTHORITY\SYSTEM`. The only stock Windows account named by the
478    /// machine-scoped store's DACL.
479    LocalSystem,
480    /// `root`. What a LaunchDaemon and a systemd system unit run as, and what
481    /// the macOS System Keychain's root-only master key requires.
482    Root,
483    /// The operator's own account, for a login-mode registration.
484    InvokingUser,
485}
486
487impl ServiceAccount {
488    /// The account a given kind of definition obliges.
489    ///
490    /// Keyed on the definition rather than on `cfg!(windows)`, and the
491    /// difference is not academic: a Windows developer rendering the launchd
492    /// property list would otherwise write `UserName = NT AUTHORITY\SYSTEM`
493    /// into it, which is not an account macOS has. A definition's account is a
494    /// property of the definition, and the whole point of rendering being pure
495    /// is that any host can render any platform's and get the right answer.
496    #[must_use]
497    pub const fn for_definition(kind: DefinitionKind, mode: StartMode) -> Self {
498        match (kind, mode) {
499            (DefinitionKind::WindowsService, _) => Self::LocalSystem,
500            (DefinitionKind::WindowsScheduledTask, _) | (_, StartMode::Login) => Self::InvokingUser,
501            (_, StartMode::Boot) => Self::Root,
502        }
503    }
504
505    /// The account the given start mode obliges on the platform this binary was
506    /// built for.
507    #[must_use]
508    pub const fn for_start_mode(mode: StartMode) -> Self {
509        Self::for_definition(host_definition_kind(mode), mode)
510    }
511
512    /// How the platform spells it.
513    #[must_use]
514    pub const fn as_str(&self) -> &'static str {
515        match self {
516            Self::LocalSystem => "NT AUTHORITY\\SYSTEM",
517            Self::Root => "root",
518            Self::InvokingUser => "the invoking user",
519        }
520    }
521
522    /// Why this is the *minimum* account that can do the job, not merely the
523    /// convenient one.
524    ///
525    /// Printed by `service status` and by [`review_least_privilege`], because a
526    /// privileged account with no stated reason is indistinguishable from a
527    /// privileged account nobody thought about.
528    #[must_use]
529    pub const fn justification(&self) -> &'static str {
530        match self {
531            Self::LocalSystem => {
532                "the machine-scoped store's DACL names SY, BA and OW only; LocalService and \
533                 NetworkService cannot read it, and widening the DACL to reach them would grant \
534                 every service on this host read access to the one credential this product holds"
535            }
536            Self::Root => {
537                "a boot-time registration runs outside every login session: on macOS the System \
538                 Keychain is unlocked by /var/db/SystemKey, which is root-only, and on Linux the \
539                 machine-scoped store is a 0600 file under /var/lib that only root can open \
540                 before a session exists"
541            }
542            Self::InvokingUser => {
543                "a login-mode registration reads the user-scoped store, which is deliberately \
544                 readable by exactly one account and needs no elevation at all"
545            }
546        }
547    }
548
549    /// Whether registering under this account needs administrative rights.
550    #[must_use]
551    pub const fn needs_elevation(&self) -> bool {
552        matches!(self, Self::LocalSystem | Self::Root)
553    }
554}
555
556impl fmt::Display for ServiceAccount {
557    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
558        f.write_str(self.as_str())
559    }
560}
561
562// ---------------------------------------------------------------------------
563// Errors
564// ---------------------------------------------------------------------------
565
566/// Something went wrong installing, inspecting, or removing the registration.
567///
568/// Every variant is formatted straight into an operator-facing message, so each
569/// one says what to do next rather than only what failed. No variant carries a
570/// secret: this module never reads the token, only the *location* `d2` publishes
571/// for exactly this purpose.
572#[derive(Debug, thiserror::Error)]
573pub enum ServiceError {
574    /// The single-instance lock is held, so a second agent must not be
575    /// registered.
576    ///
577    /// `05-infrastructure.md` item 1. The message is `d1`'s, verbatim, because
578    /// `d1` already worked out what to tell an operator who lost that race.
579    #[error("cannot install the service while an agent is already running on this host: {source}")]
580    LockHeld {
581        /// What `d1` said, including who holds it.
582        #[source]
583        source: Box<LockError>,
584    },
585
586    /// The lock could not be inspected at all, which is not the same as it being
587    /// free.
588    #[error(
589        "cannot tell whether an agent is already running on this host, so the service was not \
590         installed: {source}. Fix the reported problem with the state directory and try again."
591    )]
592    LockUnreadable {
593        /// The underlying failure.
594        #[source]
595        source: Box<LockError>,
596    },
597
598    /// The runner root this registration would run jobs under could not be
599    /// created, inspected, or given the access the registration's account needs.
600    ///
601    /// Reported *before* anything is registered. A registration whose workspaces
602    /// would land in a directory ordinary local users can write is a
603    /// registration that should not exist, and `04-security-recovery.md` asks
604    /// for exactly that refusal rather than a warning.
605    #[error(
606        "this registration would run jobs under the default runner root, and that root could \
607         not be prepared, so nothing was registered: {source}"
608    )]
609    RunnerRoot {
610        /// What [`crate::runner_root_access`] reported, including the remedy.
611        #[source]
612        source: Box<RootAccessError>,
613    },
614
615    /// The path of the running binary could not be resolved.
616    #[error(
617        "cannot resolve the absolute path of this executable, so there is nothing to register: \
618         {detail}. Run the installer from the installed binary rather than through a shell \
619         function or a wrapper that replaces argv[0]."
620    )]
621    BinaryPath {
622        /// What the platform reported.
623        detail: String,
624    },
625
626    /// The binary named for the registration is not there.
627    #[error(
628        "{} is not a file, so registering it would create a service that cannot start. Install \
629         the product first, then run `service install` from the installed binary.",
630        path.display()
631    )]
632    BinaryMissing {
633        /// The path that was to be registered.
634        path: PathBuf,
635    },
636
637    /// The requested restart delay is outside the bound.
638    #[error(
639        "a restart delay of {requested_secs}s is outside the supported range \
640         {min_secs}s-{max_secs}s. Below the floor the platform's own throttle overrides the \
641         value, so `service status` would report a delay that is not the one in force."
642    )]
643    RestartDelay {
644        /// What was asked for.
645        requested_secs: u64,
646        /// The floor.
647        min_secs: u64,
648        /// The ceiling.
649        max_secs: u64,
650    },
651
652    /// The failure-count reset window is not longer than the restart delay.
653    #[error(
654        "a failure-count reset window of {reset_secs}s is not longer than the {delay_secs}s \
655         restart delay, so the count would reset between every pair of restarts and no \
656         start limit could ever apply."
657    )]
658    RestartResetWindow {
659        /// What was asked for.
660        reset_secs: u64,
661        /// The delay it has to exceed.
662        delay_secs: u64,
663    },
664
665    /// A registration is already there, in the **other** start mode.
666    ///
667    /// Only the other one. A registration in the mode being asked for is
668    /// replaced rather than refused — see [`Operations::install`], and the
669    /// outage that taught us why.
670    #[error(
671        "{name} is already registered to start at {existing}, and this asks for {requested}. \
672         Changing the start mode moves the registration between two different service \
673         managers, so it is not something an install does by itself: run `service uninstall` \
674         first, or change the start mode in the terminal UI (`runner-manager tui`), which \
675         switches it in place and keeps the registration running throughout."
676    )]
677    AlreadyInstalled {
678        /// Which registration.
679        name: String,
680        /// The mode it currently carries.
681        existing: StartMode,
682        /// The mode this install asked for.
683        requested: StartMode,
684    },
685
686    /// No registration is there.
687    #[error("{name} is not registered on this host, so there is nothing to {operation}.")]
688    NotInstalled {
689        /// Which registration.
690        name: String,
691        /// What was being attempted.
692        operation: &'static str,
693    },
694
695    /// The record `install` wrote could not be read or written.
696    #[error("cannot {operation} the service record {}: {detail}", path.display())]
697    Record {
698        /// Read, write, or remove.
699        operation: &'static str,
700        /// The record file.
701        path: PathBuf,
702        /// The underlying failure.
703        detail: String,
704    },
705
706    /// The record is there but is not one this version wrote.
707    #[error(
708        "the service record {} was not written by this product, or was written by a version \
709         this one cannot read: {detail}. Run `service uninstall` and `service install` again; \
710         neither touches configuration, secrets, or the cache.",
711        path.display()
712    )]
713    RecordUnreadable {
714        /// The record file.
715        path: PathBuf,
716        /// What was wrong with it.
717        detail: String,
718    },
719
720    /// The record is there and **this account** may not read it.
721    ///
722    /// Its own variant rather than a [`ServiceError::Record`] with a
723    /// `Permission denied` in the detail, because it is the one record failure
724    /// that is not a fault in the record: `service status` reports it and
725    /// carries on, where every other read failure ends the command. See
726    /// [`Operations::status`].
727    ///
728    /// A version before this one wrote the record at `0600` through a temporary
729    /// file, so a boot-mode `sudo service install` left it owned by `root` and
730    /// unreadable by the operator whose profile it is in. `service status` then
731    /// failed on their own host, with a remedy naming the command that had just
732    /// failed.
733    #[error(
734        "the service record {} is there and this account may not read it: {detail}. It was \
735         written by whichever account installed the service -- on a boot-mode host, `sudo \
736         service install`. Running `service install` again rewrites it readable; until then \
737         only what the service manager itself reports is available.",
738        path.display()
739    )]
740    RecordNotPermitted {
741        /// The record file.
742        path: PathBuf,
743        /// What the operating system said.
744        detail: String,
745    },
746
747    /// The application-data directories could not be resolved or created.
748    #[error("cannot prepare this host's application-data directories: {source}")]
749    Paths {
750        /// The underlying failure.
751        #[source]
752        source: Box<crate::paths::PathsError>,
753    },
754
755    /// The platform's service manager refused, or could not be reached.
756    #[error("cannot {operation} {name} through {manager}: {detail}")]
757    Control {
758        /// What was being attempted.
759        operation: &'static str,
760        /// Which registration.
761        name: String,
762        /// Which service manager.
763        manager: &'static str,
764        /// What it said.
765        detail: String,
766    },
767
768    /// An operation failed and its compensating action failed too.
769    #[error(
770        "cannot {operation} {name}: {cause}. The attempted rollback also failed: {rollback}. \
771         Inspect `service status` before retrying."
772    )]
773    Rollback {
774        /// The transaction that could not be completed.
775        operation: &'static str,
776        /// Which registration was involved.
777        name: String,
778        /// The original failure.
779        cause: String,
780        /// The failure while restoring the previous state.
781        rollback: String,
782    },
783
784    /// The machine's registration is another installation's (see
785    /// [`ForeignService`]), and changing it from this account would act on it
786    /// with this account's directories.
787    #[error(
788        "this machine's {name} service was installed from {installed_by}, not from this \
789         account's data root, so this account does not {operation} it: doing so would \
790         re-register or remove that installation's service with this account's directories. \
791         Do it {act_as}. If that account no longer exists, remove the registration with the \
792         service manager itself first."
793    )]
794    ForeignInstallation {
795        /// Which registration.
796        name: String,
797        /// What was refused.
798        operation: &'static str,
799        /// [`ForeignService::installed_by`].
800        installed_by: String,
801        /// [`ForeignService::act_as`].
802        act_as: String,
803    },
804
805    /// `host forget-local` found this account's configuration in use, and
806    /// moved nothing.
807    #[error("this account's local configuration is in use, so nothing was archived: {detail}.")]
808    LocalConfigurationInUse {
809        /// What uses it.
810        detail: String,
811    },
812
813    /// The operation needs administrative rights it does not have.
814    #[error("{operation} {name} needs administrative rights: {detail}. {remedy}")]
815    NeedsElevation {
816        /// What was being attempted.
817        operation: &'static str,
818        /// Which registration.
819        name: String,
820        /// What the platform said.
821        detail: String,
822        /// How to get them, in this platform's own terms.
823        remedy: &'static str,
824    },
825}
826
827// ---------------------------------------------------------------------------
828// Directories
829// ---------------------------------------------------------------------------
830
831/// The four directories the registration was installed against.
832///
833/// Recorded rather than re-derived, because `05-infrastructure.md` item 2 speaks
834/// of the *configured* cache and runtime directories: the account the service
835/// runs under and the account that ran `service install` do not always resolve
836/// the same platform-standard locations, and a claim about what the service can
837/// write is only checkable against a specific set of paths.
838#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
839pub struct ServiceDirectories {
840    /// Non-secret TOML and the SQLite database.
841    pub config: PathBuf,
842    /// The agent lock, the attempt journal, and the runner package cache.
843    pub state: PathBuf,
844    /// Per-attempt disposable runner workspaces.
845    pub runtime: PathBuf,
846    /// Rotating redacted diagnostics.
847    pub logs: PathBuf,
848}
849
850impl ServiceDirectories {
851    /// Snapshots the four directories a resolved [`AppPaths`] names.
852    #[must_use]
853    pub fn of(paths: &AppPaths) -> Self {
854        Self {
855            config: paths.config_dir().to_path_buf(),
856            state: paths.state_dir().to_path_buf(),
857            runtime: paths.runtime_dir().to_path_buf(),
858            logs: paths.logs_dir().to_path_buf(),
859        }
860    }
861
862    /// The four, in the order `05-infrastructure.md` lists them.
863    #[must_use]
864    pub fn all(&self) -> [&Path; 4] {
865        [
866            self.config.as_path(),
867            self.state.as_path(),
868            self.runtime.as_path(),
869            self.logs.as_path(),
870        ]
871    }
872
873    /// The rotating diagnostic log's stem, which is what `service status`
874    /// reports and what `05-infrastructure.md` item 4 requires be preserved.
875    #[must_use]
876    pub fn log_file(&self) -> PathBuf {
877        self.logs.join(LOG_FILE_STEM)
878    }
879
880    /// The four as the hidden `daemon run` options a registration passes them
881    /// in, [`SERVICE_DIRECTORY_OPTIONS`] order.
882    #[must_use]
883    pub fn arguments(&self) -> [OsString; 8] {
884        let [config, state, runtime, logs] = SERVICE_DIRECTORY_OPTIONS.map(OsString::from);
885        [
886            config,
887            self.config.clone().into_os_string(),
888            state,
889            self.state.clone().into_os_string(),
890            runtime,
891            self.runtime.clone().into_os_string(),
892            logs,
893            self.logs.clone().into_os_string(),
894        ]
895    }
896
897    /// Reads the four back out of a registration's command line.
898    ///
899    /// `None` unless all four options are there: a registration made before
900    /// they existed starts the daemon against whatever its account's
901    /// platform-standard directories are, and which those are cannot be told
902    /// from here.
903    #[must_use]
904    pub fn from_command_line(command_line: &str) -> Option<Self> {
905        let arguments = split_command_line(command_line);
906        let value = |option: &str| {
907            let at = arguments.iter().position(|argument| argument == option)?;
908            arguments.get(at + 1).map(PathBuf::from)
909        };
910        let [config, state, runtime, logs] = SERVICE_DIRECTORY_OPTIONS;
911        Some(Self {
912            config: value(config)?,
913            state: value(state)?,
914            runtime: value(runtime)?,
915            logs: value(logs)?,
916        })
917    }
918
919    /// The deepest directory all four sit under: what an operator would call
920    /// the installation's data root. The configuration directory when they
921    /// share nothing, and [`crate::paths::SYSTEM_DATA_ROOT`] for the system
922    /// tree, whose logs are under `/var/log` rather than beside the rest.
923    #[must_use]
924    pub fn root(&self) -> PathBuf {
925        if *self == Self::of(&AppPaths::system()) {
926            return PathBuf::from(crate::paths::SYSTEM_DATA_ROOT);
927        }
928        let mut shared: Vec<std::path::Component<'_>> = self.config.components().collect();
929        for other in [&self.state, &self.runtime, &self.logs] {
930            let common = shared
931                .iter()
932                .zip(other.components())
933                .take_while(|(left, right)| **left == *right)
934                .count();
935            shared.truncate(common);
936        }
937        if shared.is_empty() {
938            self.config.clone()
939        } else {
940            shared.iter().collect()
941        }
942    }
943
944    /// The four as an [`AppPaths`], to read the records a daemon running
945    /// against them writes.
946    #[must_use]
947    pub fn paths(&self) -> AppPaths {
948        AppPaths::from_directories(&self.config, &self.state, &self.runtime, &self.logs)
949    }
950}
951
952/// The hidden `daemon run` options that carry the installing account's four
953/// application-data directories across to the service's account, in
954/// [`ServiceDirectories`] order. Written by `service install`, and read back
955/// by [`ServiceDirectories::from_command_line`] to tell whose data root a
956/// registration runs against.
957pub const SERVICE_DIRECTORY_OPTIONS: [&str; 4] = [
958    "--service-config-dir",
959    "--service-state-dir",
960    "--service-runtime-dir",
961    "--service-logs-dir",
962];
963
964// ---------------------------------------------------------------------------
965// The install plan
966// ---------------------------------------------------------------------------
967
968/// What `service install` was asked for, before anything has been resolved.
969///
970/// Separate from [`InstallPlan`] because a request is the operator's words and
971/// a plan is what the host actually supports: the plan carries a *resolved
972/// absolute* binary path, the account the start mode obliges, and the four
973/// directories the registration is installed against, none of which the caller
974/// supplies.
975#[derive(Debug, Clone, PartialEq, Eq)]
976pub struct InstallRequest {
977    start_mode: StartMode,
978    binary: Option<PathBuf>,
979    source_binary: Option<PathBuf>,
980    arguments: Vec<OsString>,
981    restart: RestartPolicy,
982    on_demand: bool,
983}
984
985impl InstallRequest {
986    /// Register the binary that is running this call.
987    #[must_use]
988    pub fn new(start_mode: StartMode) -> Self {
989        Self {
990            start_mode,
991            binary: None,
992            source_binary: None,
993            arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
994            restart: RestartPolicy::default(),
995            on_demand: false,
996        }
997    }
998
999    /// Registers the service but does not ask the manager to start it by
1000    /// itself.
1001    ///
1002    /// Production never uses this: a boot-mode registration that does not start
1003    /// at boot is the failure `service status` reports. **The privileged
1004    /// installer tests use it for every fixture they create**, so that a
1005    /// registration which somehow escaped its cleanup guard cannot start with
1006    /// the owner's machine on the next reboot. A leaked service is bad; a
1007    /// leaked service that runs is worse, and the difference costs one flag.
1008    #[must_use]
1009    pub const fn started_on_demand(mut self) -> Self {
1010        self.on_demand = true;
1011        self
1012    }
1013
1014    /// Register a named binary instead of the running one.
1015    ///
1016    /// The privileged installer tests use this to register a fixture service
1017    /// host. Production does not: `05-infrastructure.md` item 6 is specifically
1018    /// about the path of *the running binary*, and letting an operator name a
1019    /// different one would defeat the stale-path detection it asks for.
1020    #[must_use]
1021    pub fn for_binary(mut self, binary: impl Into<PathBuf>) -> Self {
1022        self.binary = Some(binary.into());
1023        self
1024    }
1025
1026    /// Where the registered binary was copied from.
1027    ///
1028    /// # Why a registration has a source at all
1029    ///
1030    /// A service registered directly against a package manager's own file can
1031    /// never be upgraded on Windows, because the running service holds that
1032    /// file open and `npm i -g` cannot replace it. What it does instead is
1033    /// worse than failing: it rewrites the package metadata, reports success,
1034    /// and leaves the old executable in place — so the operator is told the new
1035    /// version is installed while the old one keeps running. That was observed,
1036    /// twice in a row, before this existed.
1037    ///
1038    /// So the service runs a copy the product owns, and this records where that
1039    /// copy came from. The source is what a package manager updates, what
1040    /// [`inspect_binary`] watches for item 6's stale-path case, and what the
1041    /// daemon compares its own version against to know an upgrade is waiting.
1042    #[must_use]
1043    pub fn copied_from(mut self, source: impl Into<PathBuf>) -> Self {
1044        self.source_binary = Some(source.into());
1045        self
1046    }
1047
1048    /// Replace the registered arguments. Defaults to [`DAEMON_ARGUMENTS`].
1049    #[must_use]
1050    pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1051    where
1052        I: IntoIterator<Item = S>,
1053        S: Into<OsString>,
1054    {
1055        self.arguments = arguments.into_iter().map(Into::into).collect();
1056        self
1057    }
1058
1059    /// Replace the restart-on-failure policy. Defaults to
1060    /// [`RestartPolicy::default`].
1061    #[must_use]
1062    pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1063        self.restart = restart;
1064        self
1065    }
1066
1067    /// The start mode asked for.
1068    #[must_use]
1069    pub const fn start_mode(&self) -> StartMode {
1070        self.start_mode
1071    }
1072}
1073
1074/// Everything a backend needs, resolved and validated.
1075///
1076/// Constructing one is where `05-infrastructure.md` item 6 is actually
1077/// satisfied: the binary path is made absolute and is confirmed to be a file
1078/// *before* anything is registered, so a registration that would fail at boot
1079/// is refused at install time instead.
1080#[derive(Debug, Clone, PartialEq, Eq)]
1081pub struct InstallPlan {
1082    identity: ServiceIdentity,
1083    start_mode: StartMode,
1084    binary: PathBuf,
1085    source_binary: Option<PathBuf>,
1086    arguments: Vec<OsString>,
1087    account: ServiceAccount,
1088    restart: RestartPolicy,
1089    directories: ServiceDirectories,
1090    secret_guard: Option<PathBuf>,
1091    on_demand: bool,
1092}
1093
1094impl InstallPlan {
1095    /// Resolves a request against this host.
1096    ///
1097    /// # Errors
1098    ///
1099    /// [`ServiceError::BinaryPath`] when the running executable cannot be
1100    /// located, and [`ServiceError::BinaryMissing`] when the path that would be
1101    /// registered is not a file.
1102    pub fn resolve(
1103        identity: ServiceIdentity,
1104        request: &InstallRequest,
1105        directories: ServiceDirectories,
1106    ) -> Result<Self, ServiceError> {
1107        let binary = match &request.binary {
1108            Some(named) => absolute(named)?,
1109            None => running_executable()?,
1110        };
1111        if !binary.is_file() {
1112            return Err(ServiceError::BinaryMissing { path: binary });
1113        }
1114        // `d2` publishes the guard file the store lives in so the systemd
1115        // sandbox can admit only its parent for atomic token rotation. A store
1116        // that cannot be resolved is not fatal here; it merely cannot receive
1117        // that narrow writable-path exception.
1118        let secret_guard = crate::secrets::PlatformSecretStore::for_start_mode(request.start_mode)
1119            .ok()
1120            .map(|store| store.guard());
1121        Ok(Self {
1122            identity,
1123            start_mode: request.start_mode,
1124            binary,
1125            source_binary: request.source_binary.clone(),
1126            arguments: request.arguments.clone(),
1127            account: ServiceAccount::for_start_mode(request.start_mode),
1128            restart: request.restart,
1129            directories,
1130            secret_guard,
1131            on_demand: request.on_demand,
1132        })
1133    }
1134
1135    /// Builds a plan from values a caller already holds, without touching the
1136    /// filesystem.
1137    ///
1138    /// This is what makes the renderers testable on a host that has none of the
1139    /// paths involved: a Windows developer can render the systemd unit for
1140    /// `/opt/runner-manager/bin/runner-manager` and assert every line of it.
1141    #[must_use]
1142    pub fn unchecked(
1143        identity: ServiceIdentity,
1144        start_mode: StartMode,
1145        binary: impl Into<PathBuf>,
1146        directories: ServiceDirectories,
1147    ) -> Self {
1148        Self {
1149            identity,
1150            start_mode,
1151            binary: binary.into(),
1152            source_binary: None,
1153            arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
1154            account: ServiceAccount::for_start_mode(start_mode),
1155            restart: RestartPolicy::default(),
1156            directories,
1157            secret_guard: None,
1158            on_demand: false,
1159        }
1160    }
1161
1162    /// Registers without arming the manager's automatic start. See
1163    /// [`InstallRequest::started_on_demand`].
1164    #[must_use]
1165    pub const fn started_on_demand(mut self) -> Self {
1166        self.on_demand = true;
1167        self
1168    }
1169
1170    /// Whether the manager was asked not to start this by itself.
1171    #[must_use]
1172    pub const fn is_on_demand(&self) -> bool {
1173        self.on_demand
1174    }
1175
1176    /// Names the file the secret store lives in, so systemd can permit atomic
1177    /// replacement of it under `ProtectSystem=strict`.
1178    ///
1179    /// [`InstallPlan::resolve`] fills this from `d2`; a test names it directly
1180    /// so that a Windows or macOS CI leg can assert the Linux unit's writable
1181    /// path for a store that platform does not have.
1182    #[must_use]
1183    pub fn with_secret_guard(mut self, guard: impl Into<PathBuf>) -> Self {
1184        self.secret_guard = Some(guard.into());
1185        self
1186    }
1187
1188    /// The file the machine-scoped secret store lives in, when one resolved.
1189    #[must_use]
1190    pub fn secret_guard(&self) -> Option<&Path> {
1191        self.secret_guard.as_deref()
1192    }
1193
1194    /// Overrides the restart policy on an already-built plan.
1195    #[must_use]
1196    pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1197        self.restart = restart;
1198        self
1199    }
1200
1201    /// Overrides the registered arguments on an already-built plan.
1202    #[must_use]
1203    pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1204    where
1205        I: IntoIterator<Item = S>,
1206        S: Into<OsString>,
1207    {
1208        self.arguments = arguments.into_iter().map(Into::into).collect();
1209        self
1210    }
1211
1212    /// What the operating system calls this registration.
1213    #[must_use]
1214    pub const fn identity(&self) -> &ServiceIdentity {
1215        &self.identity
1216    }
1217
1218    /// Boot or login.
1219    #[must_use]
1220    pub const fn start_mode(&self) -> StartMode {
1221        self.start_mode
1222    }
1223
1224    /// The resolved absolute path of the binary to register.
1225    #[must_use]
1226    pub fn binary(&self) -> &Path {
1227        &self.binary
1228    }
1229
1230    /// Where [`Self::binary`] was copied from, when it is a copy.
1231    ///
1232    /// `None` is the legacy layout: the registration names the file a package
1233    /// manager owns, and cannot be upgraded while it runs. See
1234    /// [`InstallRequest::copied_from`].
1235    #[must_use]
1236    pub fn source_binary(&self) -> Option<&Path> {
1237        self.source_binary.as_deref()
1238    }
1239
1240    /// The arguments it is registered with.
1241    #[must_use]
1242    pub fn arguments(&self) -> &[OsString] {
1243        &self.arguments
1244    }
1245
1246    /// The account it runs under.
1247    #[must_use]
1248    pub const fn account(&self) -> &ServiceAccount {
1249        &self.account
1250    }
1251
1252    /// The restart-on-failure policy.
1253    #[must_use]
1254    pub const fn restart(&self) -> RestartPolicy {
1255        self.restart
1256    }
1257
1258    /// The four directories it was installed against.
1259    #[must_use]
1260    pub const fn directories(&self) -> &ServiceDirectories {
1261        &self.directories
1262    }
1263
1264    /// The command line as one string, quoted the way each platform expects.
1265    #[must_use]
1266    pub fn command_line(&self) -> String {
1267        let mut out = quote_argument(&self.binary.to_string_lossy());
1268        for argument in &self.arguments {
1269            out.push(' ');
1270            out.push_str(&quote_argument(&argument.to_string_lossy()));
1271        }
1272        out
1273    }
1274}
1275
1276/// Undoes a runner-root change, and says what could not be undone.
1277///
1278/// `None` when the rollback was complete — either because there was nothing to
1279/// undo, or because the directory this operation created was removed again and
1280/// the descriptor it replaced was written back. `Some` is the "report any
1281/// non-reversible existing directory state explicitly" half of the requirement,
1282/// and the caller folds it into the failure it was already reporting.
1283fn retained_runner_root(change: &RootAccessChange) -> Option<String> {
1284    let reversal = change.revert();
1285    matches!(reversal, Reversal::Retained { .. }).then(|| reversal.to_string())
1286}
1287
1288/// The error a failed step returns once both of its rollbacks have been
1289/// attempted.
1290///
1291/// `retained` is [`retained_runner_root`]'s answer and `rollback` is what
1292/// undoing the *registration* reported — whatever it reports on success, which
1293/// a failure has no use for. Both arrive already evaluated, because each may be
1294/// attempted exactly once and the order is the caller's to choose.
1295///
1296/// A complete rollback leaves `cause` exactly as it was: the operator's problem
1297/// is what failed, not the tidying up afterwards. An incomplete one is promoted
1298/// to [`ServiceError::Rollback`], which is the variant that exists to say "this
1299/// failed *and* something is left behind" — and it names everything that did.
1300fn rolled_back<T>(
1301    retained: Option<String>,
1302    rollback: Result<T, ServiceError>,
1303    operation: &'static str,
1304    identity: &ServiceIdentity,
1305    cause: ServiceError,
1306) -> ServiceError {
1307    let left_behind = match (rollback.err(), retained) {
1308        (Some(rollback), Some(retained)) => Some(format!("{rollback}; {retained}")),
1309        (Some(rollback), None) => Some(rollback.to_string()),
1310        (None, retained) => retained,
1311    };
1312    match left_behind {
1313        Some(rollback) => ServiceError::Rollback {
1314            operation,
1315            name: identity.name().to_string(),
1316            cause: cause.to_string(),
1317            rollback,
1318        },
1319        None => cause,
1320    }
1321}
1322
1323/// [`rolled_back`] for a failure with nothing registered yet, where the runner
1324/// root is the only thing there is to undo.
1325fn undo_runner_root(
1326    change: &RootAccessChange,
1327    operation: &'static str,
1328    identity: &ServiceIdentity,
1329    cause: ServiceError,
1330) -> ServiceError {
1331    rolled_back(
1332        retained_runner_root(change),
1333        Ok(()),
1334        operation,
1335        identity,
1336        cause,
1337    )
1338}
1339
1340/// Makes a path absolute without resolving symlinks.
1341///
1342/// Symbolic links are deliberately left alone. A Homebrew installation puts a
1343/// stable link at `/opt/homebrew/bin/runner-manager` and moves the file it
1344/// points at on every upgrade; recording the link's target would make an
1345/// ordinary `brew upgrade` look exactly like the npm failure this module exists
1346/// to detect, and recording the link records the thing the operator installed.
1347///
1348/// Linux is the one platform where this is not fully in the caller's hands:
1349/// `std::env::current_exe` reads `/proc/self/exe`, which the kernel has already
1350/// resolved. There the recorded path is the real file rather than the shim —
1351/// which is still correct for the npm case, because a real file under a Node
1352/// prefix disappears with the prefix.
1353fn absolute(path: &Path) -> Result<PathBuf, ServiceError> {
1354    std::path::absolute(path).map_err(|error| ServiceError::BinaryPath {
1355        detail: format!("{} could not be made absolute: {error}", path.display()),
1356    })
1357}
1358
1359/// The absolute path of the executable running this call.
1360fn running_executable() -> Result<PathBuf, ServiceError> {
1361    let raw = std::env::current_exe().map_err(|error| ServiceError::BinaryPath {
1362        detail: error.to_string(),
1363    })?;
1364    absolute(&raw)
1365}
1366
1367/// Quotes one command-line argument when it needs it.
1368///
1369/// The rule is Windows', because Windows is the platform where a command line is
1370/// a single string the callee re-splits, and because `windows-service` applies
1371/// exactly this rule when it builds `lpBinaryPathName`. The Unix backends embed
1372/// arguments in a plist array and in a systemd `ExecStart=`, both of which
1373/// accept the same quoting, so one rule serves all three rather than three
1374/// nearly-identical ones.
1375pub(crate) fn quote_argument(argument: &str) -> String {
1376    if !argument.is_empty() && !argument.contains([' ', '"', '\t', '\n']) {
1377        return argument.to_string();
1378    }
1379    let mut out = String::with_capacity(argument.len() + 2);
1380    out.push('"');
1381    let mut backslashes = 0usize;
1382    for c in argument.chars() {
1383        match c {
1384            '\\' => {
1385                backslashes += 1;
1386                out.push('\\');
1387            }
1388            '"' => {
1389                // Every backslash immediately before a quote must be doubled,
1390                // and the quote itself escaped.
1391                for _ in 0..=backslashes {
1392                    out.push('\\');
1393                }
1394                out.push('"');
1395                backslashes = 0;
1396            }
1397            other => {
1398                backslashes = 0;
1399                out.push(other);
1400            }
1401        }
1402    }
1403    // Trailing backslashes would otherwise escape the closing quote.
1404    for _ in 0..backslashes {
1405        out.push('\\');
1406    }
1407    out.push('"');
1408    out
1409}
1410
1411/// Reads the executable back out of a command line quoted by
1412/// [`quote_argument`].
1413///
1414/// `service status` needs this because the Windows Service Control Manager
1415/// stores the binary and its arguments as one string and hands the whole thing
1416/// back from `QueryServiceConfigW`. Comparing a record against a registration
1417/// means splitting that string the same way Windows itself does.
1418///
1419/// Returns `None` for an empty or whitespace-only command line, which is the
1420/// only input with no first argument to find.
1421#[must_use]
1422pub fn executable_from_command_line(command_line: &str) -> Option<PathBuf> {
1423    split_command_line(command_line)
1424        .into_iter()
1425        .next()
1426        .filter(|executable| !executable.is_empty())
1427        .map(PathBuf::from)
1428}
1429
1430/// Splits a command line quoted by [`quote_argument`] back into its
1431/// arguments, by the rule Windows itself applies: whitespace separates
1432/// arguments outside quotes, `2n` backslashes before a quote are `n`
1433/// backslashes and a quote that opens or closes, `2n+1` are `n` backslashes
1434/// and a literal quote, and every other backslash is literal.
1435#[must_use]
1436pub fn split_command_line(command_line: &str) -> Vec<String> {
1437    let mut arguments = Vec::new();
1438    let mut current = String::new();
1439    let mut started = false;
1440    let mut quoted = false;
1441    let mut backslashes = 0usize;
1442    for c in command_line.chars() {
1443        match c {
1444            '\\' => {
1445                backslashes += 1;
1446                started = true;
1447            }
1448            '"' => {
1449                current.extend(std::iter::repeat_n('\\', backslashes / 2));
1450                if backslashes.is_multiple_of(2) {
1451                    quoted = !quoted;
1452                } else {
1453                    current.push('"');
1454                }
1455                backslashes = 0;
1456                started = true;
1457            }
1458            ' ' | '\t' if !quoted => {
1459                current.extend(std::iter::repeat_n('\\', backslashes));
1460                backslashes = 0;
1461                if started {
1462                    arguments.push(std::mem::take(&mut current));
1463                    started = false;
1464                }
1465            }
1466            other => {
1467                current.extend(std::iter::repeat_n('\\', backslashes));
1468                backslashes = 0;
1469                current.push(other);
1470                started = true;
1471            }
1472        }
1473    }
1474    current.extend(std::iter::repeat_n('\\', backslashes));
1475    if started {
1476        arguments.push(current);
1477    }
1478    arguments
1479}
1480
1481// ---------------------------------------------------------------------------
1482// Rendered definitions
1483// ---------------------------------------------------------------------------
1484
1485/// Which of the four things a platform reads.
1486#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1487pub enum DefinitionKind {
1488    /// The parameters `CreateServiceW` is called with, rendered as a canonical
1489    /// descriptor so that they can be reviewed and asserted like the other
1490    /// three. The Service Control Manager has no file.
1491    WindowsService,
1492    /// A Task Scheduler XML document, for `--start-at login` on Windows.
1493    WindowsScheduledTask,
1494    /// A launchd property list — a LaunchDaemon at boot, a LaunchAgent at login.
1495    LaunchdPlist,
1496    /// A systemd unit — a system unit at boot, a user unit at login.
1497    SystemdUnit,
1498}
1499
1500impl DefinitionKind {
1501    /// What to call the thing that reads it, in an operator-facing message.
1502    #[must_use]
1503    pub const fn manager(self) -> &'static str {
1504        match self {
1505            Self::WindowsService => "the Windows Service Control Manager",
1506            Self::WindowsScheduledTask => "Windows Task Scheduler",
1507            Self::LaunchdPlist => "launchd",
1508            Self::SystemdUnit => "systemd",
1509        }
1510    }
1511}
1512
1513impl fmt::Display for DefinitionKind {
1514    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1515        f.write_str(self.manager())
1516    }
1517}
1518
1519/// One platform's definition of the registration, as text.
1520///
1521/// Producing this is pure: no `cfg`, no privileges, no filesystem. That is what
1522/// lets every leg of the CI matrix assert every platform's definition, instead
1523/// of each leg asserting only its own and the other two being reviewed by
1524/// reading.
1525#[derive(Debug, Clone, PartialEq, Eq)]
1526pub struct ServiceDefinition {
1527    kind: DefinitionKind,
1528    text: String,
1529    install_path: Option<PathBuf>,
1530}
1531
1532impl ServiceDefinition {
1533    /// Which platform this is for.
1534    #[must_use]
1535    pub const fn kind(&self) -> DefinitionKind {
1536        self.kind
1537    }
1538
1539    /// The definition itself.
1540    #[must_use]
1541    pub fn text(&self) -> &str {
1542        &self.text
1543    }
1544
1545    /// Where the platform expects the file, when the platform reads a file.
1546    ///
1547    /// `None` for [`DefinitionKind::WindowsService`], whose "definition" is a
1548    /// set of arguments to `CreateServiceW`, and for a Task Scheduler document,
1549    /// which is handed to `schtasks` from a temporary file and is not stored
1550    /// where it was written.
1551    #[must_use]
1552    pub fn install_path(&self) -> Option<&Path> {
1553        self.install_path.as_deref()
1554    }
1555
1556    /// The definition this host's own service manager would be given.
1557    ///
1558    /// The value-level twin of [`host_definition_kind`]. Rendering needs no
1559    /// privileges and no service manager, so anything that wants to *see* what
1560    /// an install would register — `f3`'s `service install --dry-run`, a
1561    /// support bundle, [`RecordingControls`] — can have it without registering
1562    /// anything.
1563    ///
1564    /// # Errors
1565    ///
1566    /// [`ServiceError::Control`] when a Windows logon-triggered registration is
1567    /// asked for and the session reports no account to register it for.
1568    pub fn for_host(plan: &InstallPlan) -> Result<Self, ServiceError> {
1569        Ok(match host_definition_kind(plan.start_mode()) {
1570            DefinitionKind::WindowsService => Self::windows_service(plan),
1571            DefinitionKind::WindowsScheduledTask => {
1572                Self::windows_scheduled_task(plan, &TaskPrincipal::current()?)
1573            }
1574            DefinitionKind::LaunchdPlist => Self::launchd(plan, host_home().as_deref()),
1575            DefinitionKind::SystemdUnit => Self::systemd(plan, host_home().as_deref()),
1576        })
1577    }
1578
1579    /// The Windows service parameters, as a canonical descriptor.
1580    #[must_use]
1581    pub fn windows_service(plan: &InstallPlan) -> Self {
1582        Self {
1583            kind: DefinitionKind::WindowsService,
1584            text: windows_service_descriptor(plan),
1585            install_path: None,
1586        }
1587    }
1588
1589    /// A Task Scheduler document for `--start-at login`.
1590    #[must_use]
1591    pub fn windows_scheduled_task(plan: &InstallPlan, principal: &TaskPrincipal) -> Self {
1592        Self {
1593            kind: DefinitionKind::WindowsScheduledTask,
1594            text: windows_scheduled_task_xml(plan, principal),
1595            install_path: None,
1596        }
1597    }
1598
1599    /// A launchd property list.
1600    ///
1601    /// `home` is only consulted for `--start-at login`, where the file belongs
1602    /// in the operator's own `~/Library/LaunchAgents`.
1603    #[must_use]
1604    pub fn launchd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1605        let file = format!("{}.plist", plan.identity().launchd_label());
1606        let install_path = match plan.start_mode() {
1607            StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
1608            StartMode::Login => home.map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file)),
1609        };
1610        Self {
1611            kind: DefinitionKind::LaunchdPlist,
1612            text: launchd_plist(plan),
1613            install_path,
1614        }
1615    }
1616
1617    /// Wraps text this module did not render.
1618    ///
1619    /// Two callers need it and both matter. A test builds a deliberately
1620    /// widened definition and watches [`review_least_privilege`] reject it —
1621    /// without which the review would be a function nobody had ever seen fail.
1622    /// And `service status` can review the definition **actually on disk**,
1623    /// which on Linux and macOS an operator is free to edit after installation.
1624    #[must_use]
1625    pub fn from_text(kind: DefinitionKind, text: impl Into<String>) -> Self {
1626        Self {
1627            kind,
1628            text: text.into(),
1629            install_path: None,
1630        }
1631    }
1632
1633    /// A systemd unit.
1634    ///
1635    /// `home` is only consulted for `--start-at login`, where the unit belongs
1636    /// in the operator's own `~/.config/systemd/user`.
1637    #[must_use]
1638    pub fn systemd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1639        let file = plan.identity().systemd_unit();
1640        let install_path = match plan.start_mode() {
1641            StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
1642            StartMode::Login => home.map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file)),
1643        };
1644        Self {
1645            kind: DefinitionKind::SystemdUnit,
1646            text: systemd_unit(plan),
1647            install_path,
1648        }
1649    }
1650}
1651
1652/// Where a LaunchDaemon lives.
1653pub const LAUNCH_DAEMONS_DIR: &str = "/Library/LaunchDaemons";
1654/// Where a LaunchAgent lives, under the operator's home directory.
1655pub const LAUNCH_AGENTS_SUBDIR: &str = "Library/LaunchAgents";
1656/// Where a systemd system unit lives.
1657pub const SYSTEMD_SYSTEM_DIR: &str = "/etc/systemd/system";
1658/// Where a systemd user unit lives, under the operator's home directory.
1659pub const SYSTEMD_USER_SUBDIR: &str = ".config/systemd/user";
1660
1661/// The product's own page, cited by every definition so an operator who finds
1662/// one on a host can find out what put it there.
1663const DOCUMENTATION: &str = "https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI";
1664
1665/// How many consecutive failures the platform tolerates before it stops
1666/// retrying.
1667///
1668/// The companion to [`RestartPolicy::reset_after`]: the delay bounds how *fast*
1669/// a restart may come, and this bounds how *many* come before the host gives
1670/// up and leaves the failure visible instead of hiding it behind an endless
1671/// retry.
1672pub const START_LIMIT_BURST: u32 = 5;
1673
1674// -- systemd -----------------------------------------------------------------
1675
1676/// Renders the systemd unit for this plan.
1677///
1678/// # Why the sandbox is this tight, and what it costs
1679///
1680/// `05-infrastructure.md` item 2 ends *"and write its configured cache and
1681/// runtime directories — and no more"*. `ProtectSystem=strict` plus an explicit
1682/// `ReadWritePaths` is what "and no more" means on Linux: everything outside the
1683/// recorded application-data directories and the credential store directory
1684/// is read-only to this unit. The latter must be writable because refresh-token
1685/// rotation atomically replaces the credential file.
1686///
1687/// **The runner inherits it.** The agent spawns the GitHub Actions runner as a
1688/// child, so a workflow running on this host also runs inside this sandbox: it
1689/// cannot write outside those directories and its private `/tmp`, and
1690/// `NoNewPrivileges=yes` means it cannot `sudo`. `07-security.md` assumes a
1691/// hostile workflow may run here, so that is the intended direction — but it is
1692/// a real behavioural limit and `docs/service-account.md` states it where an
1693/// operator will find it.
1694#[must_use]
1695pub fn systemd_unit(plan: &InstallPlan) -> String {
1696    let identity = plan.identity();
1697    let restart = plan.restart();
1698    let directories = plan.directories();
1699
1700    let mut out = String::new();
1701    out.push_str("[Unit]\n");
1702    out.push_str(&format!("Description={}\n", identity.display_name()));
1703    out.push_str(&format!("Documentation={DOCUMENTATION}\n"));
1704    out.push_str("After=network-online.target\n");
1705    out.push_str("Wants=network-online.target\n");
1706    // In `[Unit]` rather than `[Service]`: systemd moved these in v229 and only
1707    // accepts them here without a deprecation warning.
1708    out.push_str(&format!(
1709        "StartLimitIntervalSec={}\n",
1710        restart.reset_after().as_secs()
1711    ));
1712    out.push_str(&format!("StartLimitBurst={START_LIMIT_BURST}\n"));
1713
1714    out.push_str("\n[Service]\n");
1715    out.push_str("Type=simple\n");
1716    // A runner is a child of the daemon. In particular, systemd must not
1717    // signal it merely because a cooperative daemon handover replaces the
1718    // manager process; startup recovery supervises any survivor instead.
1719    out.push_str("KillMode=process\n");
1720    out.push_str(&format!("ExecStart={}\n", plan.command_line()));
1721    out.push_str(&format!(
1722        "WorkingDirectory={}\n",
1723        directories.state.display()
1724    ));
1725    out.push_str(&format!("SyslogIdentifier={identity}\n"));
1726    out.push_str("Restart=on-failure\n");
1727    out.push_str(&format!("RestartSec={}\n", restart.delay().as_secs()));
1728
1729    out.push_str("\n# Least privilege. See docs/service-account.md.\n");
1730    for directive in systemd_hardening(plan.start_mode()) {
1731        out.push_str(directive);
1732        out.push('\n');
1733    }
1734    let quoted = |paths: &[String]| {
1735        paths
1736            .iter()
1737            .map(|path| quote_argument(path))
1738            .collect::<Vec<_>>()
1739            .join(" ")
1740    };
1741    out.push_str(&format!(
1742        "ReadWritePaths={}\n",
1743        quoted(&permitted_paths(DefinitionKind::SystemdUnit, plan))
1744    ));
1745
1746    out.push_str("\n[Install]\n");
1747    out.push_str(match plan.start_mode() {
1748        StartMode::Boot => "WantedBy=multi-user.target\n",
1749        StartMode::Login => "WantedBy=default.target\n",
1750    });
1751    out
1752}
1753
1754/// The hardening directives every unit carries, in the order they are rendered.
1755///
1756/// One list, used by [`systemd_hardening`], so that [`review_least_privilege`]
1757/// is written against the same directives the renderer emits: a directive
1758/// dropped from the unit is one the review then reports as missing, rather
1759/// than one that silently stops being checked.
1760///
1761/// The capability bounding set is empty in both start modes. A boot unit runs
1762/// as `root` against `root`'s own system tree (see [`crate::paths`]), so it
1763/// needs no capability to reach its directories, and the runner and every
1764/// workflow job inherit none.
1765///
1766/// # Why not `RestrictSUIDSGID=yes`
1767///
1768/// Its seccomp filter cannot inspect `openat2`'s flags, so it answers every
1769/// `openat2` with `ENOSYS`, expecting a fallback to `openat`. GNU tar 1.35
1770/// extracts through `openat2(..., RESOLVE_BENEATH)` and has none, so every
1771/// extraction in a job failed with "Cannot open: Function not implemented" --
1772/// and the runner unpacks every action it downloads with `tar`. Measured on
1773/// Ubuntu 26.04 with systemd 259. What the directive guarded against is
1774/// covered without it: `NoNewPrivileges=yes` makes a set-user-ID bit inert for
1775/// everything this unit starts, and the only places it can write are `root`'s
1776/// own `0700` directories and its private `/tmp`, which no other account can
1777/// reach to execute a file left there.
1778const SYSTEMD_HARDENING: [&str; 12] = [
1779    "NoNewPrivileges=yes",
1780    "CapabilityBoundingSet=",
1781    "AmbientCapabilities=",
1782    "PrivateTmp=yes",
1783    "PrivateDevices=yes",
1784    "ProtectSystem=strict",
1785    "ProtectKernelTunables=yes",
1786    "ProtectKernelModules=yes",
1787    "ProtectControlGroups=yes",
1788    "RestrictNamespaces=yes",
1789    "RestrictRealtime=yes",
1790    "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1791];
1792
1793/// The hardening directives a unit started in `mode` carries.
1794///
1795/// A boot unit also hides every home directory behind an empty `tmpfs`: its
1796/// data lives outside them, and a workflow running as `root` has no business
1797/// reading the files there that DAC leaves world-readable. `tmpfs` rather than
1798/// `yes`, because tools such as `git` treat an *unreadable* `$HOME/.gitconfig`
1799/// as fatal and an absent one as nothing.
1800fn systemd_hardening(mode: StartMode) -> Vec<&'static str> {
1801    let mut directives = SYSTEMD_HARDENING.to_vec();
1802    if mode == StartMode::Boot {
1803        directives.push("ProtectHome=tmpfs");
1804    }
1805    directives
1806}
1807
1808/// The trees `ProtectHome=` hides, which a boot unit's directories must
1809/// therefore lie outside.
1810#[cfg(any(test, all(unix, not(target_os = "macos"))))]
1811const SYSTEMD_HOME_ROOTS: [&str; 3] = ["/home", "/root", "/run/user"];
1812
1813/// Renames a non-empty directory at `path` to `<name>.before-move-<stamp>`
1814/// beside it, leaving nothing at `path`.
1815#[cfg(any(all(test, unix), target_os = "linux"))]
1816fn set_aside(path: &Path, stamp: &str) -> std::io::Result<()> {
1817    let occupied = match std::fs::read_dir(path) {
1818        Ok(mut entries) => entries.next().is_some(),
1819        Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
1820        Err(error) => return Err(error),
1821    };
1822    if occupied {
1823        let name = path.file_name().map_or_else(
1824            || std::borrow::Cow::Borrowed("directory"),
1825            |name| name.to_string_lossy(),
1826        );
1827        std::fs::rename(
1828            path,
1829            path.with_file_name(format!("{name}.before-move-{stamp}")),
1830        )?;
1831    }
1832    Ok(())
1833}
1834
1835/// Copies the tree at `from` into `to`, except a top-level entry named `skip`.
1836/// Directories are created `0700`, files keep their mode, and symbolic links
1837/// are copied as links rather than followed.
1838#[cfg(any(all(test, unix), target_os = "linux"))]
1839fn copy_tree(from: &Path, to: &Path, skip: Option<&str>) -> std::io::Result<()> {
1840    use std::os::unix::fs::DirBuilderExt as _;
1841
1842    std::fs::DirBuilder::new()
1843        .recursive(true)
1844        .mode(0o700)
1845        .create(to)?;
1846    for entry in std::fs::read_dir(from)? {
1847        let entry = entry?;
1848        if skip.is_some_and(|skip| entry.file_name() == skip) {
1849            continue;
1850        }
1851        let source = entry.path();
1852        let target = to.join(entry.file_name());
1853        let kind = entry.file_type()?;
1854        if kind.is_symlink() {
1855            std::os::unix::fs::symlink(std::fs::read_link(&source)?, &target)?;
1856        } else if kind.is_dir() {
1857            copy_tree(&source, &target, None)?;
1858        } else {
1859            std::fs::copy(&source, &target)?;
1860        }
1861    }
1862    Ok(())
1863}
1864
1865/// Why a boot unit could not use `path`, owned by `owner`, or `None` when it
1866/// can.
1867///
1868/// A boot unit runs as `root` with no capability at all, so a directory
1869/// another account owns is one it cannot open, and one under a home tree is
1870/// one its `ProtectHome=` hides.
1871#[cfg(any(test, all(unix, not(target_os = "macos"))))]
1872fn boot_directory_refusal(path: &Path, owner: Option<u32>) -> Option<String> {
1873    if let Some(root) = SYSTEMD_HOME_ROOTS
1874        .iter()
1875        .find(|root| path.starts_with(root))
1876    {
1877        return Some(format!(
1878            "{} is under {root}, which a boot service cannot see",
1879            path.display()
1880        ));
1881    }
1882    match owner {
1883        Some(0) => None,
1884        Some(uid) => Some(format!(
1885            "{} belongs to uid {uid}, and a boot service runs as root with no capability \
1886             to open another account's directory",
1887            path.display()
1888        )),
1889        None => Some(format!("{} cannot be inspected", path.display())),
1890    }
1891}
1892
1893// -- launchd -----------------------------------------------------------------
1894
1895/// The `ProcessType` every rendered plist carries.
1896///
1897/// # Why not `Background`
1898///
1899/// launchd applies a job's `ProcessType` to the whole process tree it starts,
1900/// not only to the daemon. Under `Background` every runner the daemon launched
1901/// ran at base priority 4, on efficiency cores with throttled I/O, so each job
1902/// took 4 to 20 times as long as on a classic runner.
1903///
1904/// A child cannot leave it. This is the process type launchd chose at spawn
1905/// (`spawn type = background (5)` in `launchctl print`), not the
1906/// `PRIO_DARWIN_BG` flag `setpriority` controls: measured on macOS 27, a child
1907/// of a `Background` agent reads `getpriority(PRIO_DARWIN_PROCESS) == 0`, and
1908/// `setpriority(PRIO_DARWIN_PROCESS, 0, 0)` returns success and leaves it at
1909/// 4, before `exec` and after it alike. 0.4.31 tried exactly that.
1910///
1911/// The measured alternatives, as the base priority of a runner the agent
1912/// starts: `Background` 4, `Adaptive` 4, `Standard` (the default when the key
1913/// is absent) 20, `Interactive` 31 (the same as a classic runner, and the value
1914/// GitHub's own runner service template uses). The daemon itself mostly waits,
1915/// so it costs nothing to run it at that priority too.
1916pub const LAUNCHD_PROCESS_TYPE: &str = "Interactive";
1917
1918/// Renders the launchd property list for this plan.
1919///
1920/// `KeepAlive` is a dictionary rather than `<true/>` on purpose: the
1921/// requirement is *restart on failure*, and a bare `KeepAlive` also restarts a
1922/// job that exited cleanly, which would turn a deliberate `service stop` into a
1923/// fight with launchd.
1924#[must_use]
1925pub fn launchd_plist(plan: &InstallPlan) -> String {
1926    let identity = plan.identity();
1927    let directories = plan.directories();
1928    let mut out = String::new();
1929    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
1930    out.push_str(
1931        "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
1932         \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
1933    );
1934    out.push_str("<plist version=\"1.0\">\n<dict>\n");
1935    out.push_str(&plist_string("Label", &identity.launchd_label()));
1936
1937    out.push_str("  <key>ProgramArguments</key>\n  <array>\n");
1938    out.push_str(&format!(
1939        "    <string>{}</string>\n",
1940        xml_escape(&plan.binary().to_string_lossy())
1941    ));
1942    for argument in plan.arguments() {
1943        out.push_str(&format!(
1944            "    <string>{}</string>\n",
1945            xml_escape(&argument.to_string_lossy())
1946        ));
1947    }
1948    out.push_str("  </array>\n");
1949
1950    out.push_str("  <key>RunAtLoad</key>\n  <true/>\n");
1951    out.push_str("  <key>KeepAlive</key>\n  <dict>\n");
1952    out.push_str("    <key>SuccessfulExit</key>\n    <false/>\n");
1953    out.push_str("  </dict>\n");
1954    out.push_str(&format!(
1955        "  <key>ThrottleInterval</key>\n  <integer>{}</integer>\n",
1956        plan.restart().delay().as_secs()
1957    ));
1958    out.push_str(&plist_string("ProcessType", LAUNCHD_PROCESS_TYPE));
1959    out.push_str(&plist_string(
1960        "WorkingDirectory",
1961        &directories.state.to_string_lossy(),
1962    ));
1963    out.push_str(&plist_string(
1964        "StandardOutPath",
1965        &directories
1966            .logs
1967            .join("runner-manager.launchd.out.log")
1968            .to_string_lossy(),
1969    ));
1970    out.push_str(&plist_string(
1971        "StandardErrorPath",
1972        &directories
1973            .logs
1974            .join("runner-manager.launchd.err.log")
1975            .to_string_lossy(),
1976    ));
1977
1978    match plan.start_mode() {
1979        StartMode::Boot => {
1980            // A LaunchDaemon runs as root unless told otherwise, and here it
1981            // must: the System Keychain's master key is root-only.
1982            out.push_str(&plist_string(
1983                "UserName",
1984                ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
1985                    .as_str(),
1986            ));
1987            // A daemon has no user session and must not be given one.
1988            out.push_str("  <key>SessionCreate</key>\n  <false/>\n");
1989        }
1990        StartMode::Login => {
1991            // A LaunchAgent already runs as the operator. Naming a `UserName`
1992            // here would be asking launchd for an account switch a login-mode
1993            // registration has no reason to want.
1994        }
1995    }
1996
1997    out.push_str("</dict>\n</plist>\n");
1998    out
1999}
2000
2001/// One `<key>`/`<string>` pair, indented as the rest of the plist is.
2002fn plist_string(key: &str, value: &str) -> String {
2003    format!(
2004        "  <key>{}</key>\n  <string>{}</string>\n",
2005        xml_escape(key),
2006        xml_escape(value)
2007    )
2008}
2009
2010// -- Windows Task Scheduler --------------------------------------------------
2011
2012/// The account a Task Scheduler task runs as.
2013///
2014/// Task Scheduler, unlike the other three managers, needs the principal spelled
2015/// out in the document. Isolating the one impure step keeps
2016/// [`windows_scheduled_task_xml`] a pure function that every CI leg can assert.
2017#[derive(Debug, Clone, PartialEq, Eq)]
2018pub struct TaskPrincipal {
2019    user_id: String,
2020}
2021
2022impl TaskPrincipal {
2023    /// The account running this call, as `DOMAIN\user`.
2024    ///
2025    /// # Errors
2026    ///
2027    /// [`ServiceError::BinaryPath`] is not the right shape here, so this
2028    /// reports [`ServiceError::Control`] naming what the environment failed to
2029    /// say.
2030    pub fn current() -> Result<Self, ServiceError> {
2031        let user = std::env::var("USERNAME")
2032            .ok()
2033            .filter(|value| !value.trim().is_empty());
2034        let Some(user) = user else {
2035            return Err(ServiceError::Control {
2036                operation: "identify the account for",
2037                name: SERVICE_NAME.to_string(),
2038                manager: "Windows Task Scheduler",
2039                detail: "this session reports no %USERNAME%, so there is no principal to \
2040                         register a logon-triggered task for"
2041                    .to_string(),
2042            });
2043        };
2044        let domain = std::env::var("USERDOMAIN")
2045            .ok()
2046            .filter(|value| !value.trim().is_empty());
2047        Ok(Self {
2048            user_id: match domain {
2049                Some(domain) => format!("{domain}\\{user}"),
2050                None => user,
2051            },
2052        })
2053    }
2054
2055    /// A named principal, for tests and for a caller that already knows.
2056    #[must_use]
2057    pub fn named(user_id: impl Into<String>) -> Self {
2058        Self {
2059            user_id: user_id.into(),
2060        }
2061    }
2062
2063    /// `DOMAIN\user`.
2064    #[must_use]
2065    pub fn user_id(&self) -> &str {
2066        &self.user_id
2067    }
2068}
2069
2070/// Renders the Task Scheduler document for a `--start-at login` registration.
2071///
2072/// `RunLevel` is `LeastPrivilege`, which is the whole of Windows' answer to
2073/// item 2 in this domain: the task runs with the operator's ordinary filtered
2074/// token and never with an elevated one, whatever the operator's group
2075/// membership.
2076#[must_use]
2077pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
2078    let identity = plan.identity();
2079    let user = xml_escape(principal.user_id());
2080    let arguments = plan
2081        .arguments()
2082        .iter()
2083        .map(|argument| quote_argument(&argument.to_string_lossy()))
2084        .collect::<Vec<_>>()
2085        .join(" ");
2086    let mut out = String::new();
2087    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
2088    out.push_str(
2089        "<Task version=\"1.4\" \
2090         xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
2091    );
2092    out.push_str("  <RegistrationInfo>\n");
2093    out.push_str(&format!(
2094        "    <Description>{}</Description>\n",
2095        xml_escape(identity.description())
2096    ));
2097    out.push_str(&format!(
2098        "    <URI>\\{}</URI>\n",
2099        xml_escape(identity.name())
2100    ));
2101    out.push_str("  </RegistrationInfo>\n");
2102
2103    out.push_str("  <Triggers>\n    <LogonTrigger>\n");
2104    out.push_str("      <Enabled>true</Enabled>\n");
2105    out.push_str(&format!("      <UserId>{user}</UserId>\n"));
2106    out.push_str("    </LogonTrigger>\n  </Triggers>\n");
2107
2108    out.push_str("  <Principals>\n    <Principal id=\"Author\">\n");
2109    out.push_str(&format!("      <UserId>{user}</UserId>\n"));
2110    out.push_str("      <LogonType>InteractiveToken</LogonType>\n");
2111    out.push_str("      <RunLevel>LeastPrivilege</RunLevel>\n");
2112    out.push_str("    </Principal>\n  </Principals>\n");
2113
2114    out.push_str("  <Settings>\n");
2115    // One agent per host is `d1`'s lock; saying so here means Task Scheduler
2116    // refuses the second start rather than starting a process that then loses
2117    // the race and exits.
2118    out.push_str("    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
2119    out.push_str("    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
2120    out.push_str("    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
2121    out.push_str("    <AllowHardTerminate>true</AllowHardTerminate>\n");
2122    out.push_str("    <StartWhenAvailable>true</StartWhenAvailable>\n");
2123    out.push_str("    <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
2124    out.push_str("    <IdleSettings>\n");
2125    out.push_str("      <StopOnIdleEnd>false</StopOnIdleEnd>\n");
2126    out.push_str("      <RestartOnIdle>false</RestartOnIdle>\n");
2127    out.push_str("    </IdleSettings>\n");
2128    out.push_str("    <AllowStartOnDemand>true</AllowStartOnDemand>\n");
2129    out.push_str("    <Enabled>true</Enabled>\n");
2130    out.push_str("    <Hidden>false</Hidden>\n");
2131    out.push_str("    <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
2132    out.push_str("    <WakeToRun>false</WakeToRun>\n");
2133    // A daemon has no natural end, so any limit here would be a scheduled kill.
2134    out.push_str("    <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
2135    out.push_str("    <Priority>7</Priority>\n");
2136    out.push_str("    <RestartOnFailure>\n");
2137    out.push_str(&format!(
2138        "      <Interval>{}</Interval>\n",
2139        iso8601_minutes(
2140            plan.restart()
2141                .effective_delay(DefinitionKind::WindowsScheduledTask)
2142        )
2143    ));
2144    out.push_str(&format!("      <Count>{START_LIMIT_BURST}</Count>\n"));
2145    out.push_str("    </RestartOnFailure>\n");
2146    out.push_str("  </Settings>\n");
2147
2148    out.push_str("  <Actions Context=\"Author\">\n    <Exec>\n");
2149    out.push_str(&format!(
2150        "      <Command>{}</Command>\n",
2151        xml_escape(&plan.binary().to_string_lossy())
2152    ));
2153    if !arguments.is_empty() {
2154        out.push_str(&format!(
2155            "      <Arguments>{}</Arguments>\n",
2156            xml_escape(&arguments)
2157        ));
2158    }
2159    out.push_str(&format!(
2160        "      <WorkingDirectory>{}</WorkingDirectory>\n",
2161        xml_escape(&plan.directories().state.to_string_lossy())
2162    ));
2163    out.push_str("    </Exec>\n  </Actions>\n");
2164    out.push_str("</Task>\n");
2165    out
2166}
2167
2168/// `PT<n>M`, which is the only shape Task Scheduler accepts for a restart
2169/// interval. See [`RestartPolicy::effective_delay`].
2170fn iso8601_minutes(duration: Duration) -> String {
2171    format!("PT{}M", duration.as_secs() / 60)
2172}
2173
2174/// Escapes the five XML entities. Applied to every value that reaches a plist
2175/// or a task document, because a Windows account name may legitimately contain
2176/// `&` and a path may contain `<`.
2177///
2178/// `pub(crate)` rather than private because [`crate::wsl::task`] renders a
2179/// second kind of Task Scheduler document and must escape it the same way; two
2180/// escapers in one crate is exactly how one of them ends up subtly different.
2181pub(crate) fn xml_escape(value: &str) -> String {
2182    let mut out = String::with_capacity(value.len());
2183    for c in value.chars() {
2184        match c {
2185            '&' => out.push_str("&amp;"),
2186            '<' => out.push_str("&lt;"),
2187            '>' => out.push_str("&gt;"),
2188            '"' => out.push_str("&quot;"),
2189            '\'' => out.push_str("&apos;"),
2190            other => out.push(other),
2191        }
2192    }
2193    out
2194}
2195
2196/// The inverse of [`xml_escape`], for reading a definition back off disk.
2197///
2198/// `&amp;` is replaced last, which is the whole subtlety: replacing it first
2199/// would turn a literal `&amp;amp;` into `&` in two passes instead of `&amp;`
2200/// in one.
2201fn xml_unescape(value: &str) -> String {
2202    value
2203        .replace("&lt;", "<")
2204        .replace("&gt;", ">")
2205        .replace("&quot;", "\"")
2206        .replace("&apos;", "'")
2207        .replace("&amp;", "&")
2208}
2209
2210// -- Windows Service Control Manager -----------------------------------------
2211
2212/// How `CreateServiceW` should be called, in a form that can be reviewed,
2213/// asserted, and printed on any platform.
2214///
2215/// The Windows backend builds its `ServiceInfo` from **this** value rather than
2216/// from the plan, so the descriptor a Linux CI leg asserts and the parameters a
2217/// Windows host registers cannot drift apart.
2218#[derive(Debug, Clone, PartialEq, Eq)]
2219pub struct WindowsServiceSpec {
2220    /// The service name.
2221    pub name: String,
2222    /// What `services.msc` shows.
2223    pub display_name: String,
2224    /// The one-line description.
2225    pub description: String,
2226    /// `true` for `SERVICE_AUTO_START`, `false` for `SERVICE_DEMAND_START`.
2227    pub automatic_start: bool,
2228    /// The account, or `None` for `LocalSystem`, which is what
2229    /// `CreateServiceW` takes a null `lpServiceStartName` to mean.
2230    pub account: Option<String>,
2231    /// The full launch command, quoted.
2232    pub command_line: String,
2233    /// The restart-on-failure policy.
2234    pub restart: RestartPolicy,
2235}
2236
2237/// Derives the Windows service parameters from a plan.
2238#[must_use]
2239pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
2240    WindowsServiceSpec {
2241        name: plan.identity().name().to_string(),
2242        display_name: plan.identity().display_name().to_string(),
2243        description: plan.identity().description().to_string(),
2244        // Only a boot-mode registration is a service at all; a login-mode one
2245        // is a scheduled task. So an automatic start is the only kind here, and
2246        // the field exists because the privileged tests register an on-demand
2247        // fixture rather than one that starts with the test machine.
2248        automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
2249        account: match ServiceAccount::for_definition(
2250            DefinitionKind::WindowsService,
2251            plan.start_mode(),
2252        ) {
2253            // `None` means LocalSystem to `CreateServiceW`, and naming it
2254            // explicitly would be one more string to spell right.
2255            ServiceAccount::LocalSystem => None,
2256            other => Some(other.as_str().to_string()),
2257        },
2258        command_line: plan.command_line(),
2259        restart: plan.restart(),
2260    }
2261}
2262
2263/// Renders the descriptor [`review_least_privilege`] reads and `service status`
2264/// can print.
2265#[must_use]
2266fn windows_service_descriptor(plan: &InstallPlan) -> String {
2267    let spec = windows_service_spec(plan);
2268    let mut out = String::new();
2269    out.push_str("[windows-service]\n");
2270    out.push_str(&format!("Name={}\n", spec.name));
2271    out.push_str(&format!("DisplayName={}\n", spec.display_name));
2272    out.push_str(&format!("Description={}\n", spec.description));
2273    // OWN_PROCESS and never INTERACTIVE_PROCESS: an interactive service would
2274    // put a process this product controls on the operator's desktop, which is
2275    // both deprecated by Windows and more than the requirement asks for.
2276    out.push_str("ServiceType=OWN_PROCESS\n");
2277    out.push_str(&format!(
2278        "StartType={}\n",
2279        if spec.automatic_start {
2280            "AutoStart"
2281        } else {
2282            "OnDemand"
2283        }
2284    ));
2285    out.push_str("ErrorControl=Normal\n");
2286    out.push_str(&format!(
2287        "Account={}\n",
2288        spec.account
2289            .as_deref()
2290            .unwrap_or(ServiceAccount::LocalSystem.as_str())
2291    ));
2292    out.push_str(&format!("CommandLine={}\n", spec.command_line));
2293    out.push_str(&format!(
2294        "FailureActionRestartDelaySecs={}\n",
2295        spec.restart.delay().as_secs()
2296    ));
2297    out.push_str(&format!(
2298        "FailureActionsResetPeriodSecs={}\n",
2299        spec.restart.reset_after().as_secs()
2300    ));
2301    // Without this flag the Service Control Manager applies failure actions
2302    // only to a crash, and a daemon that exits non-zero after failing to reach
2303    // GitHub is not a crash.
2304    out.push_str("FailureActionsOnNonCrashFailures=true\n");
2305    out.push_str(&format!(
2306        "ReadWritePaths={}\n",
2307        plan.directories()
2308            .all()
2309            .iter()
2310            .map(|path| quote_argument(&path.to_string_lossy()))
2311            .collect::<Vec<_>>()
2312            .join(" ")
2313    ));
2314    out
2315}
2316
2317// ---------------------------------------------------------------------------
2318// The least-privilege review
2319// ---------------------------------------------------------------------------
2320
2321/// Whether a finding is about too much authority or too little.
2322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2323pub enum FindingKind {
2324    /// The definition grants more than `05-infrastructure.md` item 2 asks for.
2325    /// This is what makes a review fail.
2326    Excess,
2327    /// The definition does not grant something the daemon needs, or does not
2328    /// state something the review cannot verify without.
2329    Shortfall,
2330}
2331
2332impl fmt::Display for FindingKind {
2333    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2334        f.write_str(match self {
2335            Self::Excess => "excess",
2336            Self::Shortfall => "shortfall",
2337        })
2338    }
2339}
2340
2341/// One thing the review has to say about a definition.
2342#[derive(Debug, Clone, PartialEq, Eq)]
2343pub struct PrivilegeFinding {
2344    /// Excess or shortfall.
2345    pub kind: FindingKind,
2346    /// The directive, key, or element it is about.
2347    pub subject: String,
2348    /// What is wrong with it, in terms an operator can act on.
2349    pub detail: String,
2350}
2351
2352impl fmt::Display for PrivilegeFinding {
2353    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2354        write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2355    }
2356}
2357
2358/// What a definition actually grants, measured against the requirement.
2359///
2360/// `05-infrastructure.md` item 2 and `07-security.md`'s release gate
2361/// (*"Service account permissions are documented and verified least
2362/// privilege"*) are the same requirement stated twice, and this is the
2363/// verification half. The documentation half is `docs/service-account.md`.
2364///
2365/// The review reads the **rendered text**, not the plan it came from. That is
2366/// deliberate and it is the only version of this check worth having: a review
2367/// that re-derived its expectations from the same values the renderer used
2368/// would agree with the renderer by construction and could never fail. Reading
2369/// the text means a hand-edited unit on a real host is reviewed as it is, and
2370/// means a test can widen a definition and watch this say so.
2371#[derive(Debug, Clone, PartialEq, Eq)]
2372pub struct PrivilegeReview {
2373    kind: DefinitionKind,
2374    account: ServiceAccount,
2375    controls: Vec<String>,
2376    findings: Vec<PrivilegeFinding>,
2377}
2378
2379impl PrivilegeReview {
2380    /// Whether the definition grants nothing beyond the requirement.
2381    #[must_use]
2382    pub fn is_least_privilege(&self) -> bool {
2383        !self
2384            .findings
2385            .iter()
2386            .any(|finding| finding.kind == FindingKind::Excess)
2387    }
2388
2389    /// Everything the review found, excesses and shortfalls together.
2390    #[must_use]
2391    pub fn findings(&self) -> &[PrivilegeFinding] {
2392        &self.findings
2393    }
2394
2395    /// Only the excesses — the findings that make [`Self::is_least_privilege`]
2396    /// false.
2397    #[must_use]
2398    pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2399        self.findings
2400            .iter()
2401            .filter(|finding| finding.kind == FindingKind::Excess)
2402            .collect()
2403    }
2404
2405    /// The controls the definition was confirmed to carry.
2406    ///
2407    /// Present so that a passing review says *what it checked* rather than only
2408    /// that it passed. A check that reports nothing when it succeeds is
2409    /// indistinguishable from a check that did not run.
2410    #[must_use]
2411    pub fn controls(&self) -> &[String] {
2412        &self.controls
2413    }
2414
2415    /// The account the registration runs under, and why it is the minimum.
2416    #[must_use]
2417    pub const fn account(&self) -> &ServiceAccount {
2418        &self.account
2419    }
2420
2421    /// Which platform's definition was reviewed.
2422    #[must_use]
2423    pub const fn kind(&self) -> DefinitionKind {
2424        self.kind
2425    }
2426}
2427
2428impl fmt::Display for PrivilegeReview {
2429    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2430        writeln!(
2431            f,
2432            "{} runs as {} ({})",
2433            self.kind,
2434            self.account,
2435            self.account.justification()
2436        )?;
2437        for control in &self.controls {
2438            writeln!(f, "  confirmed  {control}")?;
2439        }
2440        for finding in &self.findings {
2441            writeln!(f, "  {finding}")?;
2442        }
2443        if self.is_least_privilege() {
2444            write!(f, "  verdict    least privilege")
2445        } else {
2446            write!(
2447                f,
2448                "  verdict    NOT least privilege: {} excess(es)",
2449                self.excesses().len()
2450            )
2451        }
2452    }
2453}
2454
2455/// Reads a definition back and reports what it grants.
2456///
2457/// `plan` supplies only the two things the text cannot: the four directories
2458/// the registration is *allowed* to write, and the start mode, which decides
2459/// which account is the minimum.
2460#[must_use]
2461pub fn review_least_privilege(
2462    definition: &ServiceDefinition,
2463    plan: &InstallPlan,
2464) -> PrivilegeReview {
2465    let mut controls = Vec::new();
2466    let mut findings = Vec::new();
2467    match definition.kind() {
2468        DefinitionKind::SystemdUnit => {
2469            review_systemd(definition.text(), plan, &mut controls, &mut findings);
2470        }
2471        DefinitionKind::LaunchdPlist => {
2472            review_launchd(definition.text(), plan, &mut controls, &mut findings);
2473        }
2474        DefinitionKind::WindowsScheduledTask => {
2475            review_scheduled_task(definition.text(), &mut controls, &mut findings);
2476        }
2477        DefinitionKind::WindowsService => {
2478            review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2479        }
2480    }
2481    PrivilegeReview {
2482        kind: definition.kind(),
2483        // The definition's account, not the plan's: a Linux CI leg reviewing the
2484        // Windows descriptor must report `LocalSystem`, which is the account
2485        // that descriptor registers, and not `root`, which is the account this
2486        // leg's own host would use.
2487        account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2488        controls,
2489        findings,
2490    }
2491}
2492
2493/// Every writable path a definition may name, as the strings it names them by.
2494fn permitted_paths(kind: DefinitionKind, plan: &InstallPlan) -> Vec<String> {
2495    let mut permitted = plan
2496        .directories()
2497        .all()
2498        .iter()
2499        .map(|path| path.to_string_lossy().into_owned())
2500        .collect::<Vec<_>>();
2501    if kind == DefinitionKind::SystemdUnit
2502        && let Some(secret_directory) = plan.secret_guard().and_then(Path::parent)
2503    {
2504        permitted.push(secret_directory.to_string_lossy().into_owned());
2505    }
2506    permitted
2507}
2508
2509/// Whether two path spellings name the same place on **this host's**
2510/// filesystem. Case-insensitive on Windows, exact elsewhere.
2511///
2512/// For a rendered definition use [`same_path_for`] instead: which comparison is
2513/// right there follows the platform the definition is *for*, and every leg of
2514/// the CI matrix reviews all four.
2515fn same_path_text(left: &str, right: &str) -> bool {
2516    if cfg!(windows) {
2517        left.eq_ignore_ascii_case(right)
2518    } else {
2519        left == right
2520    }
2521}
2522
2523/// Whether two path spellings name the same place on the platform a given
2524/// definition targets.
2525fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
2526    match kind {
2527        DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
2528            left.eq_ignore_ascii_case(right)
2529        }
2530        DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
2531    }
2532}
2533
2534/// Checks a `ReadWritePaths`-style list against the permitted directories.
2535fn review_writable_paths(
2536    kind: DefinitionKind,
2537    subject: &str,
2538    listed: &[String],
2539    plan: &InstallPlan,
2540    controls: &mut Vec<String>,
2541    findings: &mut Vec<PrivilegeFinding>,
2542) {
2543    let permitted = permitted_paths(kind, plan);
2544    for entry in listed {
2545        if !permitted
2546            .iter()
2547            .any(|allowed| same_path_for(kind, allowed, entry))
2548        {
2549            findings.push(PrivilegeFinding {
2550                kind: FindingKind::Excess,
2551                subject: subject.to_string(),
2552                detail: format!(
2553                    "{entry} is writable but is not one of this registration's required paths"
2554                ),
2555            });
2556        }
2557    }
2558    for allowed in &permitted {
2559        if !listed
2560            .iter()
2561            .any(|entry| same_path_for(kind, allowed, entry))
2562        {
2563            findings.push(PrivilegeFinding {
2564                kind: FindingKind::Shortfall,
2565                subject: subject.to_string(),
2566                detail: format!(
2567                    "{allowed} is one of this registration's directories but is not writable, \
2568                     so the daemon cannot use it"
2569                ),
2570            });
2571        }
2572    }
2573    if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
2574        controls.push(format!(
2575            "{subject} names exactly the required application-data and credential paths"
2576        ));
2577    }
2578}
2579
2580/// Compares a unit's capability bounding set with the one it should keep, as
2581/// sets: a capability it lacks is a shortfall, one it adds is an excess.
2582fn review_capabilities(
2583    expected: &str,
2584    wanted: &str,
2585    actual: &str,
2586    controls: &mut Vec<String>,
2587    findings: &mut Vec<PrivilegeFinding>,
2588) {
2589    let subject = "CapabilityBoundingSet";
2590    // `~` inverts the list, keeping every capability *except* those named.
2591    if actual.trim_start().starts_with('~') {
2592        findings.push(PrivilegeFinding {
2593            kind: FindingKind::Excess,
2594            subject: subject.to_string(),
2595            detail: format!("is `{actual}`, which keeps every capability it does not name"),
2596        });
2597        return;
2598    }
2599    let wanted: BTreeSet<&str> = wanted.split_whitespace().collect();
2600    let actual: BTreeSet<&str> = actual.split_whitespace().collect();
2601    let extra = actual.difference(&wanted).copied().collect::<Vec<_>>();
2602    let missing = wanted.difference(&actual).copied().collect::<Vec<_>>();
2603    if !extra.is_empty() {
2604        findings.push(PrivilegeFinding {
2605            kind: FindingKind::Excess,
2606            subject: subject.to_string(),
2607            detail: format!(
2608                "keeps {}, which the requirement does not ask for",
2609                extra.join(" ")
2610            ),
2611        });
2612    }
2613    if !missing.is_empty() {
2614        findings.push(PrivilegeFinding {
2615            kind: FindingKind::Shortfall,
2616            subject: subject.to_string(),
2617            detail: format!("drops {}, which the daemon needs", missing.join(" ")),
2618        });
2619    }
2620    if extra.is_empty() && missing.is_empty() {
2621        controls.push(expected.to_string());
2622    }
2623}
2624
2625/// The one inbound-surface rule, applied to whichever platform names it.
2626///
2627/// `07-security.md` handling rule 2: *"The product exposes no inbound HTTP,
2628/// socket, or RPC surface anywhere, in any command."* A definition that asks the
2629/// service manager to open a socket or publish a Mach service on the daemon's
2630/// behalf would create exactly that surface without a line of product code
2631/// changing, which is why it is checked here and not only in review.
2632fn review_inbound_surface(
2633    text: &str,
2634    markers: &[(&str, &str)],
2635    controls: &mut Vec<String>,
2636    findings: &mut Vec<PrivilegeFinding>,
2637) {
2638    let mut clean = true;
2639    for (marker, detail) in markers {
2640        if text.contains(marker) {
2641            clean = false;
2642            findings.push(PrivilegeFinding {
2643                kind: FindingKind::Excess,
2644                subject: (*marker).to_string(),
2645                detail: (*detail).to_string(),
2646            });
2647        }
2648    }
2649    if clean {
2650        controls.push(
2651            "no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
2652        );
2653    }
2654}
2655
2656fn review_systemd(
2657    text: &str,
2658    plan: &InstallPlan,
2659    controls: &mut Vec<String>,
2660    findings: &mut Vec<PrivilegeFinding>,
2661) {
2662    let directives = ini_directives(text, "Service");
2663    for expected in systemd_hardening(plan.start_mode()) {
2664        let (key, value) = expected
2665            .split_once('=')
2666            .expect("every hardening directive is written as key=value");
2667        match directives.get(key) {
2668            Some(actual) if key == "CapabilityBoundingSet" => {
2669                review_capabilities(expected, value, actual, controls, findings);
2670            }
2671            Some(actual) if actual == value => controls.push((*expected).to_string()),
2672            Some(actual) => findings.push(PrivilegeFinding {
2673                kind: FindingKind::Excess,
2674                subject: key.to_string(),
2675                detail: format!(
2676                    "is `{actual}`, not `{value}`, so the unit keeps authority the \
2677                                 requirement does not ask for"
2678                ),
2679            }),
2680            None => findings.push(PrivilegeFinding {
2681                kind: FindingKind::Excess,
2682                subject: key.to_string(),
2683                detail: format!(
2684                    "is absent, so the unit inherits systemd's default rather than `{value}`"
2685                ),
2686            }),
2687        }
2688    }
2689
2690    match directives.get("ReadWritePaths") {
2691        Some(value) => {
2692            let listed = split_quoted(value);
2693            review_writable_paths(
2694                DefinitionKind::SystemdUnit,
2695                "ReadWritePaths",
2696                &listed,
2697                plan,
2698                controls,
2699                findings,
2700            );
2701        }
2702        None => findings.push(PrivilegeFinding {
2703            kind: FindingKind::Shortfall,
2704            subject: "ReadWritePaths".to_string(),
2705            detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
2706                .to_string(),
2707        }),
2708    }
2709
2710    // `User=` on a system unit is an escalation only in the other direction, so
2711    // what is checked is the pair systemd actually treats as privileged.
2712    if directives.contains_key("PrivateUsers")
2713        && directives.get("PrivateUsers") == Some(&"no".to_string())
2714    {
2715        findings.push(PrivilegeFinding {
2716            kind: FindingKind::Excess,
2717            subject: "PrivateUsers".to_string(),
2718            detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
2719                .to_string(),
2720        });
2721    }
2722
2723    review_inbound_surface(
2724        text,
2725        &[
2726            (
2727                "ListenStream=",
2728                "asks systemd to open a listening socket for this service, which \
2729                 07-security.md rule 2 forbids the product to have",
2730            ),
2731            (
2732                "ListenDatagram=",
2733                "asks systemd to open a listening socket for this service, which \
2734                 07-security.md rule 2 forbids the product to have",
2735            ),
2736        ],
2737        controls,
2738        findings,
2739    );
2740}
2741
2742fn review_launchd(
2743    text: &str,
2744    plan: &InstallPlan,
2745    controls: &mut Vec<String>,
2746    findings: &mut Vec<PrivilegeFinding>,
2747) {
2748    // A scheduling class is not an authority, so no value here is an excess.
2749    // Anything but `Interactive` is a shortfall: launchd applies it to every
2750    // runner the daemon starts. See `LAUNCHD_PROCESS_TYPE`.
2751    match plist_string_value(text, "ProcessType") {
2752        Some(value) if value == LAUNCHD_PROCESS_TYPE => {
2753            controls.push(format!("ProcessType={LAUNCHD_PROCESS_TYPE}"));
2754        }
2755        found => findings.push(PrivilegeFinding {
2756            kind: FindingKind::Shortfall,
2757            subject: "ProcessType".to_string(),
2758            detail: format!(
2759                "is {}, and launchd applies it to every runner the daemon starts, so their jobs \
2760                 run below normal priority. The daemon rewrites it to `{LAUNCHD_PROCESS_TYPE}` \
2761                 when it next starts with no runner; `service status` says how to restart it",
2762                found.map_or_else(
2763                    || "absent (launchd's `Standard`)".to_string(),
2764                    |value| format!("`{value}`")
2765                )
2766            ),
2767        }),
2768    }
2769
2770    match plan.start_mode() {
2771        StartMode::Boot => {
2772            if plist_bool_value(text, "SessionCreate") == Some(true) {
2773                findings.push(PrivilegeFinding {
2774                    kind: FindingKind::Excess,
2775                    subject: "SessionCreate".to_string(),
2776                    detail: "asks launchd to create a security session for a job that runs \
2777                             outside every login session and has no use for one"
2778                        .to_string(),
2779                });
2780            } else {
2781                controls.push("SessionCreate is not requested".to_string());
2782            }
2783            match plist_string_value(text, "UserName").as_deref() {
2784                Some("root") => {
2785                    controls.push("UserName=root, stated rather than inherited".to_string())
2786                }
2787                Some(other) => findings.push(PrivilegeFinding {
2788                    kind: FindingKind::Shortfall,
2789                    subject: "UserName".to_string(),
2790                    detail: format!(
2791                        "is `{other}`, which cannot unlock the System Keychain: \
2792                         /var/db/SystemKey is root-only, so the daemon would start and then \
2793                         find no credential"
2794                    ),
2795                }),
2796                None => findings.push(PrivilegeFinding {
2797                    kind: FindingKind::Shortfall,
2798                    subject: "UserName".to_string(),
2799                    detail: "is absent, so the account is launchd's implicit default and this \
2800                             review cannot confirm it"
2801                        .to_string(),
2802                }),
2803            }
2804        }
2805        StartMode::Login => {
2806            if let Some(named) = plist_string_value(text, "UserName") {
2807                findings.push(PrivilegeFinding {
2808                    kind: FindingKind::Excess,
2809                    subject: "UserName".to_string(),
2810                    detail: format!(
2811                        "names `{named}` in a LaunchAgent, which already runs as the operator; \
2812                         naming an account here asks launchd for a switch a login-mode \
2813                         registration has no reason to want"
2814                    ),
2815                });
2816            } else {
2817                controls
2818                    .push("no UserName: the agent runs as the operator and no other".to_string());
2819            }
2820        }
2821    }
2822
2823    review_inbound_surface(
2824        text,
2825        &[
2826            (
2827                "<key>Sockets</key>",
2828                "asks launchd to open a socket for this job, which 07-security.md rule 2 \
2829                 forbids the product to have",
2830            ),
2831            (
2832                "<key>MachServices</key>",
2833                "publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
2834                 forbids the product to have",
2835            ),
2836        ],
2837        controls,
2838        findings,
2839    );
2840}
2841
2842fn review_scheduled_task(
2843    text: &str,
2844    controls: &mut Vec<String>,
2845    findings: &mut Vec<PrivilegeFinding>,
2846) {
2847    match xml_value(text, "RunLevel").as_deref() {
2848        Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
2849        Some(other) => findings.push(PrivilegeFinding {
2850            kind: FindingKind::Excess,
2851            subject: "RunLevel".to_string(),
2852            detail: format!(
2853                "is `{other}`, so the task runs with an elevated token whenever the operator is \
2854                 an administrator"
2855            ),
2856        }),
2857        None => findings.push(PrivilegeFinding {
2858            kind: FindingKind::Excess,
2859            subject: "RunLevel".to_string(),
2860            detail: "is absent, so Task Scheduler decides the token rather than the definition"
2861                .to_string(),
2862        }),
2863    }
2864
2865    match xml_value(text, "LogonType").as_deref() {
2866        Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
2867        Some(other) => findings.push(PrivilegeFinding {
2868            kind: FindingKind::Excess,
2869            subject: "LogonType".to_string(),
2870            detail: format!(
2871                "is `{other}`, which asks Windows to store or synthesise a credential for this \
2872                 task; an interactive token needs neither"
2873            ),
2874        }),
2875        None => findings.push(PrivilegeFinding {
2876            kind: FindingKind::Shortfall,
2877            subject: "LogonType".to_string(),
2878            detail: "is absent, so this review cannot confirm that no credential is stored"
2879                .to_string(),
2880        }),
2881    }
2882}
2883
2884fn review_windows_service(
2885    text: &str,
2886    plan: &InstallPlan,
2887    controls: &mut Vec<String>,
2888    findings: &mut Vec<PrivilegeFinding>,
2889) {
2890    let directives = ini_directives(text, "windows-service");
2891
2892    match directives.get("ServiceType").map(String::as_str) {
2893        Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
2894        Some(other) => findings.push(PrivilegeFinding {
2895            kind: FindingKind::Excess,
2896            subject: "ServiceType".to_string(),
2897            detail: format!(
2898                "is `{other}`; an interactive or shared-process service reaches further than a \
2899                 daemon that only talks to GitHub over HTTPS"
2900            ),
2901        }),
2902        None => findings.push(PrivilegeFinding {
2903            kind: FindingKind::Shortfall,
2904            subject: "ServiceType".to_string(),
2905            detail: "is absent, so this review cannot confirm the service is not interactive"
2906                .to_string(),
2907        }),
2908    }
2909
2910    // The account is not a free choice on Windows: `d2`'s DACL decides it. What
2911    // the review can check is that the definition names the one account that
2912    // DACL admits, and no broader one.
2913    match directives.get("Account").map(String::as_str) {
2914        Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
2915            controls.push(format!(
2916                "Account={account}: the only stock account the machine-scoped store's DACL \
2917                 (SY, BA, OW) admits"
2918            ));
2919        }
2920        Some(other) => findings.push(PrivilegeFinding {
2921            kind: FindingKind::Shortfall,
2922            subject: "Account".to_string(),
2923            detail: format!(
2924                "is `{other}`, which the machine-scoped store's DACL does not name, so the \
2925                 daemon would start and then find no credential. Widening that DACL is not this \
2926                 registration's to do: an ACE reaching `{other}` would also reach every other \
2927                 service running under it"
2928            ),
2929        }),
2930        None => findings.push(PrivilegeFinding {
2931            kind: FindingKind::Shortfall,
2932            subject: "Account".to_string(),
2933            detail: "is absent, so this review cannot confirm which account was registered"
2934                .to_string(),
2935        }),
2936    }
2937
2938    match directives.get("ReadWritePaths") {
2939        Some(value) => {
2940            let listed = split_quoted(value);
2941            review_writable_paths(
2942                DefinitionKind::WindowsService,
2943                "ReadWritePaths",
2944                &listed,
2945                plan,
2946                controls,
2947                findings,
2948            );
2949        }
2950        None => findings.push(PrivilegeFinding {
2951            kind: FindingKind::Shortfall,
2952            subject: "ReadWritePaths".to_string(),
2953            detail: "is absent, so the directories the service was installed against are not \
2954                     recorded"
2955                .to_string(),
2956        }),
2957    }
2958}
2959
2960// -- parsing helpers ---------------------------------------------------------
2961
2962/// Reads `key=value` lines out of one `[section]` of an INI-shaped document.
2963///
2964/// systemd's unit format and this module's Windows descriptor are both this
2965/// shape. A repeated key takes its last value, which is systemd's own rule for
2966/// every directive here.
2967fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
2968    let mut out = BTreeMap::new();
2969    let mut inside = false;
2970    for line in text.lines() {
2971        let line = line.trim();
2972        if line.starts_with('[') && line.ends_with(']') {
2973            inside = &line[1..line.len() - 1] == section;
2974            continue;
2975        }
2976        if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2977            continue;
2978        }
2979        if let Some((key, value)) = line.split_once('=') {
2980            out.insert(key.trim().to_string(), value.trim().to_string());
2981        }
2982    }
2983    out
2984}
2985
2986/// The command line in a systemd `ExecStart=`, without the prefixes systemd
2987/// reads off its front (`-`, `@`, `:`, `+`, `!`, `!!`).
2988///
2989/// A drop-in may carry any of them, and left in place they become part of the
2990/// executable's path. With `@` the second word is the `argv[0]` handed to the
2991/// process rather than an argument, so it goes as well.
2992#[cfg(any(test, all(unix, not(target_os = "macos"))))]
2993fn systemd_exec_command_line(value: &str) -> String {
2994    let value = value.trim();
2995    let command = value.trim_start_matches(['-', '@', ':', '+', '!']);
2996    if !value[..value.len() - command.len()].contains('@') {
2997        return command.to_string();
2998    }
2999    let mut words = split_command_line(command);
3000    if words.len() > 1 {
3001        words.remove(1);
3002    }
3003    words
3004        .iter()
3005        .map(|word| quote_argument(word))
3006        .collect::<Vec<_>>()
3007        .join(" ")
3008}
3009
3010/// Parses a systemd time span such as `15`, `15s`, `500ms`, `1min 30s` or
3011/// `2h`. A bare number is seconds. `None` for `infinity`, an empty value, or
3012/// anything systemd would not accept either.
3013#[cfg(any(test, all(unix, not(target_os = "macos"))))]
3014fn systemd_timespan(value: &str) -> Option<Duration> {
3015    let mut rest = value.trim();
3016    if rest.is_empty() || rest == "infinity" {
3017        return None;
3018    }
3019    let mut seconds = 0f64;
3020    while !rest.is_empty() {
3021        let digits = rest
3022            .find(|c: char| !(c.is_ascii_digit() || c == '.'))
3023            .unwrap_or(rest.len());
3024        if digits == 0 {
3025            return None;
3026        }
3027        let number: f64 = rest[..digits].parse().ok()?;
3028        rest = rest[digits..].trim_start();
3029        let letters = rest
3030            .find(|c: char| !c.is_alphabetic())
3031            .unwrap_or(rest.len());
3032        let scale = match &rest[..letters] {
3033            "" | "s" | "sec" | "second" | "seconds" => 1.0,
3034            "us" | "usec" | "\u{3bc}s" | "\u{b5}s" => 1e-6,
3035            "ms" | "msec" => 1e-3,
3036            "m" | "min" | "minute" | "minutes" => 60.0,
3037            "h" | "hr" | "hour" | "hours" => 3_600.0,
3038            "d" | "day" | "days" => 86_400.0,
3039            "w" | "week" | "weeks" => 604_800.0,
3040            "M" | "month" | "months" => 2_629_800.0,
3041            "y" | "year" | "years" => 31_557_600.0,
3042            _ => return None,
3043        };
3044        seconds += number * scale;
3045        rest = rest[letters..].trim_start();
3046    }
3047    Duration::try_from_secs_f64(seconds).ok()
3048}
3049
3050/// Splits a whitespace-separated list that may contain arguments quoted by
3051/// [`quote_argument`].
3052fn split_quoted(value: &str) -> Vec<String> {
3053    let mut out = Vec::new();
3054    let mut rest = value.trim();
3055    while !rest.is_empty() {
3056        if rest.starts_with('"') {
3057            // `executable_from_command_line` already implements Windows' quoting
3058            // rules; reuse it rather than write a second, subtly different one.
3059            if let Some(parsed) = executable_from_command_line(rest) {
3060                out.push(parsed.to_string_lossy().into_owned());
3061            }
3062            // Advance past the closing quote.
3063            let mut depth = 0usize;
3064            let mut end = rest.len();
3065            for (index, c) in rest.char_indices() {
3066                match c {
3067                    '\\' => depth += 1,
3068                    '"' => {
3069                        if depth.is_multiple_of(2) && index > 0 {
3070                            end = index + 1;
3071                            break;
3072                        }
3073                        depth = 0;
3074                    }
3075                    _ => depth = 0,
3076                }
3077            }
3078            rest = rest[end..].trim_start();
3079        } else {
3080            let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
3081            out.push(rest[..end].to_string());
3082            rest = rest[end..].trim_start();
3083        }
3084    }
3085    out
3086}
3087
3088/// The text between the **first** `<tag>` and its `</tag>`, unescaped, for the
3089/// small, known documents this module renders. Not a general XML parser and
3090/// does not pretend to be one.
3091///
3092/// "First" matters for a document that repeats a tag: a Task Scheduler task has
3093/// an `<Enabled>` in its trigger *and* one in its settings, so a caller after
3094/// the second one passes the slice that starts at `<Settings>`.
3095///
3096/// `pub(crate)` rather than private because [`crate::wsl::task`] reads back a
3097/// second kind of Task Scheduler document; one reader for both is the same
3098/// argument [`xml_escape`] makes for one escaper.
3099pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
3100    let open = format!("<{tag}>");
3101    let close = format!("</{tag}>");
3102    let start = text.find(&open)? + open.len();
3103    let end = text[start..].find(&close)? + start;
3104    Some(xml_unescape(text[start..end].trim()))
3105}
3106
3107/// Whether Task Scheduler will launch a login task automatically.
3108///
3109/// Its `/Query /XML` output normalises away values that equal the schema
3110/// default. In particular, an enabled task commonly has no `<Enabled>` node at
3111/// all, so only an explicit `false` means disabled.
3112#[cfg(any(windows, test))]
3113fn windows_login_task_starts_automatically(document: &str) -> bool {
3114    document.contains("<LogonTrigger>")
3115        && xml_value(document, "Enabled").as_deref() != Some("false")
3116}
3117
3118/// The `<string>` that follows `<key>key</key>` in a property list.
3119fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
3120    let marker = format!("<key>{key}</key>");
3121    let start = text.find(&marker)? + marker.len();
3122    Some(text[start..].trim_start())
3123}
3124
3125pub fn plist_string_value(text: &str, key: &str) -> Option<String> {
3126    let rest = plist_value_after_key(text, key)?;
3127    if !rest.starts_with("<string>") {
3128        return None;
3129    }
3130    xml_value(rest, "string")
3131}
3132
3133fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
3134    let rest = plist_value_after_key(text, key)?;
3135    if rest.starts_with("<true/>") {
3136        Some(true)
3137    } else if rest.starts_with("<false/>") {
3138        Some(false)
3139    } else {
3140        None
3141    }
3142}
3143
3144// ---------------------------------------------------------------------------
3145// The install record
3146// ---------------------------------------------------------------------------
3147
3148/// The version of [`InstallRecord`] on disk.
3149///
3150/// Bumped when a record written by an older version can no longer be read.
3151/// `service status` reports an unreadable record as a problem with a remedy
3152/// rather than failing, because the remedy — uninstall and install again —
3153/// touches no configuration, no secret and no cache.
3154pub const RECORD_SCHEMA_VERSION: u32 = 1;
3155
3156/// What `service install` wrote down, and what `service status` reads back.
3157///
3158/// This is the *record* half of `05-infrastructure.md` item 6. The service
3159/// manager also knows the binary path, and the two are compared: a registration
3160/// whose command line no longer matches the record is a registration something
3161/// else has edited, and saying so is more useful than picking one of them to
3162/// believe.
3163///
3164/// It holds no credential. The nearest thing is the *location* of the secret
3165/// store, which `d2` publishes for `host show` to print, and even that is
3166/// derived rather than stored.
3167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3168pub struct InstallRecord {
3169    /// [`RECORD_SCHEMA_VERSION`] at the time of writing.
3170    pub schema_version: u32,
3171    /// What the operating system calls the registration.
3172    pub service_name: String,
3173    /// Which service manager holds it.
3174    pub manager: String,
3175    /// Boot or login.
3176    pub start_mode: StartMode,
3177    /// The account it runs under.
3178    pub account: ServiceAccount,
3179    /// **The resolved absolute path of the binary at install time.** Item 6.
3180    pub binary: PathBuf,
3181    /// Where [`Self::binary`] was copied from, when it is a copy.
3182    ///
3183    /// `#[serde(default)]` rather than a schema bump: a record written before
3184    /// this existed is still readable, and reads as `None`. That is not a
3185    /// silent downgrade — `None` means the registration names a package
3186    /// manager's own file, which is the layout that cannot be upgraded while
3187    /// the service runs, and `service status` says so by name.
3188    #[serde(default)]
3189    pub source_binary: Option<PathBuf>,
3190    /// The arguments it was registered with.
3191    pub arguments: Vec<String>,
3192    /// The restart-on-failure delay.
3193    pub restart_delay_secs: u64,
3194    /// How long it must run before the failure count resets.
3195    pub restart_reset_secs: u64,
3196    /// The rotating diagnostic log's stem. Item 4.
3197    pub log_file: PathBuf,
3198    /// Whether the manager was asked not to start this by itself. Always
3199    /// `false` for a registration an operator made; see
3200    /// [`InstallRequest::started_on_demand`].
3201    ///
3202    /// `#[serde(default)]` so that a record missing the field is rejected by
3203    /// the schema check below, with its remedy, rather than by a parse error
3204    /// that names a field an operator has never heard of.
3205    #[serde(default)]
3206    pub starts_on_demand: bool,
3207    /// Where the platform's definition file was written, when there is one.
3208    pub definition_path: Option<PathBuf>,
3209    /// When the registration was made.
3210    pub installed_at: DateTime<Utc>,
3211    /// Which build of the product made it.
3212    pub installed_by_version: String,
3213    // A TOML table has to follow every scalar at its level, so this field is
3214    // last by necessity rather than by taste.
3215    /// The four directories the registration was installed against. Item 2's
3216    /// *"configured cache and runtime directories"*.
3217    pub directories: ServiceDirectories,
3218}
3219
3220impl InstallRecord {
3221    /// Where the record lives, given this host's directories.
3222    #[must_use]
3223    pub fn path(paths: &AppPaths) -> PathBuf {
3224        paths.config_dir().join(RECORD_FILE)
3225    }
3226
3227    /// Builds the record for a plan that has just been applied.
3228    #[must_use]
3229    pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
3230        Self {
3231            schema_version: RECORD_SCHEMA_VERSION,
3232            service_name: plan.identity().name().to_string(),
3233            manager: definition.kind().manager().to_string(),
3234            start_mode: plan.start_mode(),
3235            account: plan.account().clone(),
3236            binary: plan.binary().to_path_buf(),
3237            arguments: plan
3238                .arguments()
3239                .iter()
3240                .map(|argument| argument.to_string_lossy().into_owned())
3241                .collect(),
3242            restart_delay_secs: plan.restart().delay().as_secs(),
3243            restart_reset_secs: plan.restart().reset_after().as_secs(),
3244            starts_on_demand: plan.is_on_demand(),
3245            source_binary: plan.source_binary().map(Path::to_path_buf),
3246            log_file: plan.directories().log_file(),
3247            definition_path: definition.install_path().map(Path::to_path_buf),
3248            installed_at: at,
3249            installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
3250            directories: plan.directories().clone(),
3251        }
3252    }
3253
3254    /// The plan this record describes, as this build would register it.
3255    ///
3256    /// The directories, binary, arguments and restart policy are the recorded
3257    /// ones; everything else -- hardening, the secret store's guard -- is what
3258    /// this build renders. That difference is the point: it is how a
3259    /// registration written by an older build is compared against, and
3260    /// repaired to, the current definition.
3261    #[must_use]
3262    pub fn plan(&self, identity: ServiceIdentity) -> InstallPlan {
3263        let plan = InstallPlan::unchecked(
3264            identity,
3265            self.start_mode,
3266            self.binary.clone(),
3267            self.directories.clone(),
3268        )
3269        .with_arguments(self.arguments.clone())
3270        .with_restart(self.restart());
3271        let plan = if self.starts_on_demand {
3272            plan.started_on_demand()
3273        } else {
3274            plan
3275        };
3276        match crate::secrets::PlatformSecretStore::for_start_mode(self.start_mode) {
3277            Ok(store) => plan.with_secret_guard(store.guard()),
3278            Err(_) => plan,
3279        }
3280    }
3281
3282    /// The restart policy this record describes, or [`RestartPolicy::default`]
3283    /// when the recorded numbers are outside the supported range — which can
3284    /// only happen to a record something else has edited.
3285    #[must_use]
3286    pub fn restart(&self) -> RestartPolicy {
3287        RestartPolicy::new(
3288            Duration::from_secs(self.restart_delay_secs),
3289            Duration::from_secs(self.restart_reset_secs),
3290        )
3291        .unwrap_or_default()
3292    }
3293
3294    /// Reads the record, or reports that there is none.
3295    ///
3296    /// `Ok(None)` means no registration was made by this product on this host.
3297    ///
3298    /// # Errors
3299    ///
3300    /// [`ServiceError::Record`] when the file exists and cannot be read,
3301    /// [`ServiceError::RecordNotPermitted`] when it exists and this account may
3302    /// not read it, and [`ServiceError::RecordUnreadable`] when it is not a
3303    /// record this version understands.
3304    pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
3305        let path = Self::path(paths);
3306        let text = match std::fs::read_to_string(&path) {
3307            Ok(text) => text,
3308            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3309            // Told apart from every other read failure because `status` carries
3310            // on through this one alone. See `ServiceError::RecordNotPermitted`.
3311            Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
3312                return Err(ServiceError::RecordNotPermitted {
3313                    path,
3314                    detail: error.to_string(),
3315                });
3316            }
3317            Err(error) => {
3318                return Err(ServiceError::Record {
3319                    operation: "read",
3320                    path,
3321                    detail: error.to_string(),
3322                });
3323            }
3324        };
3325        let record: Self =
3326            toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
3327                path: path.clone(),
3328                detail: error.to_string(),
3329            })?;
3330        if record.schema_version != RECORD_SCHEMA_VERSION {
3331            return Err(ServiceError::RecordUnreadable {
3332                path,
3333                detail: format!(
3334                    "it declares schema version {} and this build reads version {}",
3335                    record.schema_version, RECORD_SCHEMA_VERSION
3336                ),
3337            });
3338        }
3339        Ok(Some(record))
3340    }
3341
3342    /// Writes the record, replacing whatever was there.
3343    ///
3344    /// # Errors
3345    ///
3346    /// [`ServiceError::Record`].
3347    pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
3348        use std::io::Write as _;
3349
3350        let path = Self::path(paths);
3351        let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
3352            operation: "encode",
3353            path: path.clone(),
3354            detail: error.to_string(),
3355        })?;
3356        if let Some(parent) = path.parent() {
3357            std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
3358                operation: "write",
3359                path: path.clone(),
3360                detail: error.to_string(),
3361            })?;
3362        }
3363        let parent = path.parent().ok_or_else(|| ServiceError::Record {
3364            operation: "write",
3365            path: path.clone(),
3366            detail: "the record path has no parent directory".to_string(),
3367        })?;
3368        let mut temporary =
3369            tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
3370                operation: "write",
3371                path: path.clone(),
3372                detail: error.to_string(),
3373            })?;
3374        temporary
3375            .write_all(text.as_bytes())
3376            .and_then(|()| temporary.as_file().sync_all())
3377            .map_err(|error| ServiceError::Record {
3378                operation: "write",
3379                path: path.clone(),
3380                detail: error.to_string(),
3381            })?;
3382        // ------------------------------------------------------------------
3383        // THE RECORD IS READABLE BY THE ACCOUNT WHOSE DIRECTORY IT IS IN.
3384        // ------------------------------------------------------------------
3385        // `NamedTempFile` creates at `0600`, which is right for a temporary
3386        // file and wrong for this one. A boot-mode `service install` runs under
3387        // `sudo`, so `0600` made the record `root`-owned and unreadable by the
3388        // operator — and `service status`, which asks no privilege of anybody,
3389        // failed on their own host with `Permission denied` and a remedy that
3390        // named the command that had just failed.
3391        //
3392        // `0644` does not widen anything: `RECORD_FILE`'s own documentation is
3393        // that this is *"non-secret TOML"* holding no credential, and
3394        // `AppPaths::create_all` puts it in a `0700` directory, so no other
3395        // local account can reach it whatever its own mode says. It is the same
3396        // arrangement the rotating diagnostics already have.
3397        #[cfg(unix)]
3398        {
3399            use std::os::unix::fs::PermissionsExt as _;
3400
3401            temporary
3402                .as_file()
3403                .set_permissions(std::fs::Permissions::from_mode(0o644))
3404                .map_err(|error| ServiceError::Record {
3405                    operation: "write",
3406                    path: path.clone(),
3407                    detail: error.to_string(),
3408                })?;
3409        }
3410        temporary
3411            .persist(&path)
3412            .map(|_| ())
3413            .map_err(|error| ServiceError::Record {
3414                operation: "write",
3415                path,
3416                detail: error.error.to_string(),
3417            })
3418    }
3419
3420    /// Removes the record. Returns whether there was one.
3421    ///
3422    /// # Errors
3423    ///
3424    /// [`ServiceError::Record`].
3425    pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
3426        let path = Self::path(paths);
3427        match std::fs::remove_file(&path) {
3428            Ok(()) => Ok(true),
3429            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
3430            Err(error) => Err(ServiceError::Record {
3431                operation: "remove",
3432                path,
3433                detail: error.to_string(),
3434            }),
3435        }
3436    }
3437}
3438
3439// ---------------------------------------------------------------------------
3440// The last successful GitHub contact
3441// ---------------------------------------------------------------------------
3442
3443/// The heartbeat file's own schema version.
3444const CONTACT_SCHEMA_VERSION: u32 = 1;
3445
3446#[derive(Debug, Clone, Serialize, Deserialize)]
3447struct ContactRecord {
3448    schema_version: u32,
3449    last_success: DateTime<Utc>,
3450}
3451
3452/// Records that GitHub was reached successfully, for `service status` to
3453/// report.
3454///
3455/// # The contract, for `f3` and for the agent
3456///
3457/// Journey 5 step 4 requires `service status` to report *"the last successful
3458/// GitHub contact"*, and `service status` runs in the operator's terminal while
3459/// the daemon runs in a service. They share no memory, so the fact has to be on
3460/// disk, and this is the file. **The daemon calls this after each successful
3461/// GitHub call; nothing else writes it.**
3462///
3463/// It is a single timestamp and deliberately not a log: an operator asking this
3464/// question wants to know whether the agent is alive and reaching GitHub *now*,
3465/// and a value that is minutes old answers it. Writing it is a whole-file
3466/// replace through a temporary in the same directory, so a status command can
3467/// never read a half-written timestamp.
3468///
3469/// # Errors
3470///
3471/// [`ServiceError::Record`] when `state/` cannot be written.
3472pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3473    write_state_record(
3474        &contact_path(paths),
3475        &ContactRecord {
3476            schema_version: CONTACT_SCHEMA_VERSION,
3477            last_success: at,
3478        },
3479    )
3480}
3481
3482/// Reads the last successful GitHub contact, or reports that none was recorded.
3483///
3484/// `Ok(None)` is the honest answer on a host whose agent has never run, and it
3485/// is what `service status` prints rather than a zero timestamp.
3486///
3487/// # Errors
3488///
3489/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3490/// A malformed heartbeat is reported rather than treated as absence: absence
3491/// means *"the agent has never reached GitHub"*, and reporting a parse failure
3492/// as that would be a wrong answer to the question Journey 5 asks.
3493pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3494    Ok(read_state_record::<ContactRecord>(&contact_path(paths))?.map(|record| record.last_success))
3495}
3496
3497/// Where the heartbeat lives.
3498#[must_use]
3499pub fn contact_path(paths: &AppPaths) -> PathBuf {
3500    paths.state_dir().join(CONTACT_FILE)
3501}
3502
3503/// Writes a small TOML record under `state/`, through a temporary in the same
3504/// directory and a rename, so a status command never reads half of one.
3505pub(crate) fn write_state_record(path: &Path, record: &impl Serialize) -> Result<(), ServiceError> {
3506    let failed = |detail: String| ServiceError::Record {
3507        operation: "write",
3508        path: path.to_path_buf(),
3509        detail,
3510    };
3511    let text = toml::to_string_pretty(record).map_err(|error| failed(error.to_string()))?;
3512    let directory = path.parent().unwrap_or_else(|| Path::new("."));
3513    std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3514    let temporary = path.with_extension("toml.new");
3515    std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3516    std::fs::rename(&temporary, path).map_err(|error| failed(error.to_string()))
3517}
3518
3519/// Reads a record [`write_state_record`] wrote. `Ok(None)` when there is none;
3520/// one that exists and does not parse is an error, never absence.
3521pub(crate) fn read_state_record<T: serde::de::DeserializeOwned>(
3522    path: &Path,
3523) -> Result<Option<T>, ServiceError> {
3524    let failed = |detail: String| ServiceError::Record {
3525        operation: "read",
3526        path: path.to_path_buf(),
3527        detail,
3528    };
3529    let text = match std::fs::read_to_string(path) {
3530        Ok(text) => text,
3531        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3532        Err(error) => return Err(failed(error.to_string())),
3533    };
3534    toml::from_str(&text)
3535        .map(Some)
3536        .map_err(|error| failed(error.to_string()))
3537}
3538
3539/// Removes a record; one that is not there is already removed.
3540pub(crate) fn remove_state_record(path: &Path) -> Result<(), ServiceError> {
3541    match std::fs::remove_file(path) {
3542        Ok(()) => Ok(()),
3543        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3544        Err(error) => Err(ServiceError::Record {
3545            operation: "remove",
3546            path: path.to_path_buf(),
3547            detail: error.to_string(),
3548        }),
3549    }
3550}
3551
3552// ---------------------------------------------------------------------------
3553// The credential GitHub rejected
3554// ---------------------------------------------------------------------------
3555
3556#[derive(Debug, Serialize, Deserialize)]
3557#[serde(deny_unknown_fields)]
3558struct CredentialRejectionRecord {
3559    schema_version: u32,
3560    rejected_since: DateTime<Utc>,
3561}
3562
3563/// Records that GitHub rejected the daemon's credential, keeping the first
3564/// moment of an ongoing rejection.
3565///
3566/// # Why a file of its own
3567///
3568/// A rejected credential used to be visible only in the daemon's log: the
3569/// contact heartbeat simply stopped moving, `service status` still printed
3570/// `healthy`, and the TUI -- which authenticates with its own, perhaps freshly
3571/// renewed, copy -- showed nothing at all while the service started no runner
3572/// for hours. Separate from [`CONTACT_FILE`] so that an older build reading
3573/// that file is not handed a field it refuses.
3574///
3575/// # Errors
3576///
3577/// [`ServiceError::Record`] when `state/` cannot be written.
3578pub fn record_github_credential_rejected(
3579    paths: &AppPaths,
3580    at: DateTime<Utc>,
3581) -> Result<(), ServiceError> {
3582    if github_credential_rejected_since(paths)
3583        .ok()
3584        .flatten()
3585        .is_some()
3586    {
3587        return Ok(());
3588    }
3589    write_state_record(
3590        &credential_rejection_path(paths),
3591        &CredentialRejectionRecord {
3592            schema_version: CONTACT_SCHEMA_VERSION,
3593            rejected_since: at,
3594        },
3595    )
3596}
3597
3598/// Clears a recorded rejection once GitHub accepts the credential again.
3599///
3600/// # Errors
3601///
3602/// [`ServiceError::Record`] when the record exists and cannot be removed.
3603pub fn clear_github_credential_rejection(paths: &AppPaths) -> Result<(), ServiceError> {
3604    remove_state_record(&credential_rejection_path(paths))
3605}
3606
3607/// Since when GitHub has been rejecting the daemon's credential, if it is.
3608///
3609/// # Errors
3610///
3611/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3612pub fn github_credential_rejected_since(
3613    paths: &AppPaths,
3614) -> Result<Option<DateTime<Utc>>, ServiceError> {
3615    Ok(
3616        read_state_record::<CredentialRejectionRecord>(&credential_rejection_path(paths))?
3617            .map(|record| record.rejected_since),
3618    )
3619}
3620
3621/// Where the rejection record lives.
3622#[must_use]
3623pub fn credential_rejection_path(paths: &AppPaths) -> PathBuf {
3624    paths.state_dir().join(CREDENTIAL_REJECTION_FILE)
3625}
3626
3627#[derive(Debug, Serialize, Deserialize)]
3628#[serde(deny_unknown_fields)]
3629struct CredentialUnreadableRecord {
3630    schema_version: u32,
3631    unreadable_since: DateTime<Utc>,
3632}
3633
3634/// Records that the daemon could not read its stored credential, keeping the
3635/// first moment of an ongoing failure.
3636///
3637/// # Why a file of its own
3638///
3639/// A daemon that cannot read its credential exits, and the service manager
3640/// starts it again a few seconds later, for ever. `service status` reads the
3641/// registration, which is fine, and the heartbeat, which merely stops moving --
3642/// so it said `healthy` while a macOS host that had just been upgraded was
3643/// locked out of its own keychain item and started no runner. The reason was
3644/// only in the daemon's log. The daemon is the one process whose view of the
3645/// store matters, and it shares no memory with `service status`, so it says
3646/// so on disk; [`clear_credential_unreadable`] removes it as soon as a read
3647/// succeeds.
3648///
3649/// # Errors
3650///
3651/// [`ServiceError::Record`] when `state/` cannot be written.
3652pub fn record_credential_unreadable(
3653    paths: &AppPaths,
3654    at: DateTime<Utc>,
3655) -> Result<(), ServiceError> {
3656    if credential_unreadable_since(paths).ok().flatten().is_some() {
3657        return Ok(());
3658    }
3659    write_state_record(
3660        &credential_unreadable_path(paths),
3661        &CredentialUnreadableRecord {
3662            schema_version: CONTACT_SCHEMA_VERSION,
3663            unreadable_since: at,
3664        },
3665    )
3666}
3667
3668/// Clears a recorded failure once the daemon reads its credential again.
3669///
3670/// # Errors
3671///
3672/// [`ServiceError::Record`] when the record exists and cannot be removed.
3673pub fn clear_credential_unreadable(paths: &AppPaths) -> Result<(), ServiceError> {
3674    remove_state_record(&credential_unreadable_path(paths))
3675}
3676
3677/// Since when the daemon has been unable to read its stored credential, if it
3678/// has.
3679///
3680/// # Errors
3681///
3682/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3683pub fn credential_unreadable_since(
3684    paths: &AppPaths,
3685) -> Result<Option<DateTime<Utc>>, ServiceError> {
3686    Ok(
3687        read_state_record::<CredentialUnreadableRecord>(&credential_unreadable_path(paths))?
3688            .map(|record| record.unreadable_since),
3689    )
3690}
3691
3692/// Where the unreadable-credential record lives.
3693#[must_use]
3694pub fn credential_unreadable_path(paths: &AppPaths) -> PathBuf {
3695    paths.state_dir().join(CREDENTIAL_UNREADABLE_FILE)
3696}
3697
3698/// How to sign this host in again for `mode`: the exact command, in
3699/// backticks, and where to run it when that matters.
3700///
3701/// A machine-scoped store needs privilege: `sudo` on Unix, an elevated
3702/// terminal on Windows. A login-mode store on macOS is the login keychain,
3703/// which is unlocked for the desktop session only, so an SSH session cannot
3704/// write it.
3705#[must_use]
3706pub fn sign_in_instruction(mode: StartMode) -> String {
3707    let (command, place) = match mode {
3708        StartMode::Boot if cfg!(unix) => (sudo_command("auth login --start-at boot"), ""),
3709        StartMode::Boot => (
3710            "runner-manager auth login --start-at boot".to_owned(),
3711            " from an elevated terminal",
3712        ),
3713        StartMode::Login => (
3714            "runner-manager auth login --start-at login".to_owned(),
3715            MACOS_LOGIN_PLACE,
3716        ),
3717    };
3718    format!("`{command}`{place}")
3719}
3720
3721/// `sudo`, then this program, then `arguments`: a command an operator can
3722/// paste.
3723///
3724/// `sudo` searches only its own `secure_path`, which an npm or nvm
3725/// installation is not on, and the npm command is a Node shim that root's
3726/// `PATH` could not run either. So the command names this executable -- the
3727/// native binary, even behind that shim -- unless it already lives on
3728/// `secure_path`, where the bare name reads better and works.
3729#[must_use]
3730pub fn sudo_command(arguments: &str) -> String {
3731    // A test binary is not the product; its own path would only make every
3732    // remedy a test compares depend on where the build directory is.
3733    let executable = if cfg!(test) {
3734        None
3735    } else {
3736        std::env::current_exe().ok()
3737    };
3738    format!("sudo {} {arguments}", sudo_program(executable.as_deref()))
3739}
3740
3741/// How a `sudo` command names `executable`. See [`sudo_command`].
3742fn sudo_program(executable: Option<&Path>) -> String {
3743    /// Ubuntu's and Debian's `secure_path`, which other distributions'
3744    /// defaults are a subset of.
3745    const SECURE_PATH: [&str; 7] = [
3746        "/usr/local/sbin",
3747        "/usr/local/bin",
3748        "/usr/sbin",
3749        "/usr/bin",
3750        "/sbin",
3751        "/bin",
3752        "/snap/bin",
3753    ];
3754    let Some(executable) = executable else {
3755        return "runner-manager".to_owned();
3756    };
3757    if executable
3758        .parent()
3759        .is_some_and(|directory| SECURE_PATH.iter().any(|path| directory == Path::new(path)))
3760    {
3761        return "runner-manager".to_owned();
3762    }
3763    let text = executable.to_string_lossy();
3764    if text
3765        .chars()
3766        .all(|c| c.is_ascii_alphanumeric() || "/._-+@".contains(c))
3767    {
3768        text.into_owned()
3769    } else {
3770        format!("'{}'", text.replace('\'', "'\\''"))
3771    }
3772}
3773
3774/// Where a login-mode sign-in has to happen on macOS, as a phrase to follow
3775/// the command; nothing elsewhere.
3776pub const MACOS_LOGIN_PLACE: &str = if cfg!(target_os = "macos") {
3777    " in a Terminal in this Mac's desktop session (an SSH session cannot unlock the login \
3778     keychain)"
3779} else {
3780    ""
3781};
3782
3783// ---------------------------------------------------------------------------
3784// A definition written by an older build
3785// ---------------------------------------------------------------------------
3786
3787/// An installed service definition that differs from what this build renders
3788/// for the same recorded installation.
3789///
3790/// # Why this exists
3791///
3792/// An upgrade replaces the binary a registration runs and nothing else. A
3793/// systemd unit written by 0.4.7 still carried `LoadCredential=`, which hands
3794/// the daemon a copy of the credential frozen at service start, and lacked
3795/// write access to the credential store. Every release after the fix kept
3796/// running under that unit: the daemon could neither renew the token nor see
3797/// one renewed by anybody else, and stopped starting runners the moment the
3798/// frozen access token expired.
3799#[derive(Debug, Clone, PartialEq, Eq)]
3800pub struct DefinitionDrift {
3801    /// The definition file on disk.
3802    pub path: PathBuf,
3803    /// The build that wrote it, as its install record says.
3804    pub installed_by_version: String,
3805    /// What this build renders in its place.
3806    pub rendered: String,
3807    /// The start mode it was installed for.
3808    pub start_mode: StartMode,
3809    /// Which manager reads the definition.
3810    pub manager: DefinitionKind,
3811}
3812
3813/// Compares the installed definition against this build's rendering.
3814///
3815/// A systemd unit is compared byte for byte. A launchd plist is compared on
3816/// `ProcessType` alone (see [`launchd_definition_drift`]). `None` for every
3817/// other manager, for a definition that cannot be read, and for one that
3818/// already matches. The file is read through `controls`, so a double never
3819/// compares against a real host's definition.
3820fn definition_drift(
3821    controls: &dyn ControlFactory,
3822    identity: &ServiceIdentity,
3823    record: &InstallRecord,
3824) -> Option<DefinitionDrift> {
3825    let compare = match host_definition_kind(record.start_mode) {
3826        DefinitionKind::SystemdUnit => systemd_definition_drift,
3827        DefinitionKind::LaunchdPlist => launchd_definition_drift,
3828        DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => return None,
3829    };
3830    let path = record.definition_path.clone()?;
3831    let installed = controls.installed_definition(&path)?;
3832    compare(identity, record, path, &installed)
3833}
3834
3835/// A plist whose `ProcessType` is not [`LAUNCHD_PROCESS_TYPE`], with the plist
3836/// this build renders in its place.
3837///
3838/// Only that one key, unlike the systemd comparison. The daemon repairs this
3839/// drift by itself (`daemon.rs`, `converge_launchd_definition`), so a
3840/// whole-file comparison would make it rewrite and reload over every edit an
3841/// operator made to the plist. A plist that does carry the wrong value is
3842/// replaced whole, as `service install` would replace it.
3843fn launchd_definition_drift(
3844    identity: &ServiceIdentity,
3845    record: &InstallRecord,
3846    path: PathBuf,
3847    installed: &str,
3848) -> Option<DefinitionDrift> {
3849    (plist_string_value(installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE)).then(
3850        || DefinitionDrift {
3851            path,
3852            installed_by_version: record.installed_by_version.clone(),
3853            rendered: launchd_plist(&record.plan(identity.clone())),
3854            start_mode: record.start_mode,
3855            manager: DefinitionKind::LaunchdPlist,
3856        },
3857    )
3858}
3859
3860fn systemd_definition_drift(
3861    identity: &ServiceIdentity,
3862    record: &InstallRecord,
3863    path: PathBuf,
3864    installed: &str,
3865) -> Option<DefinitionDrift> {
3866    let rendered = systemd_unit(&record.plan(identity.clone()));
3867    (installed != rendered).then(|| DefinitionDrift {
3868        path,
3869        installed_by_version: record.installed_by_version.clone(),
3870        rendered,
3871        start_mode: record.start_mode,
3872        manager: DefinitionKind::SystemdUnit,
3873    })
3874}
3875
3876// ---------------------------------------------------------------------------
3877// The last runner-root refusal
3878// ---------------------------------------------------------------------------
3879
3880const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
3881
3882#[derive(Debug, Clone, Serialize, Deserialize)]
3883struct RootRefusalFile {
3884    schema_version: u32,
3885    /// Keyed by policy, because the fact is per-policy and not per-host.
3886    #[serde(default)]
3887    refusals: BTreeMap<String, RootRefusalEntry>,
3888}
3889
3890#[derive(Debug, Clone, Serialize, Deserialize)]
3891struct RootRefusalEntry {
3892    at: DateTime<Utc>,
3893    kind: String,
3894    root: String,
3895    detail: String,
3896}
3897
3898/// One policy's last runner-root refusal, as `service status` reports it.
3899#[derive(Debug, Clone, PartialEq, Eq)]
3900pub struct RunnerRootRefusal {
3901    /// The policy that placed no runner.
3902    pub policy: String,
3903    /// When it was last refused.
3904    pub at: DateTime<Utc>,
3905    /// The closed-vocabulary cause, as `RunnerRootError::kind` names it.
3906    pub kind: String,
3907    /// The root it was refused.
3908    pub root: String,
3909    /// The refusal in full, including the remediation. Not redacted: this file
3910    /// is read by a command in the operator's terminal, not by the log sink.
3911    pub detail: String,
3912}
3913
3914/// Records that one policy could not use its runner root.
3915///
3916/// # The contract, and why a file
3917///
3918/// A launch the runner root refuses fails before any attempt row exists, so the
3919/// journal cannot carry it and no screen can show it. The daemon's own log
3920/// cannot either: it redacts every field it does not allow-list and scrubs
3921/// anything path-shaped out of the ones it does, which is most of a
3922/// `RunnerRootError`. So the sentence an operator needs — the directory and the
3923/// remediation, verbatim — has nowhere to go, and the failure reads
3924/// `runner_start_failed reason=other` once per poll for as long as it lasts.
3925/// That is not hypothetical: it cost three hours.
3926///
3927/// **Keyed by policy, and that is load-bearing.** A host runs several policies
3928/// and they do not share a fate: `allocate_persistent_slot` tolerates an
3929/// unresolvable host default, so a repository with its own persistent root
3930/// places runners happily while an ephemeral policy on a withheld volume places
3931/// none. A single host-wide slot would have the working policy clear the broken
3932/// one's record on the same reconcile pass, and `service status` would report a
3933/// healthy host that had started zero runners for an entire target.
3934///
3935/// **The daemon writes this when a root refuses a launch and clears the policy's
3936/// entry when one succeeds; nothing else writes it.** It keeps the last refusal
3937/// per policy rather than a history, for the reason [`record_github_contact`] is
3938/// a single timestamp: the operator is asking whether runners can be placed
3939/// *now*.
3940///
3941/// Written whole through a temporary in the same directory, so a status command
3942/// cannot read half a record.
3943///
3944/// # Errors
3945///
3946/// [`ServiceError::Record`] when `state/` cannot be read or written.
3947pub fn record_runner_root_refusal(
3948    paths: &AppPaths,
3949    policy: &str,
3950    at: DateTime<Utc>,
3951    kind: &str,
3952    root: &str,
3953    detail: &str,
3954) -> Result<(), ServiceError> {
3955    let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
3956        schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
3957        refusals: BTreeMap::new(),
3958    });
3959    file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
3960    file.refusals.insert(
3961        policy.to_owned(),
3962        RootRefusalEntry {
3963            at,
3964            kind: kind.to_owned(),
3965            root: root.to_owned(),
3966            detail: detail.to_owned(),
3967        },
3968    );
3969    write_refusal_file(paths, &file)
3970}
3971
3972/// Clears one policy's entry, because that policy placed a runner.
3973///
3974/// Removes the file once the last entry goes, so a host that is working leaves
3975/// nothing behind for a later `service status` to report. A policy with no entry
3976/// is not an error: this is called on every successful placement, and almost
3977/// every one of those follows another success.
3978///
3979/// # Errors
3980///
3981/// [`ServiceError::Record`] when `state/` cannot be read or written.
3982pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
3983    let Some(mut file) = read_refusal_file(paths)? else {
3984        return Ok(());
3985    };
3986    if file.refusals.remove(policy).is_none() {
3987        return Ok(());
3988    }
3989    if file.refusals.is_empty() {
3990        let path = root_refusal_path(paths);
3991        return match std::fs::remove_file(&path) {
3992            Ok(()) => Ok(()),
3993            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3994            Err(error) => Err(ServiceError::Record {
3995                operation: "remove",
3996                path,
3997                detail: error.to_string(),
3998            }),
3999        };
4000    }
4001    write_refusal_file(paths, &file)
4002}
4003
4004/// Every policy whose runner root refused it and has not since succeeded.
4005///
4006/// Ordered by policy, so two runs of `service status` on an unchanged host
4007/// print the same thing in the same order.
4008///
4009/// # Errors
4010///
4011/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
4012/// Reported rather than treated as absence, for the reason
4013/// [`last_github_contact`] gives: absence means "every policy is placing
4014/// runners", and answering a parse failure with that would be a wrong answer.
4015pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
4016    Ok(read_refusal_file(paths)?
4017        .map(|file| {
4018            file.refusals
4019                .into_iter()
4020                .map(|(policy, entry)| RunnerRootRefusal {
4021                    policy,
4022                    at: entry.at,
4023                    kind: entry.kind,
4024                    root: entry.root,
4025                    detail: entry.detail,
4026                })
4027                .collect()
4028        })
4029        .unwrap_or_default())
4030}
4031
4032fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
4033    read_state_record(&root_refusal_path(paths))
4034}
4035
4036fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
4037    write_state_record(&root_refusal_path(paths), file)
4038}
4039
4040/// Where the refusals live.
4041#[must_use]
4042pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
4043    paths.state_dir().join(ROOT_REFUSAL_FILE)
4044}
4045
4046// ---------------------------------------------------------------------------
4047// The recorded binary path
4048// ---------------------------------------------------------------------------
4049
4050/// What became of the absolute path `install` recorded.
4051///
4052/// `05-infrastructure.md` item 6 in one type. Three of the four variants are
4053/// errors, and `service status` reports them as errors rather than as health —
4054/// which is the whole point, because the npm case produces a service that looks
4055/// installed, is registered, and cannot start.
4056#[derive(Debug, Clone, PartialEq, Eq)]
4057pub enum BinaryPath {
4058    /// The recorded path is a file, and the service manager still starts it.
4059    Current {
4060        /// The path.
4061        path: PathBuf,
4062    },
4063    /// **Nothing is at the recorded path.**
4064    ///
4065    /// This is the npm upgrade: `npm i -g @ivan-murzak/runner-manager` puts the binary under
4066    /// the active Node installation's global prefix, and switching Node versions
4067    /// with `nvm`, `fnm`, or `volta` moves that prefix. The service is still
4068    /// registered, still set to start at boot, and starts nothing.
4069    Missing {
4070        /// The path the record names.
4071        recorded: PathBuf,
4072    },
4073    /// Something is at the recorded path, but it is not a file the service
4074    /// manager could start.
4075    NotExecutable {
4076        /// The path the record names.
4077        recorded: PathBuf,
4078        /// What is there instead.
4079        detail: String,
4080    },
4081    /// The record and the service manager name different binaries.
4082    Diverged {
4083        /// What the record says.
4084        recorded: PathBuf,
4085        /// What the service manager is registered to start.
4086        registered: PathBuf,
4087    },
4088}
4089
4090impl BinaryPath {
4091    /// Whether this is a state `service status` must report as an error.
4092    #[must_use]
4093    pub const fn is_error(&self) -> bool {
4094        !matches!(self, Self::Current { .. })
4095    }
4096
4097    /// The path the record names, whatever state it is in.
4098    #[must_use]
4099    pub fn recorded(&self) -> &Path {
4100        match self {
4101            Self::Current { path } => path,
4102            Self::Missing { recorded }
4103            | Self::NotExecutable { recorded, .. }
4104            | Self::Diverged { recorded, .. } => recorded,
4105        }
4106    }
4107}
4108
4109impl fmt::Display for BinaryPath {
4110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4111        match self {
4112            Self::Current { path } => write!(f, "{}", path.display()),
4113            Self::Missing { recorded } => write!(
4114                f,
4115                "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
4116                 package manager that moved the binary is the usual cause; an `npm i -g` \
4117                 installation moves with the active Node version. Run `service install` again \
4118                 from the binary that is now installed.",
4119                recorded.display()
4120            ),
4121            Self::NotExecutable { recorded, detail } => write!(
4122                f,
4123                "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
4124                 from the installed binary.",
4125                recorded.display()
4126            ),
4127            Self::Diverged {
4128                recorded,
4129                registered,
4130            } => write!(
4131                f,
4132                "{} -- STALE: the service manager is registered to start {} instead. Something \
4133                 has edited the registration since it was installed. Run `service uninstall` and \
4134                 `service install`; neither touches configuration, secrets, or the cache.",
4135                recorded.display(),
4136                registered.display()
4137            ),
4138        }
4139    }
4140}
4141
4142/// Decides what became of a recorded path.
4143///
4144/// `registered` is what the service manager says it will start, when the
4145/// platform can be asked. Order matters and is deliberate: **absence is checked
4146/// first**, because a missing binary is the failure item 6 exists for and an
4147/// operator hearing "the registration disagrees with the record" about a file
4148/// that is not there would be sent to the wrong problem.
4149#[must_use]
4150pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
4151    match std::fs::metadata(recorded) {
4152        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
4153            return BinaryPath::Missing {
4154                recorded: recorded.to_path_buf(),
4155            };
4156        }
4157        Err(error) => {
4158            return BinaryPath::NotExecutable {
4159                recorded: recorded.to_path_buf(),
4160                detail: format!("it cannot be inspected ({error})"),
4161            };
4162        }
4163        Ok(metadata) if !metadata.is_file() => {
4164            return BinaryPath::NotExecutable {
4165                recorded: recorded.to_path_buf(),
4166                detail: "what is there is not a file".to_string(),
4167            };
4168        }
4169        Ok(_) => {}
4170    }
4171    if let Some(registered) = registered
4172        && !same_path_text(&recorded.to_string_lossy(), &registered.to_string_lossy())
4173    {
4174        return BinaryPath::Diverged {
4175            recorded: recorded.to_path_buf(),
4176            registered: registered.to_path_buf(),
4177        };
4178    }
4179    BinaryPath::Current {
4180        path: recorded.to_path_buf(),
4181    }
4182}
4183
4184// ---------------------------------------------------------------------------
4185// The control seam
4186// ---------------------------------------------------------------------------
4187
4188/// What a service manager says about a registration it holds.
4189#[derive(Debug, Clone, PartialEq, Eq)]
4190pub struct Registration {
4191    /// Which manager holds it, which is also what decides the start mode:
4192    /// a Windows service is a boot registration and a Task Scheduler task is a
4193    /// login one; a LaunchDaemon and a system unit are boot, a LaunchAgent and
4194    /// a user unit are login.
4195    pub manager: DefinitionKind,
4196    /// The start mode the *domain* it was found in implies.
4197    pub start_mode: StartMode,
4198    /// The full command line, as the manager stores it.
4199    pub command_line: String,
4200    /// The account it runs under, spelled as [`ServiceAccount`] spells it, when
4201    /// the manager names a real account.
4202    ///
4203    /// `None` where the manager reports only a placeholder: a login
4204    /// registration that runs as whoever signs in, or a Task Scheduler `UserId`
4205    /// that is a raw SID. Status then reports the install record's account.
4206    pub account: Option<String>,
4207    /// Whether it is running now.
4208    pub running: bool,
4209    /// Whether it will start by itself.
4210    ///
4211    /// Distinct from [`Registration::start_mode`]: a Windows service can be
4212    /// registered in the boot domain and still be set to `demand` start, which
4213    /// is a service that exists, looks installed, and does not come back after
4214    /// a reboot.
4215    pub starts_automatically: bool,
4216    /// The restart-on-failure delay the manager reports, when it reports one.
4217    ///
4218    /// The *delay* rather than the whole [`RestartPolicy`], because that is the
4219    /// half every one of the four managers can be asked for. The failure-count
4220    /// reset window has no representation at all in Task Scheduler or launchd,
4221    /// so a `RestartPolicy` read back from them would carry one number the
4222    /// manager reported and one this module invented — and a comparison against
4223    /// an invented number is a comparison that cannot fail.
4224    pub restart_delay: Option<Duration>,
4225}
4226
4227impl Registration {
4228    /// The executable the manager will start, parsed out of the command line.
4229    #[must_use]
4230    pub fn binary(&self) -> Option<PathBuf> {
4231        executable_from_command_line(&self.command_line)
4232    }
4233}
4234
4235/// One platform's service manager, for one start-mode domain.
4236///
4237/// Every method takes the identity rather than storing it, so one control can
4238/// answer about the product's registration and about a test fixture's without
4239/// either being able to reach the other by accident.
4240pub trait ServiceControl: fmt::Debug {
4241    /// Which manager this is.
4242    fn manager(&self) -> DefinitionKind;
4243
4244    /// Registers the plan, returning the definition that was applied.
4245    ///
4246    /// # Errors
4247    ///
4248    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`].
4249    fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
4250
4251    /// Deregisters. Returns whether there was a registration to remove.
4252    ///
4253    /// **Removes the registration and nothing else.** No implementation of this
4254    /// method may delete a directory, a database, a secret, or a cache;
4255    /// `05-infrastructure.md` item 5 is a property of every backend, not a
4256    /// check somewhere above them.
4257    ///
4258    /// # Errors
4259    ///
4260    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`].
4261    fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
4262
4263    /// What the manager knows about this registration, if anything.
4264    ///
4265    /// # Errors
4266    ///
4267    /// [`ServiceError::Control`].
4268    fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
4269
4270    /// Starts it now, without waiting for the next boot or logon.
4271    ///
4272    /// # Errors
4273    ///
4274    /// [`ServiceError::Control`], [`ServiceError::NeedsElevation`], or
4275    /// [`ServiceError::NotInstalled`].
4276    fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
4277
4278    /// Stops it. Returns whether it was running.
4279    ///
4280    /// # Errors
4281    ///
4282    /// As [`ServiceControl::start`].
4283    fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
4284}
4285
4286/// Chooses the control for a start mode.
4287///
4288/// The indirection is what makes [`ServiceOperations`] testable: production
4289/// hands it [`HostControls`], every test in this file hands it
4290/// [`RecordingControls`], and neither knows the difference.
4291pub trait ControlFactory: fmt::Debug + Send + Sync {
4292    /// The control for one start-mode domain.
4293    ///
4294    /// # Errors
4295    ///
4296    /// [`ServiceError::Control`] when this host has no manager for that domain.
4297    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
4298
4299    /// The text of an installed definition file, for drift detection. `None`
4300    /// when there is none to read. The default reads nothing, so a double
4301    /// never reports the real host's unit as its own.
4302    fn installed_definition(&self, _path: &Path) -> Option<String> {
4303        None
4304    }
4305
4306    /// Whether this Mac signs somebody in after a restart, and whether
4307    /// FileVault holds it at the unlock screen. `None` where the platform has
4308    /// no such probe; the default probes nothing, for the same reason as
4309    /// [`Self::installed_definition`].
4310    fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
4311        None
4312    }
4313}
4314
4315/// The real service managers of the host this binary was built for.
4316#[derive(Debug, Clone, Copy, Default)]
4317pub struct HostControls;
4318
4319// ---------------------------------------------------------------------------
4320// The operations
4321// ---------------------------------------------------------------------------
4322
4323/// What `install` did.
4324#[derive(Debug, Clone)]
4325pub struct Installed {
4326    /// The plan that was applied, including the resolved absolute binary path.
4327    pub plan: InstallPlan,
4328    /// The definition the platform was given.
4329    pub definition: ServiceDefinition,
4330    /// The record that was written.
4331    pub record: InstallRecord,
4332    /// What the definition grants, measured against the requirement.
4333    pub review: PrivilegeReview,
4334    /// What was done to the runner root the registration will run jobs under.
4335    ///
4336    /// Named trustees rather than SIDs, so printing it adds no identity to the
4337    /// output. See [`crate::runner_root_access`].
4338    pub runner_root: RootAccessSummary,
4339    /// Whether this replaced a registration that was already there, rather than
4340    /// making a new one.
4341    ///
4342    /// Reported rather than glossed over: "Service installed" on a host that
4343    /// had one already reads as though nothing had been taken away and put
4344    /// back, and what was taken away and put back is the thing an operator
4345    /// watching a running agent wants to know about.
4346    pub replaced_existing: bool,
4347}
4348
4349/// What `uninstall` did — and, as importantly, what it did not.
4350#[derive(Debug, Clone, PartialEq, Eq)]
4351pub struct Uninstalled {
4352    /// Whether a registration was removed.
4353    pub removed_registration: bool,
4354    /// Whether the install record was removed.
4355    pub removed_record: bool,
4356    /// Whether a definition file was removed, and which.
4357    pub removed_definition: Option<PathBuf>,
4358    /// The directories that were **left exactly as they were**.
4359    ///
4360    /// `05-infrastructure.md` item 5 stated as a value rather than as a
4361    /// promise: `service uninstall` prints this list, so an operator can see
4362    /// that the configuration, the SQLite database, the stored token and the
4363    /// runner package cache are all still there.
4364    pub preserved: Vec<PathBuf>,
4365}
4366
4367impl fmt::Display for Uninstalled {
4368    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4369        if self.removed_registration {
4370            writeln!(f, "The service registration was removed.")?;
4371        } else {
4372            writeln!(f, "There was no service registration to remove.")?;
4373        }
4374        writeln!(f, "Nothing else was deleted. These are untouched:")?;
4375        for path in &self.preserved {
4376            writeln!(f, "  {}", path.display())?;
4377        }
4378        write!(
4379            f,
4380            "The stored GitHub token is untouched too; `auth logout` is what purges it."
4381        )
4382    }
4383}
4384
4385/// What `set_start_mode` did.
4386#[derive(Debug, Clone, PartialEq, Eq)]
4387pub struct StartModeChange {
4388    /// What it was.
4389    pub from: StartMode,
4390    /// What it is now.
4391    pub to: StartMode,
4392    /// `false` when the registration was already in the requested mode.
4393    pub changed: bool,
4394    /// The secret store the new mode obliges, so a caller can tell an operator
4395    /// whether the token has to move too.
4396    pub store_scope: crate::secrets::SecretScope,
4397    /// What the mode change did to the runner root's access control.
4398    ///
4399    /// A mode change moves the account the daemon runs as, and the runner root
4400    /// admits that account by name — so the two move together or the new
4401    /// registration cannot write its own workspaces.
4402    pub runner_root: RootAccessSummary,
4403}
4404
4405impl fmt::Display for StartModeChange {
4406    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4407        if !self.changed {
4408            return write!(f, "The service already starts at {}.", self.to);
4409        }
4410        write!(
4411            f,
4412            "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
4413             if the token was stored under the other scope, run `auth login` again. {}",
4414            self.to, self.from, self.store_scope, self.runner_root
4415        )
4416    }
4417}
4418
4419/// Install, uninstall, inspect, and switch start mode.
4420///
4421/// This is the whole of the library contract `f3` builds `service install`,
4422/// `service uninstall` and `service status` on. Nothing here prints; every
4423/// operation returns a value with a [`fmt::Display`] a command can write, so the
4424/// same facts are available to the TUI and to a JSON document without being
4425/// re-derived from text.
4426#[derive(Debug, Clone)]
4427pub struct ServiceOperations {
4428    paths: AppPaths,
4429    identity: ServiceIdentity,
4430    controls: std::sync::Arc<dyn ControlFactory>,
4431    runner_root: Option<LocalAbsolutePath>,
4432}
4433
4434impl ServiceOperations {
4435    /// Operates on this host's real service managers.
4436    #[must_use]
4437    pub fn on_this_host(paths: AppPaths) -> Self {
4438        Self::with_controls(
4439            paths,
4440            ServiceIdentity::product(),
4441            std::sync::Arc::new(HostControls),
4442        )
4443    }
4444
4445    /// Operates on the controls a caller supplies.
4446    ///
4447    /// Both other arguments are explicit for the same reason: the privileged
4448    /// installer tests register a real service under a
4449    /// [`ServiceIdentity::fixture`] name, against a disposable
4450    /// [`AppPaths::rooted_at`] tree, and must not be able to touch an operator's
4451    /// installation even by mistake.
4452    #[must_use]
4453    pub fn with_controls(
4454        paths: AppPaths,
4455        identity: ServiceIdentity,
4456        controls: std::sync::Arc<dyn ControlFactory>,
4457    ) -> Self {
4458        Self {
4459            paths,
4460            identity,
4461            controls,
4462            runner_root: None,
4463        }
4464    }
4465
4466    /// Points runner-root preparation at a directory the caller owns, for the
4467    /// **current account**.
4468    ///
4469    /// **Only a [`ServiceIdentity::fixture`] registration is allowed to move
4470    /// it, and the guard is here rather than at the call site.** A product
4471    /// registration ignores the override entirely and resolves
4472    /// [`crate::runner_root::default_runner_root`] itself, which is what keeps
4473    /// "custom roots are never re-ACLed" true no matter what a caller passes.
4474    ///
4475    /// It exists because the directory the product uses is
4476    /// `%SystemDrive%\rman`, and a smoke test that created and re-permissioned
4477    /// *that* would be editing the machine it runs on from outside its own
4478    /// fixture. So a fixture aims this at a temporary directory it created and
4479    /// will delete — and "and will delete" is why the override is always
4480    /// reconciled for the calling account rather than for the account the start
4481    /// mode obliges. A boot-mode root admits `SY` and `BA` only, which an
4482    /// ordinary filtered token is neither, so a test that supplied one could
4483    /// not then inspect or remove its own temporary directory.
4484    #[must_use]
4485    pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
4486        self.runner_root = Some(root);
4487        self
4488    }
4489
4490    /// The directories this operates against.
4491    #[must_use]
4492    pub const fn paths(&self) -> &AppPaths {
4493        &self.paths
4494    }
4495
4496    /// The registration this operates on.
4497    #[must_use]
4498    pub const fn identity(&self) -> &ServiceIdentity {
4499        &self.identity
4500    }
4501
4502    /// Registers `daemon run` with the operating system.
4503    ///
4504    /// The order is the contract, and each step is a requirement:
4505    ///
4506    /// 1. the four directories are created, because item 2 is about writing them;
4507    /// 2. **the single-instance lock is taken** — item 1. It is held for the
4508    ///    whole of the install rather than probed and released, so a daemon that
4509    ///    starts halfway through cannot end up racing a registration;
4510    /// 3. neither start-mode domain already holds a registration — item 6's
4511    ///    record would otherwise describe one of two;
4512    /// 4. the binary path is resolved and confirmed to be a file — item 6;
4513    /// 5. the platform registers it;
4514    /// 6. the record is written.
4515    ///
4516    /// # Errors
4517    ///
4518    /// [`ServiceError::LockHeld`] when an agent is already running,
4519    /// [`ServiceError::AlreadyInstalled`], [`ServiceError::BinaryMissing`], and
4520    /// whatever the platform reports.
4521    pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
4522        self.paths
4523            .create_all()
4524            .map_err(|source| ServiceError::Paths {
4525                source: Box::new(source),
4526            })?;
4527
4528        // Item 1. The guard lives until the end of this function; dropping it is
4529        // the release, so an early return releases it too.
4530        let _guard = self.refuse_while_an_agent_runs()?;
4531        let adopted = self.adopt_boot_installation(request.start_mode())?;
4532        // Replacing applies to this account's own registration only: over
4533        // another installation's it would re-register that service against
4534        // this account's directories.
4535        if !adopted {
4536            self.refuse_foreign("replace")?;
4537        }
4538
4539        // --------------------------------------------------------------------
4540        // AN INSTALL OVER THE SAME START MODE REPLACES; IT DOES NOT REFUSE.
4541        // --------------------------------------------------------------------
4542        // This used to refuse any existing registration, and that was wrong in
4543        // a way that took a real host down.
4544        //
4545        // `service install` is *also* how an operator moves to a new version by
4546        // hand — `install_owned_copy` says so, and the `.old` rename it does
4547        // exists precisely so that reinstalling over a **running** service
4548        // works. So the caller replaces the copy the service runs and *then*
4549        // asks to register it. Refusing at that point does not undo the swap:
4550        // the daemon is left running a binary nobody registered, the command
4551        // reports failure, and on macOS the new binary is a program the stored
4552        // credential's keychain grant does not name. Watched on 0.1.17: the
4553        // copy under `state/bin` was replaced at 00:16, `service install` said
4554        // "already registered", and the daemon crash-looped on `-25293` from
4555        // then on.
4556        //
4557        // Registering the same service twice is not what an operator is asking
4558        // for here and is not what they get: the platform is asked to drop the
4559        // registration and take it again, which is a deregister followed by a
4560        // register because that is the only thing launchd, systemd and the SCM
4561        // all support — `launchctl bootstrap` refuses a label already loaded.
4562        //
4563        // The *other* mode is still refused. Moving between boot and login
4564        // moves the registration between two service managers and changes the
4565        // account and the secret store with it; `set_start_mode` does that,
4566        // carefully, and an install is not the place for it.
4567        let (replacing, previous_was_running) = match self.find_registration()? {
4568            Some((existing, registration)) if existing == request.start_mode() => {
4569                (true, registration.running)
4570            }
4571            Some((existing, _)) => {
4572                return Err(ServiceError::AlreadyInstalled {
4573                    name: self.identity.name().to_string(),
4574                    existing,
4575                    requested: request.start_mode(),
4576                });
4577            }
4578            None => (false, false),
4579        };
4580
4581        let plan = InstallPlan::resolve(
4582            self.identity.clone(),
4583            request,
4584            ServiceDirectories::of(&self.paths),
4585        )?;
4586
4587        // Resolved before the runner root, and used after it: every step from
4588        // here on has to be able to undo the directory, and a `?` between the
4589        // preparation and the first fallible use would leave one behind with
4590        // nothing to say so.
4591        let control = self.controls.control(plan.start_mode())?;
4592
4593        // Item 8, and the one step that can refuse an otherwise valid install.
4594        // Before the platform is asked to register anything, because a
4595        // registration whose workspaces would land in a directory ordinary local
4596        // users can write is one that should never have existed — and because
4597        // undoing a directory is cheaper than undoing a service.
4598        let root = self.prepare_runner_root(plan.start_mode())?;
4599
4600        // Read before the registration is dropped, so that a replace which then
4601        // fails to take can put back what was there. A record this account
4602        // cannot read leaves nothing to reinstate, which is reported rather
4603        // than pretended: see `reinstate`.
4604        let previous = if replacing {
4605            InstallRecord::read(&self.paths).ok().flatten()
4606        } else {
4607            None
4608        };
4609        if replacing && let Err(cause) = control.uninstall(&self.identity) {
4610            return Err(undo_runner_root(&root, "install", &self.identity, cause));
4611        }
4612
4613        let definition = match control.install(&plan) {
4614            Ok(definition) => definition,
4615            Err(cause) => {
4616                // Nothing was deregistered on a first install, so there is
4617                // nothing to put back and `reinstate` says so with `Ok(())`.
4618                let restored =
4619                    self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running);
4620                return Err(rolled_back(
4621                    retained_runner_root(&root),
4622                    restored,
4623                    "install",
4624                    &self.identity,
4625                    cause,
4626                ));
4627            }
4628        };
4629        let review = review_least_privilege(&definition, &plan);
4630        let record = InstallRecord::of(&plan, &definition, Utc::now());
4631        if let Err(cause) = record.write(&self.paths) {
4632            let rollback = control
4633                .uninstall(&self.identity)
4634                .map(|_| ())
4635                .and_then(|()| {
4636                    self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4637                });
4638            return Err(rolled_back(
4639                retained_runner_root(&root),
4640                rollback,
4641                "install",
4642                &self.identity,
4643                cause,
4644            ));
4645        }
4646
4647        // Registration is not the postcondition of an install: a service the
4648        // current session still has to start by hand is only half-installed.
4649        // This matters most for a logon-triggered Windows task because its
4650        // trigger has already passed by the time an operator reinstalls it.
4651        // Start through the same control that registered the definition and
4652        // roll the whole replacement back if the manager refuses it.
4653        if let Err(cause) = control.start(&self.identity) {
4654            let remove_new = control.uninstall(&self.identity).map(|_| ());
4655            let restore_registration = remove_new.and_then(|()| {
4656                self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4657            });
4658            let restore_record = restore_registration.and_then(|()| match &previous {
4659                Some(previous) => previous.write(&self.paths),
4660                None => InstallRecord::remove(&self.paths).map(|_| ()),
4661            });
4662            return Err(rolled_back(
4663                retained_runner_root(&root),
4664                restore_record,
4665                "install and start",
4666                &self.identity,
4667                cause,
4668            ));
4669        }
4670        Ok(Installed {
4671            plan,
4672            definition,
4673            record,
4674            review,
4675            runner_root: root.summary().clone(),
4676            replaced_existing: replacing,
4677        })
4678    }
4679
4680    /// Puts back a registration this call deregistered in order to replace it.
4681    ///
4682    /// `Ok(())` when there was nothing to put back, which is the ordinary case:
4683    /// a first install deregisters nothing. When there *was* a record, the plan
4684    /// is rebuilt from it rather than from the request, because the request is
4685    /// the one that just failed.
4686    ///
4687    /// Best effort by construction — the caller passes the result to
4688    /// [`rolled_back`], which reports a failed rollback alongside the original
4689    /// cause rather than replacing it.
4690    fn reinstate(
4691        &self,
4692        control: &dyn ServiceControl,
4693        previous: Option<&InstallRecord>,
4694        was_running: bool,
4695    ) -> Result<(), ServiceError> {
4696        let Some(record) = previous else {
4697            return Ok(());
4698        };
4699        control.install(&record.plan(self.identity.clone()))?;
4700        if was_running {
4701            control.start(&self.identity)?;
4702        }
4703        Ok(())
4704    }
4705
4706    /// Deregisters, and deletes nothing else.
4707    ///
4708    /// # Errors
4709    ///
4710    /// Whatever the platform reports. A missing registration is **not** an
4711    /// error: `service uninstall` on a host that has none should say so and
4712    /// exit cleanly, because an operator running it twice has not made a
4713    /// mistake.
4714    pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
4715        let record = InstallRecord::read(&self.paths).ok().flatten();
4716        let removed_definition = record
4717            .as_ref()
4718            .and_then(|record| record.definition_path.clone());
4719
4720        let mut removed_registration = false;
4721        // Both domains, not only the recorded one. A record can be lost while a
4722        // registration survives, and leaving a registration behind because a
4723        // TOML file was deleted is exactly the state `uninstall` exists to end.
4724        //
4725        // Except another installation's: it is skipped, and when it is all
4726        // there is, the uninstall is refused and this account's record kept.
4727        let mut foreign = None;
4728        for mode in [StartMode::Boot, StartMode::Login] {
4729            let control = self.controls.control(mode)?;
4730            if let Some(directories) = control
4731                .query(&self.identity)
4732                .ok()
4733                .flatten()
4734                .and_then(|registration| self.foreign_directories(&registration))
4735            {
4736                foreign = Some(directories);
4737                continue;
4738            }
4739            if control.uninstall(&self.identity)? {
4740                removed_registration = true;
4741            }
4742        }
4743        if !removed_registration && let Some(directories) = foreign {
4744            return Err(self.foreign_refusal("uninstall", directories));
4745        }
4746        let removed_record = InstallRecord::remove(&self.paths)?;
4747        Ok(Uninstalled {
4748            removed_registration,
4749            removed_record,
4750            removed_definition: removed_definition.filter(|_| removed_registration),
4751            preserved: self
4752                .paths
4753                .all()
4754                .iter()
4755                .map(|(_, path)| (*path).to_path_buf())
4756                .collect(),
4757        })
4758    }
4759
4760    /// Switches between `boot` and `login` **without re-resolving anything**.
4761    ///
4762    /// `05-infrastructure.md` item 7. Everything the new registration carries —
4763    /// the absolute binary path, the arguments, the restart policy, the four
4764    /// directories — comes from the existing record, so the operator is not
4765    /// asked to reinstall the product and a binary that has since moved is not
4766    /// silently swapped for whichever one happens to be running this command.
4767    ///
4768    /// On Windows the registration necessarily moves between two different
4769    /// Windows facilities, because Windows has no service that starts at logon;
4770    /// see this module's documentation. That is still not a reinstall: no file
4771    /// is downloaded, replaced, or re-resolved.
4772    ///
4773    /// The caller must persist the new mode onto this host's `Host` record so
4774    /// that `host show` reports it — [`StartModeChange::to`] is the value, and
4775    /// `f1` already emits `host.service_start_mode` from that field.
4776    ///
4777    /// # Errors
4778    ///
4779    /// [`ServiceError::NotInstalled`] when there is no record to switch, and
4780    /// whatever the platform reports.
4781    pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
4782        let Some(record) = InstallRecord::read(&self.paths)? else {
4783            return Err(ServiceError::NotInstalled {
4784                name: self.identity.name().to_string(),
4785                operation: "switch the start mode of",
4786            });
4787        };
4788        let from = record.start_mode;
4789        if from == to {
4790            // Nothing moves, so nothing about the root's access control has to.
4791            // Reconciling it here would turn a no-op command into one that can
4792            // fail on a permission it does not need.
4793            return Ok(StartModeChange {
4794                from,
4795                to,
4796                changed: false,
4797                store_scope: crate::secrets::SecretScope::for_start_mode(to),
4798                runner_root: RootAccessSummary::NotApplicable,
4799            });
4800        }
4801
4802        // A real switch only: a no-op touches no service manager at all. Over
4803        // another installation's registration it would move that service.
4804        self.refuse_foreign("switch the start mode of")?;
4805
4806        #[cfg(windows)]
4807        let arguments = {
4808            let mut arguments = record.arguments.clone();
4809            arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
4810            if to == StartMode::Boot {
4811                arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
4812            }
4813            arguments
4814        };
4815        #[cfg(not(windows))]
4816        let arguments = record.arguments.clone();
4817        let plan = InstallPlan::unchecked(
4818            self.identity.clone(),
4819            to,
4820            record.binary.clone(),
4821            record.directories.clone(),
4822        )
4823        .with_arguments(arguments)
4824        .with_restart(record.restart());
4825        let plan = if record.starts_on_demand {
4826            plan.started_on_demand()
4827        } else {
4828            plan
4829        };
4830        let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
4831            Ok(store) => plan.with_secret_guard(store.guard()),
4832            Err(_) => plan,
4833        };
4834
4835        // Install the target domain before touching the live one. This makes a
4836        // failed target install a no-op from the operator's point of view and,
4837        // unlike uninstall-first ordering, never trades a working service for
4838        // an error message. Resolved before the runner root and used after it,
4839        // so that no `?` sits between preparing the directory and the first
4840        // step that knows how to undo it.
4841        let target = self.controls.control(to)?;
4842        // The domain being left, resolved here rather than where it is used for
4843        // the same reason: the only step that removes it runs after the runner
4844        // root has been prepared, and a `?` there would abandon the target
4845        // registration, the record and the directory without a word.
4846        let previous = self.controls.control(from)?;
4847
4848        // The account changes with the mode, and so must the account the runner
4849        // root admits: `04-security-recovery.md` requires the selected identity
4850        // to be *reconciled* when service mode changes, which means adding the
4851        // operator's on the way to login and dropping it again on the way back.
4852        // Before the target install, for the same reason `install` does it
4853        // first — a root that cannot be made safe must not produce a working
4854        // registration.
4855        let root = self.prepare_runner_root(to)?;
4856
4857        let definition = match target.install(&plan) {
4858            Ok(definition) => definition,
4859            Err(cause) => {
4860                return Err(undo_runner_root(
4861                    &root,
4862                    "switch start mode",
4863                    &self.identity,
4864                    cause,
4865                ));
4866            }
4867        };
4868        let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
4869        if let Err(cause) = next_record.write(&self.paths) {
4870            return Err(rolled_back(
4871                retained_runner_root(&root),
4872                target.uninstall(&self.identity),
4873                "switch start mode",
4874                &self.identity,
4875                cause,
4876            ));
4877        }
4878
4879        // Only after the target registration and its durable record exist is
4880        // it safe to remove the old domain. If that last step fails, remove the
4881        // target and put the old record back so status and reality agree.
4882        if let Err(cause) = previous.uninstall(&self.identity) {
4883            let target_rollback = target.uninstall(&self.identity);
4884            let record_rollback = record.write(&self.paths);
4885            return Err(rolled_back(
4886                retained_runner_root(&root),
4887                target_rollback.and(record_rollback),
4888                "switch start mode",
4889                &self.identity,
4890                cause,
4891            ));
4892        }
4893        Ok(StartModeChange {
4894            from,
4895            to,
4896            changed: true,
4897            store_scope: crate::secrets::SecretScope::for_start_mode(to),
4898            runner_root: root.summary().clone(),
4899        })
4900    }
4901
4902    /// Starts the registration now.
4903    ///
4904    /// # Errors
4905    ///
4906    /// [`ServiceError::NotInstalled`], or whatever the platform reports.
4907    pub fn start(&self) -> Result<(), ServiceError> {
4908        let mode = self.own_registration_mode("start")?;
4909        self.controls.control(mode)?.start(&self.identity)
4910    }
4911
4912    /// The domain holding this account's own registration, for `operation`.
4913    ///
4914    /// # Errors
4915    ///
4916    /// [`ServiceError::NotInstalled`] when there is none, and
4917    /// [`ServiceError::ForeignInstallation`] when the only one is another
4918    /// installation's.
4919    fn own_registration_mode(&self, operation: &'static str) -> Result<StartMode, ServiceError> {
4920        match self.registration_for_status()? {
4921            None => Err(ServiceError::NotInstalled {
4922                name: self.identity.name().to_string(),
4923                operation,
4924            }),
4925            Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
4926            Some((registration, None)) => Ok(registration.start_mode),
4927        }
4928    }
4929
4930    /// Stops the registration. Returns whether it was running.
4931    ///
4932    /// # Errors
4933    ///
4934    /// As [`ServiceOperations::start`].
4935    pub fn stop(&self) -> Result<bool, ServiceError> {
4936        let mode = self.own_registration_mode("stop")?;
4937        self.controls.control(mode)?.stop(&self.identity)
4938    }
4939
4940    /// Everything Journey 5 step 4 asks `service status` to report.
4941    ///
4942    /// # Errors
4943    ///
4944    /// [`ServiceError::Record`] when local state cannot be read, and whatever
4945    /// the platform reports. A *stale binary path* is deliberately **not** an
4946    /// error here: it is a reported state, because a status command that
4947    /// refused to print anything else would hide the very facts an operator
4948    /// needs in order to fix it.
4949    ///
4950    /// A record **this account may not read** is reported for the same reason
4951    /// and is the second exception. On a boot-mode host the record was written
4952    /// by `sudo service install`, and a version before this one wrote it `0600`
4953    /// — so the operator's own `service status` ended with `Permission denied`
4954    /// and printed nothing at all, on a host whose registration the service
4955    /// manager would have described perfectly well. What launchd, systemd or
4956    /// the SCM says is a separate fact from the record, and it is still worth
4957    /// having.
4958    ///
4959    /// # A registration made from another data root
4960    ///
4961    /// The service manager holds one registration per machine under one name,
4962    /// and every account on the machine sees it. When its command line names
4963    /// application-data directories that are not this account's, it is
4964    /// **another installation's service**, and comparing this account's
4965    /// install record against it would report a stale binary, a mismatched
4966    /// start mode and a secret store in the wrong scope, each with a remedy
4967    /// (`service uninstall`, `service install`, `auth login`) that, followed
4968    /// from this account, breaks a healthy service. So it is reported as what
4969    /// it is: see [`ForeignService`].
4970    pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
4971        match self.registration_for_status()? {
4972            Some((registration, Some(directories))) => {
4973                Ok(self.foreign_status(registration, directories))
4974            }
4975            found => {
4976                let (record, record_refused) = match InstallRecord::read(&self.paths) {
4977                    Ok(record) => (record, None),
4978                    Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
4979                    Err(error) => return Err(error),
4980                };
4981                self.status_at(
4982                    &self.paths,
4983                    record,
4984                    record_refused.as_ref(),
4985                    found.map(|(registration, _)| registration),
4986                )
4987            }
4988        }
4989    }
4990
4991    /// [`Self::status`] for the installation whose directories are `paths`,
4992    /// from the record already read there.
4993    fn status_at(
4994        &self,
4995        paths: &AppPaths,
4996        record: Option<InstallRecord>,
4997        record_refused: Option<&ServiceError>,
4998        found: Option<Registration>,
4999    ) -> Result<ServiceStatus, ServiceError> {
5000        let last_github_contact = last_github_contact(paths)?;
5001        let drift = record
5002            .as_ref()
5003            .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, record));
5004        let credential_rejected_since = github_credential_rejected_since(paths).ok().flatten();
5005        let credential_unreadable_since = credential_unreadable_since(paths).ok().flatten();
5006        let probed = self.controls.unattended_login();
5007        let unattended = record.as_ref().and_then(|record| {
5008            // The account is only read where something was probed to judge
5009            // it against.
5010            let account = probed.as_ref().map_or_else(String::new, |_| {
5011                found
5012                    .as_ref()
5013                    .and_then(|registration| registration.account.clone())
5014                    .unwrap_or_else(|| service_account_name(&record.account))
5015            });
5016            unattended_gap(record.start_mode, &account, probed.as_ref())
5017        });
5018        let now = Utc::now();
5019        let installed = record.is_some() || found.is_some();
5020        let status = ServiceStatus::compose(
5021            self.identity.clone(),
5022            record,
5023            record_refused,
5024            found,
5025            last_github_contact,
5026            paths,
5027        )
5028        .with_definition_drift(drift)
5029        .with_credential_rejection(credential_rejected_since)
5030        .with_credential_unreadable(credential_unreadable_since)
5031        .with_launches_blocked(crate::launch_health::launches_blocked(paths, now))
5032        .with_unattended_gap(unattended)
5033        .with_host_unfit(
5034            crate::host_fitness::host_unfit_in_force(paths, now).filter(|_| installed),
5035        );
5036        let restart = status.restart_command();
5037        Ok(status.with_stall(
5038            crate::daemon_heartbeat::liveness(paths, now).stall(last_github_contact, &restart),
5039        ))
5040    }
5041
5042    /// The installed definition, when it is not what this build renders for
5043    /// the recorded installation. See [`definition_drift`].
5044    ///
5045    /// # Errors
5046    ///
5047    /// Whatever reading the install record reports.
5048    pub fn definition_drift(&self) -> Result<Option<DefinitionDrift>, ServiceError> {
5049        Ok(InstallRecord::read(&self.paths)?
5050            .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, &record)))
5051    }
5052
5053    /// Writes the plist this build renders over an outdated one, then has a
5054    /// detached helper boot the job out and bootstrap it again from the new
5055    /// file.
5056    ///
5057    /// launchd reads a plist only when the job is bootstrapped. Rewriting the
5058    /// file changes nothing for a job that is already loaded, and a `KeepAlive`
5059    /// restart reuses the loaded copy. So the job has to be booted out, and
5060    /// that terminates the daemon that asked for it. The helper runs in a
5061    /// session of its own, which launchd does not kill with the job, and
5062    /// retries the bootstrap: launchd refuses it for a few seconds while the
5063    /// old job is still exiting (`5: Input/output error`, measured).
5064    ///
5065    /// Call this only from the daemon the job runs, and only while it holds no
5066    /// runner. It is an operation on the registration the daemon itself runs
5067    /// under, and it never changes the start mode.
5068    ///
5069    /// # Errors
5070    ///
5071    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`] when the
5072    /// plist cannot be written or the helper cannot be started. The loaded job
5073    /// is untouched in both cases.
5074    #[cfg(target_os = "macos")]
5075    pub fn reload_launchd_definition(&self, drift: &DefinitionDrift) -> Result<(), ServiceError> {
5076        // The record names the start mode; the account this process runs as has
5077        // to agree, or the record describes a registration other than the one
5078        // running, and root would end up rewriting an operator's LaunchAgent.
5079        // SAFETY: `getuid` reads the calling process's real user id and cannot
5080        // fail.
5081        let root = unsafe { libc::getuid() } == 0;
5082        if root != (drift.start_mode == StartMode::Boot) {
5083            return Err(ServiceError::Control {
5084                operation: "rewrite",
5085                name: self.identity.name().to_string(),
5086                manager: "launchd",
5087                detail: format!(
5088                    "the install record says --start-at {}, but this daemon runs as {}; the plist \
5089                     is left as it is",
5090                    drift.start_mode,
5091                    if root { "root" } else { "the operator" }
5092                ),
5093            });
5094        }
5095        replace_definition(self.identity.name(), drift)?;
5096        // The definition is now this build's, as after the WSL unit rewrite.
5097        // Best effort: the record says who wrote the plist and nothing reads it
5098        // to decide whether to repair.
5099        if let Ok(Some(mut record)) = InstallRecord::read(&self.paths) {
5100            record.installed_by_version = env!("CARGO_PKG_VERSION").to_string();
5101            let _ = record.write(&self.paths);
5102        }
5103        sys::reload_detached(&self.identity, drift.start_mode, &drift.path)
5104    }
5105
5106    /// Creates or reconciles the runner root this start mode's account needs.
5107    ///
5108    /// The account is not an argument: it is [`ServiceAccount::for_start_mode`],
5109    /// the same function the registration's own principal comes from, so the
5110    /// directory admits exactly the identity the definition registers and a mode
5111    /// change reconciles both together or neither.
5112    ///
5113    /// On macOS and Linux this is a no-op that returns
5114    /// [`RootAccessSummary::NotApplicable`]; see [`crate::runner_root_access`].
5115    fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
5116        #[cfg(not(windows))]
5117        {
5118            // `runner_root` is consumed here as well as in the Windows arm: a
5119            // field only one platform reads is a dead field on the other.
5120            let _ = (mode, &self.runner_root);
5121            Ok(RootAccessChange::not_applicable())
5122        }
5123        #[cfg(windows)]
5124        {
5125            let wrap = |source| ServiceError::RunnerRoot {
5126                source: Box::new(source),
5127            };
5128            // The test seam, and the two things allowed through it. `cfg!(test)`
5129            // is false in every shipped binary, so what a released build honours
5130            // is the fixture name alone — and a fixture name cannot be the
5131            // product's, which is what keeps a released `service install`
5132            // pointed at the platform default whatever a caller passes.
5133            //
5134            // An overridden root is always reconciled **for this account**,
5135            // which is the foreground admission — a real mode of the product
5136            // rather than a concession invented here. It has to be: a boot-mode
5137            // root admits `SY` and `BA` only, and a test process holding an
5138            // ordinary filtered token is neither, so it could not inspect the
5139            // temporary directory it just supplied nor delete it afterwards.
5140            // What that costs is that the *boot* descriptor is not proved
5141            // through this path; it is proved purely, by this module's
5142            // `the_runner_root_a_boot_registration_needs_admits_only_the_service`
5143            // and by `runner_root_access`'s own tests, and for real by the
5144            // privileged installer test, which runs elevated.
5145            if let Some(root) = self
5146                .runner_root
5147                .as_ref()
5148                .filter(|_| self.identity.is_fixture() || cfg!(test))
5149            {
5150                let admission = RootAdmission::of_this_account().map_err(wrap)?;
5151                return crate::runner_root_access::reconcile(&self.paths, root, &admission)
5152                    .map_err(wrap);
5153            }
5154
5155            let admission = match ServiceAccount::for_start_mode(mode) {
5156                // A boot registration runs as LocalSystem, which the constant
5157                // `SY` ace already names.
5158                ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
5159                // A login registration runs as this account, under a filtered
5160                // token in which Administrators is deny-only — so without an
5161                // ace of its own it would be admitted by nothing.
5162                ServiceAccount::InvokingUser | ServiceAccount::Root => {
5163                    RootAdmission::of_this_account().map_err(wrap)?
5164                }
5165            };
5166            crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
5167        }
5168    }
5169
5170    /// Takes the single-instance lock, or refuses with `d1`'s own message.
5171    fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
5172        HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
5173            |source| match source {
5174                held @ LockError::Held { .. } => ServiceError::LockHeld {
5175                    source: Box::new(held),
5176                },
5177                other => ServiceError::LockUnreadable {
5178                    source: Box::new(other),
5179                },
5180            },
5181        )
5182    }
5183
5184    /// The registration, in whichever domain holds it.
5185    fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
5186        for mode in [StartMode::Boot, StartMode::Login] {
5187            let control = self.controls.control(mode)?;
5188            if let Some(registration) = control.query(&self.identity)? {
5189                return Ok(Some((registration.start_mode, registration)));
5190            }
5191        }
5192        Ok(None)
5193    }
5194
5195    /// The registration [`Self::status`] describes, with the directories it
5196    /// runs against when they are not this account's: this account's own
5197    /// registration when either domain holds one, else whichever there is.
5198    ///
5199    /// [`Self::find_registration`] stops at the boot domain, so a boot service
5200    /// made from another data root would hide this account's own login
5201    /// registration. The other domain is asked only in that case, and a
5202    /// failure to ask it is not this status's failure.
5203    fn registration_for_status(
5204        &self,
5205    ) -> Result<Option<(Registration, Option<ServiceDirectories>)>, ServiceError> {
5206        let Some((mode, found)) = self.find_registration()? else {
5207            return Ok(None);
5208        };
5209        let Some(directories) = self.foreign_directories(&found) else {
5210            return Ok(Some((found, None)));
5211        };
5212        let other = match mode {
5213            StartMode::Boot => StartMode::Login,
5214            StartMode::Login => StartMode::Boot,
5215        };
5216        let own = self
5217            .controls
5218            .control(other)
5219            .ok()
5220            .and_then(|control| control.query(&self.identity).ok().flatten())
5221            .filter(|registration| self.foreign_directories(registration).is_none());
5222        Ok(Some(match own {
5223            Some(own) => (own, None),
5224            None => (found, Some(directories)),
5225        }))
5226    }
5227
5228    /// Moves a Linux boot service registered against another tree onto this
5229    /// one, which for `root` is the system tree. Returns whether it did.
5230    ///
5231    /// `service install` calls this before it opens anything in this tree, so
5232    /// that the copy lands in an empty one; the install itself calls it again,
5233    /// which then only confirms the registration is to be replaced.
5234    ///
5235    /// Before the system tree, a boot service ran against whichever tree
5236    /// `sudo` resolved: `/root`'s on some distributions, the operator's own on
5237    /// others. A boot unit with no capabilities cannot open the operator's, and
5238    /// `ProtectHome=` hides both, so the service is stopped, its configuration
5239    /// and state are copied here unless this tree already has its own, and the
5240    /// install then replaces the registration. The old tree is left as it was.
5241    ///
5242    /// # Errors
5243    ///
5244    /// Whatever stopping the old service or copying its tree reports.
5245    pub fn adopt_boot_installation(&self, mode: StartMode) -> Result<bool, ServiceError> {
5246        #[cfg(target_os = "linux")]
5247        return self.adopt_boot_installation_on_linux(mode);
5248        #[cfg(not(target_os = "linux"))]
5249        {
5250            let _ = mode;
5251            Ok(false)
5252        }
5253    }
5254
5255    #[cfg(target_os = "linux")]
5256    fn adopt_boot_installation_on_linux(&self, mode: StartMode) -> Result<bool, ServiceError> {
5257        if mode != StartMode::Boot {
5258            return Ok(false);
5259        }
5260        self.refuse_overridden_account()?;
5261        if !crate::host_fitness::is_elevated() {
5262            return Ok(false);
5263        }
5264        let Some((StartMode::Boot, found)) = self.find_registration()? else {
5265            return Ok(false);
5266        };
5267        let Some(old) = self.foreign_directories(&found) else {
5268            return Ok(false);
5269        };
5270        self.controls
5271            .control(StartMode::Boot)?
5272            .stop(&self.identity)?;
5273        // No service was ever installed against this tree, so whatever is in it
5274        // is what an interrupted attempt or a stray command left: set aside,
5275        // never deleted, in favour of the data the running service used. A
5276        // tree with its own install record keeps it.
5277        if matches!(InstallRecord::read(&self.paths), Ok(None)) {
5278            let stamp = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
5279            for (from, to, skip) in [
5280                (&old.config, self.paths.config_dir(), None),
5281                // `service install` copies the binary in again.
5282                (&old.state, self.paths.state_dir(), Some("bin")),
5283            ] {
5284                set_aside(to, &stamp)
5285                    .and_then(|()| copy_tree(from, to, skip))
5286                    .map_err(|error| ServiceError::Control {
5287                        operation: "move the data of",
5288                        name: self.identity.name().to_string(),
5289                        manager: "systemd",
5290                        detail: format!(
5291                            "cannot copy {} to {}: {error}",
5292                            from.display(),
5293                            to.display()
5294                        ),
5295                    })?;
5296            }
5297        }
5298        Ok(true)
5299    }
5300
5301    /// Refuses a Linux boot install over a unit an administrator's drop-in made
5302    /// run as another account.
5303    ///
5304    /// A drop-in outlives the unit file `service install` rewrites, so the new
5305    /// unit would run as that account too, and that account cannot open the
5306    /// system tree `root` owns. Said before anything is stopped or copied.
5307    #[cfg(target_os = "linux")]
5308    fn refuse_overridden_account(&self) -> Result<(), ServiceError> {
5309        let Some((StartMode::Boot, found)) = self.find_registration()? else {
5310            return Ok(());
5311        };
5312        match found.account.as_deref() {
5313            Some(account) if account != ServiceAccount::Root.as_str() => {
5314                Err(ServiceError::Control {
5315                    operation: "install",
5316                    name: self.identity.name().to_string(),
5317                    manager: "systemd",
5318                    detail: format!(
5319                        "a drop-in under {SYSTEMD_SYSTEM_DIR}/{}.d/ makes it run as {account}, and \
5320                         a boot service must run as root to use {}. Remove `User=` and `Group=` \
5321                         from that drop-in, run `sudo systemctl daemon-reload`, and install again",
5322                        self.identity.systemd_unit(),
5323                        crate::paths::SYSTEM_DATA_ROOT,
5324                    ),
5325                })
5326            }
5327            _ => Ok(()),
5328        }
5329    }
5330
5331    /// Refuses `operation` when the machine's registration is another
5332    /// installation's. See [`ServiceError::ForeignInstallation`].
5333    fn refuse_foreign(&self, operation: &'static str) -> Result<(), ServiceError> {
5334        match self.registration_for_status()? {
5335            Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
5336            _ => Ok(()),
5337        }
5338    }
5339
5340    /// [`ServiceError::ForeignInstallation`] for the installation at
5341    /// `directories`.
5342    fn foreign_refusal(
5343        &self,
5344        operation: &'static str,
5345        directories: ServiceDirectories,
5346    ) -> ServiceError {
5347        let foreign = self.foreign_service_at(directories);
5348        ServiceError::ForeignInstallation {
5349            name: self.identity.name().to_string(),
5350            operation,
5351            installed_by: foreign.installed_by(),
5352            act_as: foreign.act_as(),
5353        }
5354    }
5355
5356    /// The directories `registration` runs against, when they are not this
5357    /// account's. See [`ForeignService`].
5358    fn foreign_directories(&self, registration: &Registration) -> Option<ServiceDirectories> {
5359        ServiceDirectories::from_command_line(&registration.command_line)
5360            .filter(|directories| !same_directory(&directories.config, self.paths.config_dir()))
5361    }
5362
5363    /// The machine's service and the installation it belongs to, when that is
5364    /// not this account's, without reading anything of that installation's
5365    /// beyond its path. `None` when the service is this account's, when there
5366    /// is none, or when the service manager cannot be asked.
5367    #[must_use]
5368    pub fn foreign_service(&self) -> Option<ForeignService> {
5369        let (_, directories) = self.registration_for_status().ok()??;
5370        Some(self.foreign_service_at(directories?))
5371    }
5372
5373    /// The [`ForeignService`] for an installation at `directories`, before
5374    /// anything of its own is read.
5375    fn foreign_service_at(&self, directories: ServiceDirectories) -> ForeignService {
5376        let root = directories.root();
5377        let home = host_home();
5378        // This account's own installation under other directories, when the
5379        // four are exactly a layout a command of this account resolves.
5380        let this_account = home
5381            .as_deref()
5382            .filter(|home| contains_directory(home, &root))
5383            .and_then(|_| {
5384                if directories == ServiceDirectories::of(&AppPaths::rooted_at(&root)) {
5385                    Some(format!(
5386                        "with `--data-dir {}`",
5387                        quote_argument(&root.to_string_lossy())
5388                    ))
5389                } else {
5390                    AppPaths::discover()
5391                        .ok()
5392                        .filter(|standard| directories == ServiceDirectories::of(standard))
5393                        .map(|_| "without `--data-dir`".to_owned())
5394                }
5395            });
5396        ForeignService {
5397            owner: data_root_owner(&root, home.as_deref()),
5398            this_account,
5399            root,
5400            directories,
5401            local_root: ServiceDirectories::of(&self.paths).root(),
5402            local_record: InstallRecord::path(&self.paths),
5403            unused_local_record: InstallRecord::read(&self.paths).ok().flatten(),
5404            unreadable: None,
5405        }
5406    }
5407
5408    /// [`Self::status`] for a registration made from another data root.
5409    ///
5410    /// What that installation recorded is read when this account may read it,
5411    /// and the status is then exactly the one its owner would see. When it may
5412    /// not, the status holds what the service manager says and nothing it
5413    /// would have to guess.
5414    fn foreign_status(
5415        &self,
5416        registration: Registration,
5417        directories: ServiceDirectories,
5418    ) -> ServiceStatus {
5419        let service_paths = directories.paths();
5420        let mut foreign = self.foreign_service_at(directories);
5421        let unreadable = match InstallRecord::read(&service_paths) {
5422            Ok(Some(record)) => {
5423                match self.status_at(
5424                    &service_paths,
5425                    Some(record),
5426                    None,
5427                    Some(registration.clone()),
5428                ) {
5429                    Ok(status) => return status.with_foreign(foreign),
5430                    Err(error) => error.to_string(),
5431                }
5432            }
5433            Ok(None) => format!(
5434                "there is no install record at {}",
5435                InstallRecord::path(&service_paths).display()
5436            ),
5437            // Not the error's own words: `RecordNotPermitted` and
5438            // `RecordUnreadable` both end in a `service install` remedy, which
5439            // from this account would re-register the machine's service
5440            // against the wrong directories.
5441            Err(
5442                ServiceError::RecordNotPermitted { path, detail }
5443                | ServiceError::RecordUnreadable { path, detail }
5444                | ServiceError::Record { path, detail, .. },
5445            ) => format!("this account cannot read {} ({detail})", path.display()),
5446            Err(error) => error.to_string(),
5447        };
5448        foreign.unreadable = Some(unreadable);
5449        ServiceStatus::foreign_unread(self.identity.clone(), registration, foreign)
5450    }
5451
5452    /// Moves this account's own configuration aside, when no registration on
5453    /// this machine runs against it: `host forget-local`.
5454    ///
5455    /// Everything in the configuration directory -- the install record, the
5456    /// database with its host row and policies, `runner.env` and the rest -- is
5457    /// **renamed** into a `forgotten-<timestamp>` directory beside it, never
5458    /// deleted, so moving it back restores it exactly. The state, runtime and
5459    /// log directories are left alone even where the platform puts them inside
5460    /// the configuration directory (macOS does), and so is every secret store
5461    /// and every directory a registration names.
5462    ///
5463    /// Both domains are asked, strictly: a domain that cannot be asked might
5464    /// hold this account's own registration, so it refuses rather than reads
5465    /// as empty.
5466    ///
5467    /// # Errors
5468    ///
5469    /// [`ServiceError::LocalConfigurationInUse`] when a registration on this
5470    /// machine runs against these directories, when a registration from
5471    /// another data root names a directory overlapping them, or when an agent
5472    /// holds this account's single-instance lock. [`ServiceError::Record`] when
5473    /// the move fails; whatever had already moved is put back first, and
5474    /// anything that could not be put back is named.
5475    pub fn forget_local(&self, at: DateTime<Utc>) -> Result<ForgottenLocal, ServiceError> {
5476        let config = self.paths.config_dir();
5477        let in_use = |detail: String| ServiceError::LocalConfigurationInUse { detail };
5478        let mut service_root = None;
5479        for mode in [StartMode::Boot, StartMode::Login] {
5480            let registration = self
5481                .controls
5482                .control(mode)
5483                .and_then(|control| control.query(&self.identity))
5484                .map_err(|error| {
5485                    in_use(format!(
5486                        "whether a {mode} registration runs against it could not be read ({error})"
5487                    ))
5488                })?;
5489            let Some(registration) = registration else {
5490                continue;
5491            };
5492            let Some(directories) = self.foreign_directories(&registration) else {
5493                return Err(in_use(format!(
5494                    "{} holds a registration that runs against {}. `service status` describes it",
5495                    registration.manager,
5496                    config.display()
5497                )));
5498            };
5499            if let Some(overlap) = directories.all().into_iter().find(|directory| {
5500                contains_directory(directory, config) || contains_directory(config, directory)
5501            }) {
5502                return Err(in_use(format!(
5503                    "the machine's service runs against {}, which overlaps {}",
5504                    overlap.display(),
5505                    config.display()
5506                )));
5507            }
5508            service_root.get_or_insert_with(|| directories.root());
5509        }
5510        let _lock =
5511            HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(|source| {
5512                in_use(format!(
5513                    "an agent may be running against these directories: {source}"
5514                ))
5515            })?;
5516        let failed = |operation: &'static str, path: &Path| {
5517            let path = path.to_path_buf();
5518            move |error: std::io::Error| ServiceError::Record {
5519                operation,
5520                path,
5521                detail: error.to_string(),
5522            }
5523        };
5524        // Never the agent's own directories, wherever the platform nests them.
5525        let kept = [
5526            self.paths.state_dir(),
5527            self.paths.runtime_dir(),
5528            self.paths.logs_dir(),
5529        ];
5530        let mut entries = Vec::new();
5531        for entry in std::fs::read_dir(config).map_err(failed("list", config))? {
5532            let entry = entry.map_err(failed("list", config))?.path();
5533            let archived = entry
5534                .file_name()
5535                .is_some_and(|name| name.to_string_lossy().starts_with(FORGOTTEN_PREFIX));
5536            if !archived && !kept.iter().any(|kept| contains_directory(&entry, kept)) {
5537                entries.push(entry);
5538            }
5539        }
5540        if entries.is_empty() {
5541            return Ok(ForgottenLocal {
5542                archive: None,
5543                moved: entries,
5544                service_root,
5545            });
5546        }
5547        entries.sort();
5548        let archive = config.join(format!("{FORGOTTEN_PREFIX}{}", at.format("%Y%m%dT%H%M%SZ")));
5549        std::fs::create_dir(&archive).map_err(failed("create", &archive))?;
5550        let inside = |entry: &Path| archive.join(entry.file_name().unwrap_or_default());
5551        for (index, entry) in entries.iter().enumerate() {
5552            if let Err(error) = std::fs::rename(entry, inside(entry)) {
5553                let stranded: Vec<String> = entries[..index]
5554                    .iter()
5555                    .rev()
5556                    .filter(|done| std::fs::rename(inside(done), done).is_err())
5557                    .map(|done| inside(done).display().to_string())
5558                    .collect();
5559                let _ = std::fs::remove_dir(&archive);
5560                let mut detail = error.to_string();
5561                if !stranded.is_empty() {
5562                    detail.push_str(&format!(
5563                        "; these could not be put back and are still in the archive: {}",
5564                        stranded.join(", ")
5565                    ));
5566                }
5567                return Err(ServiceError::Record {
5568                    operation: "archive",
5569                    path: entry.clone(),
5570                    detail,
5571                });
5572            }
5573        }
5574        Ok(ForgottenLocal {
5575            archive: Some(archive),
5576            moved: entries,
5577            service_root,
5578        })
5579    }
5580}
5581
5582/// The name every `host forget-local` archive starts with. A later run leaves
5583/// earlier archives where they are.
5584pub const FORGOTTEN_PREFIX: &str = "forgotten-";
5585
5586/// What [`ServiceOperations::forget_local`] did.
5587#[derive(Debug, Clone, PartialEq, Eq)]
5588pub struct ForgottenLocal {
5589    /// Where the configuration went, or `None` when there was nothing to move.
5590    pub archive: Option<PathBuf>,
5591    /// What was moved, by its old path.
5592    pub moved: Vec<PathBuf>,
5593    /// The data root of the machine's service, when one runs from another
5594    /// installation. Named so the operator can see it was not touched.
5595    pub service_root: Option<PathBuf>,
5596}
5597
5598impl fmt::Display for ForgottenLocal {
5599    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5600        let Some(archive) = &self.archive else {
5601            return write!(
5602                f,
5603                "Nothing to archive: this account's configuration directory is empty."
5604            );
5605        };
5606        writeln!(f, "Archived this account's unused local configuration")?;
5607        writeln!(f, "  archive                   {}", archive.display())?;
5608        for path in &self.moved {
5609            writeln!(f, "  moved                     {}", path.display())?;
5610        }
5611        if let Some(root) = &self.service_root {
5612            writeln!(
5613                f,
5614                "  service data root         {} (untouched)",
5615                root.display()
5616            )?;
5617        }
5618        write!(
5619            f,
5620            "  restore                   move everything in the archive back into its parent \
5621             directory"
5622        )
5623    }
5624}
5625
5626/// Whether two directory spellings name the same place: the same text on
5627/// this platform's terms, or the same canonical directory.
5628fn same_directory(left: &Path, right: &Path) -> bool {
5629    let text = |path: &Path| {
5630        path.to_string_lossy()
5631            .trim_end_matches(['/', '\\'])
5632            .to_owned()
5633    };
5634    same_path_text(&text(left), &text(right))
5635        || matches!(
5636            (std::fs::canonicalize(left), std::fs::canonicalize(right)),
5637            (Ok(left), Ok(right)) if left == right
5638        )
5639}
5640
5641/// Where the remedy starts in a [`StatusProblem`]'s detail: the sentence
5642/// openings every builder above uses for it.
5643const REMEDY_OPENINGS: [&str; 8] = [
5644    ". Run ",
5645    ". If nothing is waiting",
5646    ". Re-register",
5647    ". Switch the start mode",
5648    " Sign in again",
5649    ". Fix: ",
5650    "; to do it now",
5651    "; if the service manager denies",
5652];
5653
5654/// A problem's diagnosis without its remedy. See
5655/// [`ServiceStatus::with_foreign`].
5656///
5657/// A detail that still names a command that changes the registration or the
5658/// credential after the cut -- a builder this list does not know yet -- is
5659/// reduced to the first clause rather than passed on, because the cost of
5660/// showing too little is a shorter sentence and the cost of showing too much
5661/// is a broken service.
5662fn diagnosis_only(detail: &str) -> String {
5663    let cut = REMEDY_OPENINGS
5664        .iter()
5665        .filter_map(|opening| detail.find(opening))
5666        .min()
5667        .unwrap_or(detail.len());
5668    let diagnosis = detail[..cut].trim_end_matches(['.', ' ']);
5669    let names_a_change = |text: &str| {
5670        [
5671            "service install",
5672            "service uninstall",
5673            "auth login",
5674            "launchctl",
5675            "systemctl",
5676        ]
5677        .iter()
5678        .any(|command| text.contains(command))
5679    };
5680    if !names_a_change(diagnosis) {
5681        return diagnosis.to_owned();
5682    }
5683    // Clause ends followed by a space, so a `.` inside a path does not cut.
5684    let end = [", ", "; ", ". "]
5685        .iter()
5686        .filter_map(|separator| diagnosis.find(separator))
5687        .min()
5688        .unwrap_or(diagnosis.len());
5689    let first = &diagnosis[..end];
5690    if names_a_change(first) {
5691        "the installation's own records report this".to_owned()
5692    } else {
5693        first.to_owned()
5694    }
5695}
5696
5697/// A path's components as this platform compares them: case-insensitively
5698/// on Windows.
5699fn path_key(path: &Path) -> Vec<String> {
5700    path.components()
5701        .map(|component| {
5702            let text = component.as_os_str().to_string_lossy();
5703            if cfg!(windows) {
5704                text.to_lowercase()
5705            } else {
5706                text.into_owned()
5707            }
5708        })
5709        .collect()
5710}
5711
5712/// Whether `inner` is `outer` or a directory under it, by the rule
5713/// [`same_directory`] uses: on this platform's terms, or canonically.
5714fn contains_directory(outer: &Path, inner: &Path) -> bool {
5715    path_key(inner).starts_with(&path_key(outer))
5716        || matches!(
5717            (std::fs::canonicalize(outer), std::fs::canonicalize(inner)),
5718            (Ok(outer), Ok(inner)) if inner.starts_with(&outer)
5719        )
5720}
5721
5722/// The account whose data root `root` is, when the path says: the folder
5723/// under the directory that holds this account's `home`, `root` for root's
5724/// home on Unix, and `LocalSystem` for its profile on Windows. `None` when
5725/// the path names nobody, which is reported as the path alone.
5726#[must_use]
5727pub fn data_root_owner(root: &Path, home: Option<&Path>) -> Option<String> {
5728    let target = path_key(root);
5729    if cfg!(windows) {
5730        if target
5731            .windows(3)
5732            .any(|run| run == ["system32", "config", "systemprofile"])
5733        {
5734            return Some("LocalSystem".to_owned());
5735        }
5736    } else if [
5737        "/root",
5738        "/var/root",
5739        "/private/var/root",
5740        crate::paths::SYSTEM_DATA_ROOT,
5741    ]
5742    .iter()
5743    .any(|home| root.starts_with(home))
5744    {
5745        return Some("root".to_owned());
5746    }
5747    let users = path_key(home?.parent()?);
5748    // A home directly under the root (`/root` under `sudo`) names no directory
5749    // of homes: every path would then "belong" to its first component.
5750    if users.len() < 2 {
5751        return None;
5752    }
5753    if target.len() > users.len() && target[..users.len()] == users[..] {
5754        root.components()
5755            .nth(users.len())
5756            .map(|name| name.as_os_str().to_string_lossy().into_owned())
5757    } else {
5758        None
5759    }
5760}
5761
5762/// A registration whose command line names application-data directories that
5763/// are not this account's: **another installation's service**.
5764///
5765/// Every account on a machine sees the one registration its service manager
5766/// holds under the product's name. A second account -- one that ran a
5767/// login-mode install once, say -- has an install record and a database of its
5768/// own, and comparing them against the other installation's registration used
5769/// to report a stale binary, a mismatched start mode and a secret store in the
5770/// wrong scope, with `service uninstall`, `service install` and `auth login`
5771/// as the remedies. Followed from that account, each of them breaks or
5772/// replaces a service that is healthy. So the registration is reported as the
5773/// other installation's, with what can be read of it, and this account's own
5774/// record as the unused configuration it is.
5775#[derive(Debug, Clone, PartialEq, Eq)]
5776pub struct ForeignService {
5777    /// The directories its command line names.
5778    pub directories: ServiceDirectories,
5779    /// The data root they sit under. See [`ServiceDirectories::root`].
5780    pub root: PathBuf,
5781    /// The account that data root belongs to, when its path says.
5782    pub owner: Option<String>,
5783    /// How a command run by this account reaches the installation, when it is
5784    /// this account's own under other directories -- an install made with
5785    /// `--data-dir`, seen from a command run without it, or the other way
5786    /// round. `None` for another account's.
5787    pub this_account: Option<String>,
5788    /// This account's own data root, which no registration runs against.
5789    pub local_root: PathBuf,
5790    /// Where this account's own install record is, or would be.
5791    pub local_record: PathBuf,
5792    /// This account's own install record, from an earlier install.
5793    pub unused_local_record: Option<InstallRecord>,
5794    /// Why the service's own records could not be read, or `None` when they
5795    /// were and the status describes them.
5796    pub unreadable: Option<String>,
5797}
5798
5799impl ForeignService {
5800    /// How to see the service's own details: as its owner, or elevated.
5801    #[must_use]
5802    pub fn where_to_look(&self) -> String {
5803        if let Some(reach) = &self.this_account {
5804            return reach.clone();
5805        }
5806        let elevated = if cfg!(windows) {
5807            "from an elevated terminal (Run as administrator)"
5808        } else {
5809            "with sudo"
5810        };
5811        match &self.owner {
5812            Some(owner) if owner != "LocalSystem" && owner != "root" => {
5813                format!("{elevated}, or signed in as {owner}")
5814            }
5815            _ => elevated.to_owned(),
5816        }
5817    }
5818
5819    /// Where a change to the service has to be made from: the installation's
5820    /// own account, because every remedy a command prints acts on the
5821    /// directories of whoever runs it. Elevation alone reads the service's
5822    /// records but is still this account.
5823    #[must_use]
5824    pub fn act_as(&self) -> String {
5825        if let Some(reach) = &self.this_account {
5826            return reach.clone();
5827        }
5828        match &self.owner {
5829            Some(owner) if owner != "LocalSystem" && owner != "root" => {
5830                format!("signed in as {owner}")
5831            }
5832            _ => format!(
5833                "from the account whose data root {} is",
5834                self.root.display()
5835            ),
5836        }
5837    }
5838
5839    /// Whether the service's own records could not be read, so only what the
5840    /// service manager says is known.
5841    #[must_use]
5842    pub const fn is_unread(&self) -> bool {
5843        self.unreadable.is_some()
5844    }
5845
5846    /// Who installed it, for a sentence.
5847    #[must_use]
5848    pub fn installed_by(&self) -> String {
5849        match &self.owner {
5850            Some(owner) => format!("{owner}'s data root {}", self.root.display()),
5851            None => format!("the data root {}", self.root.display()),
5852        }
5853    }
5854
5855    /// The data root and its owner, for a field: `<root> (<owner>)`.
5856    #[must_use]
5857    pub fn label(&self) -> String {
5858        match &self.owner {
5859            Some(owner) => format!("{} ({owner})", self.root.display()),
5860            None => self.root.display().to_string(),
5861        }
5862    }
5863
5864    /// The note describing this account's own record, when there is one.
5865    #[must_use]
5866    pub fn unused_local_note(&self) -> Option<String> {
5867        let record = self.unused_local_record.as_ref()?;
5868        Some(format!(
5869            "unused local configuration from an earlier install: {} records a {} registration \
5870             made by runner-manager {} on {}, and no service on this machine runs against it. \
5871             `runner-manager host forget-local` moves it aside; it never touches the service's \
5872             own directories or any secret.",
5873            self.local_record.display(),
5874            record.start_mode,
5875            record.installed_by_version,
5876            record.installed_at.format("%Y-%m-%d"),
5877        ))
5878    }
5879}
5880
5881// ---------------------------------------------------------------------------
5882// Status
5883// ---------------------------------------------------------------------------
5884
5885/// One thing `service status` has to report as wrong.
5886#[derive(Debug, Clone, PartialEq, Eq)]
5887pub struct StatusProblem {
5888    /// What it is about.
5889    pub subject: &'static str,
5890    /// What is wrong, and what to do.
5891    pub detail: String,
5892}
5893
5894impl fmt::Display for StatusProblem {
5895    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5896        write!(f, "{}: {}", self.subject, self.detail)
5897    }
5898}
5899
5900/// Everything `service status` reports.
5901///
5902/// Journey 5 step 4 names four of these — the start mode, the resolved binary
5903/// path, the diagnostic log path, and the last successful GitHub contact — and
5904/// the release gate adds the fifth: *"`service status` reports a stale binary
5905/// path as an error rather than appearing healthy"*. [`Self::is_healthy`] is
5906/// what an exit code should be derived from, and it is false whenever
5907/// [`Self::problems`] is non-empty.
5908#[derive(Debug, Clone)]
5909pub struct ServiceStatus {
5910    identity: ServiceIdentity,
5911    record: Option<InstallRecord>,
5912    registration: Option<Registration>,
5913    binary: Option<BinaryPath>,
5914    log_file: PathBuf,
5915    store: Option<crate::secrets::ActiveStore>,
5916    last_github_contact: Option<DateTime<Utc>>,
5917    runner_root: Option<(PathBuf, RootAccessReport)>,
5918    credential_rejected_since: Option<DateTime<Utc>>,
5919    launches_blocked: Option<crate::launch_health::LaunchesBlocked>,
5920    unattended_gap: Option<UnattendedGap>,
5921    foreign: Option<ForeignService>,
5922    problems: Vec<StatusProblem>,
5923    notes: Vec<String>,
5924}
5925
5926/// Why a registration does not come back by itself after an unattended
5927/// restart, and what an operator can do about it.
5928#[derive(Debug, Clone, PartialEq, Eq)]
5929pub struct UnattendedGap {
5930    /// One or more sentences, the way `service status` prints them.
5931    pub reason: String,
5932    /// What changes it. Never something this product does by itself.
5933    pub remedy: String,
5934}
5935
5936/// The name of the account a registration runs as: the invoking user's own
5937/// for a login-mode one.
5938#[must_use]
5939pub fn service_account_name(account: &ServiceAccount) -> String {
5940    match account {
5941        // Under `sudo` the invoking user is the one sudo names, not root.
5942        ServiceAccount::InvokingUser => std::env::var("SUDO_USER")
5943            .ok()
5944            .filter(|user| !user.is_empty() && crate::host_fitness::is_elevated())
5945            .or_else(crate::unattended_login::current_account)
5946            .unwrap_or_else(|| "the operator".to_owned()),
5947        other => other.as_str().to_owned(),
5948    }
5949}
5950
5951/// The account `registration` names, which an administrator can change from
5952/// the one the install record saved, and otherwise the record's.
5953fn registered_account<'a>(registration: Option<&'a Registration>, recorded: &'a str) -> &'a str {
5954    registration
5955        .and_then(|registration| registration.account.as_deref())
5956        .unwrap_or(recorded)
5957}
5958
5959/// A Windows service account as `QueryServiceConfigW` names it, spelled the
5960/// way [`ServiceAccount`] does, so a service nobody edited reports the account
5961/// the record holds.
5962#[cfg(any(windows, test))]
5963fn windows_service_account(name: &str) -> String {
5964    if name.eq_ignore_ascii_case("LocalSystem") || name.eq_ignore_ascii_case(".\\LocalSystem") {
5965        ServiceAccount::LocalSystem.as_str().to_string()
5966    } else {
5967        name.to_string()
5968    }
5969}
5970
5971/// A Task Scheduler `UserId`, unless it is a raw SID, which names no account
5972/// an operator would recognise.
5973#[cfg(any(windows, test))]
5974fn scheduled_task_account(user_id: Option<String>) -> Option<String> {
5975    user_id.filter(|id| !id.to_ascii_uppercase().starts_with("S-1-"))
5976}
5977
5978/// Works out the gap for a registration in `mode` running as `account`.
5979///
5980/// `found` is `None` where nothing could be probed (every platform but macOS):
5981/// a login registration then keeps the general statement that it waits for a
5982/// sign-in. On a Mac the statement depends on automatic login and FileVault,
5983/// and a login registration that automatic login brings back has no gap. The
5984/// earlier statement was printed for every login registration, so a Mac set to
5985/// sign its runner account in automatically was told it would not resume.
5986#[must_use]
5987pub fn unattended_gap(
5988    mode: StartMode,
5989    account: &str,
5990    found: Option<&crate::unattended_login::UnattendedLogin>,
5991) -> Option<UnattendedGap> {
5992    let Some(found) = found else {
5993        return (mode == StartMode::Login).then(|| UnattendedGap {
5994            reason: "This registration starts at login, so the agent does not run until the \
5995                     operator signs in; this host does not resume work after an unattended \
5996                     reboot."
5997                .to_owned(),
5998            remedy: "For machine-on operation, move the service and credential to `--start-at \
5999                     boot` from an elevated terminal."
6000                .to_owned(),
6001        });
6002    };
6003    let resume = crate::unattended_login::resume(found, mode, account);
6004    if matches!(resume, crate::unattended_login::Resume::Unknown(_)) {
6005        // `host doctor` says what could not be read; a note here would claim
6006        // a gap nobody has seen.
6007        return None;
6008    }
6009    Some(UnattendedGap {
6010        reason: resume.explain(account)?,
6011        remedy: resume.remedy().unwrap_or_else(|| {
6012            "Run `runner-manager host doctor` on the Mac's desktop, where automatic login and \
6013             FileVault can be read."
6014                .to_owned()
6015        }),
6016    })
6017}
6018
6019impl ServiceStatus {
6020    /// `record_refused` carries a [`ServiceError::RecordNotPermitted`] when
6021    /// there **is** a record and this account may not read it, which is not the
6022    /// same state as having none and must not be reported as one. See
6023    /// [`Operations::status`].
6024    fn compose(
6025        identity: ServiceIdentity,
6026        record: Option<InstallRecord>,
6027        record_refused: Option<&ServiceError>,
6028        registration: Option<Registration>,
6029        last_github_contact: Option<DateTime<Utc>>,
6030        paths: &AppPaths,
6031    ) -> Self {
6032        let mut problems = Vec::new();
6033        let mut notes = Vec::new();
6034
6035        if let Some(refusal) = record_refused {
6036            // A problem rather than a note: `is_healthy` stays false, because
6037            // an operator who cannot read their own install record does have
6038            // something to fix. It is just not a reason to withhold everything
6039            // the service manager knows.
6040            problems.push(StatusProblem {
6041                subject: "install record",
6042                detail: refusal.to_string(),
6043            });
6044        }
6045
6046        let log_file = record.as_ref().map_or_else(
6047            || ServiceDirectories::of(paths).log_file(),
6048            |record| record.log_file.clone(),
6049        );
6050
6051        let binary = record.as_ref().map(|record| {
6052            inspect_binary(
6053                &record.binary,
6054                registration
6055                    .as_ref()
6056                    .and_then(Registration::binary)
6057                    .as_deref(),
6058            )
6059        });
6060        if let Some(state) = &binary
6061            && state.is_error()
6062        {
6063            problems.push(StatusProblem {
6064                subject: "binary",
6065                detail: state.to_string(),
6066            });
6067        }
6068
6069        match (&record, &registration) {
6070            (Some(_), None) => problems.push(StatusProblem {
6071                subject: "registration",
6072                detail: "this host has a service record but no service manager knows the \
6073                         registration. Run `service install` again; it deletes nothing."
6074                    .to_string(),
6075            }),
6076            // Not reported when the record was refused rather than absent: the
6077            // problem above already names the real state, and "there is no
6078            // install record" would be a second, wrong diagnosis of the same
6079            // file, with a `service uninstall` in its remedy.
6080            (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
6081                subject: "record",
6082                detail: format!(
6083                    "{} holds a registration for this service but there is no install record, so \
6084                     the path it was installed from and the directories it was installed against \
6085                     are unknown. Run `service uninstall` and `service install`.",
6086                    found.manager
6087                ),
6088            }),
6089            (None, Some(_)) => {}
6090            (Some(record), Some(found)) => {
6091                if record.start_mode != found.start_mode {
6092                    problems.push(StatusProblem {
6093                        subject: "start mode",
6094                        detail: format!(
6095                            "the record says {} and {} holds a {} registration. Switch the start \
6096                             mode again to make them agree.",
6097                            record.start_mode, found.manager, found.start_mode
6098                        ),
6099                    });
6100                }
6101                if record.start_mode == StartMode::Boot && !found.starts_automatically {
6102                    problems.push(StatusProblem {
6103                        subject: "start mode",
6104                        detail: format!(
6105                            "{} holds the registration but will not start it by itself, so this \
6106                             host does not resume work after a reboot.",
6107                            found.manager
6108                        ),
6109                    });
6110                }
6111                if let Some(actual) = found.restart_delay {
6112                    let expected = record.restart().effective_delay(found.manager);
6113                    if actual != expected {
6114                        problems.push(StatusProblem {
6115                            subject: "restart policy",
6116                            detail: format!(
6117                                "the record says the service restarts after {}s and {} reports \
6118                                 {}s. Something has edited the registration since it was \
6119                                 installed.",
6120                                expected.as_secs(),
6121                                found.manager,
6122                                actual.as_secs()
6123                            ),
6124                        });
6125                    } else if expected != record.restart().delay() {
6126                        // Not a fault. Task Scheduler expresses this in whole
6127                        // minutes, so the delay in force is longer than the one
6128                        // asked for -- never shorter, which is the direction
6129                        // the requirement cares about.
6130                        notes.push(format!(
6131                            "{} expresses the restart delay in whole minutes, so the {}s asked \
6132                             for is enforced as {}s. The service therefore never restarts faster \
6133                             than the configured bound.",
6134                            found.manager,
6135                            record.restart().delay().as_secs(),
6136                            expected.as_secs()
6137                        ));
6138                    }
6139                }
6140                if found.starts_automatically && !found.running {
6141                    problems.push(StatusProblem {
6142                        subject: "runtime",
6143                        detail: format!(
6144                            "{} holds an automatic registration, but the daemon is stopped. Run \
6145                             `runner-manager service install --start-at {}`; if the service \
6146                             manager denies replacement, retry from an elevated terminal. If it \
6147                             stops again, inspect {}.",
6148                            found.manager,
6149                            found.start_mode,
6150                            log_file.display()
6151                        ),
6152                    });
6153                }
6154            }
6155            (None, None) => {}
6156        }
6157
6158        // The two facts here really are independent, which is what `f1`'s
6159        // `status.rs` says this check needs and could not have there: the scope
6160        // is derived from the mode the **record** carries, and it is compared
6161        // against the mode the **service manager** actually holds.
6162        let store = record.as_ref().and_then(|record| {
6163            let registered_mode = registration
6164                .as_ref()
6165                .map_or(record.start_mode, |found| found.start_mode);
6166            crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
6167                .ok()
6168                .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
6169        });
6170        if let Some(store) = &store
6171            && !store.agrees_with_start_mode()
6172        {
6173            problems.push(StatusProblem {
6174                subject: "secret store",
6175                detail: format!(
6176                    "{store}. Run `auth login` again so the token is stored where the registered \
6177                     start mode can read it."
6178                ),
6179            });
6180        }
6181
6182        if last_github_contact.is_none() {
6183            notes.push(
6184                "GitHub has not been reached successfully since this host's state directory was \
6185                 created."
6186                    .to_string(),
6187            );
6188        }
6189
6190        // The privileged inspection output. Read-only, tolerant of an account
6191        // that may not read the descriptor at all, and never a `problem`: a
6192        // root this account cannot inspect is a true statement about this
6193        // account's rights, not a fault in the registration. What refuses an
6194        // install is the preflight in `runner_root_access`, which runs as the
6195        // installing account and has the authority to know.
6196        let runner_root = crate::runner_root::default_runner_root(paths)
6197            .ok()
6198            .map(|root| {
6199                let path = root.as_path().to_path_buf();
6200                let report = crate::runner_root_access::report(&path);
6201                (path, report)
6202            });
6203        // Reported, and deliberately not a `problem`. The Definition of Done
6204        // asks for a broad root to be "reported and fail the security
6205        // preflight", and the security preflight is `install`'s -- which runs
6206        // as the installing account and refuses outright. `service status` runs
6207        // as whoever typed it, is expected to be readable on a host with
6208        // nothing installed at all, and drives an exit code; turning a
6209        // directory that predates this feature into a non-zero exit for a
6210        // machine that has never installed the service would report a fault
6211        // that is not this registration's.
6212        if let Some((
6213            path,
6214            RootAccessReport::Present {
6215                broad_write: true, ..
6216            },
6217        )) = &runner_root
6218        {
6219            notes.push(format!(
6220                "the platform default runner root {} can be written by ordinary local users, so \
6221                 it is not a safe place to run jobs. `service install` refuses it rather than \
6222                 tightening it, because the contents of a directory anybody could write cannot \
6223                 be trusted: remove or empty it, or choose another root with `runner-manager \
6224                 host set-runtime-root --path <PATH>`.",
6225                path.display()
6226            ));
6227        }
6228
6229        // The surface the agent has nowhere else to reach. A root that refuses
6230        // a launch does so before any attempt row exists, and the daemon's log
6231        // redacts the paths out of the sentence, so without this the operator
6232        // sees `runner_start_failed reason=other` once per poll and nothing
6233        // that names the directory or the fix.
6234        //
6235        // A **note** and not a problem, by the same rule the broad-write root
6236        // above obeys: `service status` runs as whoever typed it, is expected
6237        // to be readable on a host with nothing installed at all, and drives an
6238        // exit code. A problem here would fail that exit code on three hosts
6239        // that are not broken -- one whose root was fixed but which has had no
6240        // queued job since, one whose service was uninstalled without clearing
6241        // `state/`, and one that only ever ran the agent in the foreground --
6242        // and the printed remedy (`service uninstall && service install`) does
6243        // not touch `state/`, so following it would not clear the error either.
6244        // A note is read by the same operator at the same moment and traps
6245        // nobody in a loop.
6246        //
6247        // A read failure is reported and not swallowed, for the reason
6248        // `runner_root_refusals` documents: absence means "every policy is
6249        // placing runners", and answering a parse failure with that would be
6250        // the wrong answer to the question this row exists for.
6251        match runner_root_refusals(paths) {
6252            Ok(refusals) => {
6253                for refusal in refusals {
6254                    notes.push(format!(
6255                        "policy {} started no runner: its runner root {} refused the launch \
6256                         ({}), last at {}. {} This clears when that policy next places a \
6257                         runner.",
6258                        refusal.policy,
6259                        refusal.root,
6260                        refusal.kind,
6261                        refusal.at.to_rfc3339(),
6262                        refusal.detail,
6263                    ));
6264                }
6265            }
6266            Err(error) => notes.push(format!(
6267                "whether the agent could use its runner roots could not be read: {error}"
6268            )),
6269        }
6270
6271        Self {
6272            identity,
6273            record,
6274            registration,
6275            binary,
6276            log_file,
6277            store,
6278            last_github_contact,
6279            runner_root,
6280            credential_rejected_since: None,
6281            launches_blocked: None,
6282            unattended_gap: None,
6283            foreign: None,
6284            problems,
6285            notes,
6286        }
6287    }
6288
6289    /// Marks a status read from another installation's directories as that
6290    /// installation's. See [`ForeignService`].
6291    ///
6292    /// Its problems were written for its owner, and their remedies -- `service
6293    /// install`, `auth login`, re-registering a drifted definition -- act on
6294    /// the directories of whoever runs them. Printed to this account, which
6295    /// reaches here precisely by following "run it elevated", they would
6296    /// re-register the machine's service against this account's data root.
6297    /// So each keeps its diagnosis and loses its remedy, and says where the
6298    /// remedy has to be run instead.
6299    fn with_foreign(mut self, foreign: ForeignService) -> Self {
6300        let act_as = foreign.act_as();
6301        for problem in &mut self.problems {
6302            problem.detail = format!(
6303                "{}. Act on it {act_as}: a remedy run from this account acts on this account's \
6304                 own directories, not the service's.",
6305                diagnosis_only(&problem.detail)
6306            );
6307        }
6308        if let Some(note) = foreign.unused_local_note() {
6309            self.notes.push(note);
6310        }
6311        self.foreign = Some(foreign);
6312        self
6313    }
6314
6315    /// A registration made from another data root whose records this account
6316    /// cannot read: what the service manager reports, and nothing else.
6317    ///
6318    /// The only problems are ones the service manager alone settles -- an
6319    /// automatic registration that is stopped, a boot registration that will
6320    /// not start by itself -- and their remedy is to look as the owner, never
6321    /// to reinstall from here.
6322    fn foreign_unread(
6323        identity: ServiceIdentity,
6324        registration: Registration,
6325        foreign: ForeignService,
6326    ) -> Self {
6327        let mut problems = Vec::new();
6328        let look = foreign.where_to_look();
6329        if registration.starts_automatically && !registration.running {
6330            problems.push(StatusProblem {
6331                subject: "runtime",
6332                detail: format!(
6333                    "{} holds an automatic registration for the service installed from {}, but \
6334                     it is stopped. Run `runner-manager service status` {look} to see why; do not \
6335                     reinstall it from this account.",
6336                    registration.manager,
6337                    foreign.installed_by(),
6338                ),
6339            });
6340        }
6341        if registration.start_mode == StartMode::Boot && !registration.starts_automatically {
6342            problems.push(StatusProblem {
6343                subject: "start mode",
6344                detail: format!(
6345                    "{} holds the boot registration installed from {} but will not start it by \
6346                     itself. Run `runner-manager service status` {look}.",
6347                    registration.manager,
6348                    foreign.installed_by(),
6349                ),
6350            });
6351        }
6352        let mut notes = Vec::new();
6353        if let Some(reason) = &foreign.unreadable {
6354            notes.push(format!(
6355                "its health is not known here: {reason}. Run `runner-manager service status` \
6356                 {look} to see it."
6357            ));
6358        }
6359        notes.extend(foreign.unused_local_note());
6360        Self {
6361            identity,
6362            record: None,
6363            log_file: foreign.directories.log_file(),
6364            registration: Some(registration),
6365            binary: None,
6366            store: None,
6367            last_github_contact: None,
6368            runner_root: None,
6369            credential_rejected_since: None,
6370            launches_blocked: None,
6371            unattended_gap: None,
6372            foreign: Some(foreign),
6373            problems,
6374            notes,
6375        }
6376    }
6377
6378    /// The other installation this machine's service belongs to, when it is
6379    /// not this account's. See [`ForeignService`].
6380    #[must_use]
6381    pub const fn foreign(&self) -> Option<&ForeignService> {
6382        self.foreign.as_ref()
6383    }
6384
6385    /// Whether this is another installation's service whose own records this
6386    /// account could not read, so only the service manager's facts are here.
6387    #[must_use]
6388    pub fn is_unread(&self) -> bool {
6389        self.foreign.as_ref().is_some_and(ForeignService::is_unread)
6390    }
6391
6392    /// The one-word-or-so verdict `service status` ends with.
6393    #[must_use]
6394    pub fn verdict(&self) -> String {
6395        if !self.is_healthy() {
6396            return "NOT healthy".to_owned();
6397        }
6398        match &self.foreign {
6399            Some(foreign) if foreign.is_unread() => format!(
6400                "{}; installed from {}, so its health is read {}",
6401                if self.is_running() {
6402                    "running"
6403                } else {
6404                    "not running"
6405                },
6406                foreign.installed_by(),
6407                foreign.where_to_look()
6408            ),
6409            _ => "healthy".to_owned(),
6410        }
6411    }
6412
6413    /// Reports a definition this build would render differently.
6414    #[must_use]
6415    pub fn with_definition_drift(mut self, drift: Option<DefinitionDrift>) -> Self {
6416        if let Some(drift) = drift {
6417            if drift.manager == DefinitionKind::LaunchdPlist {
6418                // A restart, not `service install`: the running daemon holds the
6419                // single-instance lock, which refuses an install, and a daemon
6420                // that starts with no runner repairs the plist itself.
6421                let restart = match drift.start_mode {
6422                    StartMode::Boot => format!(
6423                        "sudo launchctl kickstart -k system/{}",
6424                        self.identity.launchd_label()
6425                    ),
6426                    StartMode::Login => format!(
6427                        "launchctl kickstart -k gui/$(id -u)/{}",
6428                        self.identity.launchd_label()
6429                    ),
6430                };
6431                self.problems.push(StatusProblem {
6432                    subject: "definition",
6433                    detail: format!(
6434                        "{} was written by runner-manager {} with a `ProcessType` other than \
6435                         `{LAUNCHD_PROCESS_TYPE}`, which launchd applies to every runner the \
6436                         daemon starts, so their jobs run below normal priority. The daemon \
6437                         rewrites it and reloads itself when it starts with no runner, which it \
6438                         does after every upgrade; to do it now, restart the service while no \
6439                         job is running: `{restart}`. Configuration and credentials are kept.",
6440                        drift.path.display(),
6441                        drift.installed_by_version,
6442                    ),
6443                });
6444                return self;
6445            }
6446            let repair = match drift.start_mode {
6447                StartMode::Boot => format!(
6448                    "`sudo systemctl stop runner-manager && {}` (for a managed WSL host, run \
6449                     `runner-manager wsl install --distribution <name>` from Windows)",
6450                    sudo_command("service install --start-at boot")
6451                ),
6452                StartMode::Login => "`systemctl --user stop runner-manager && runner-manager \
6453                                     service install --start-at login`"
6454                    .to_owned(),
6455            };
6456            self.problems.push(StatusProblem {
6457                subject: "definition",
6458                detail: format!(
6459                    "{} was written by runner-manager {} and differs from what {} renders, so \
6460                     the daemon runs under outdated settings (for example a credential frozen \
6461                     at service start). Re-register it with {repair}; configuration and \
6462                     credentials are kept.",
6463                    drift.path.display(),
6464                    drift.installed_by_version,
6465                    env!("CARGO_PKG_VERSION"),
6466                ),
6467            });
6468        }
6469        self
6470    }
6471
6472    /// Reports that GitHub is rejecting the daemon's credential.
6473    #[must_use]
6474    pub fn with_credential_rejection(mut self, since: Option<DateTime<Utc>>) -> Self {
6475        if let Some(since) = since {
6476            self.credential_rejected_since = Some(since);
6477            self.problems.push(StatusProblem {
6478                subject: "github credential",
6479                detail: format!(
6480                    "GitHub has rejected the service's credential since {}, so the service \
6481                     cannot see queued jobs or start runners. Run `runner-manager auth login` \
6482                     (for a managed WSL host, `runner-manager --host wsl:<name> auth login` \
6483                     from Windows).",
6484                    since.to_rfc3339()
6485                ),
6486            });
6487        }
6488        self
6489    }
6490
6491    /// Since when GitHub has rejected the service's credential, if it has.
6492    #[must_use]
6493    pub const fn credential_rejected_since(&self) -> Option<DateTime<Utc>> {
6494        self.credential_rejected_since
6495    }
6496
6497    /// Reports that the daemon starts no runner because its launches are
6498    /// blocked. See [`crate::launch_health`].
6499    #[must_use]
6500    pub fn with_launches_blocked(
6501        mut self,
6502        blocked: Option<crate::launch_health::LaunchesBlocked>,
6503    ) -> Self {
6504        if let Some(blocked) = &blocked {
6505            self.problems.push(StatusProblem {
6506                subject: LAUNCHES_BLOCKED_SUBJECT,
6507                detail: format!(
6508                    "the service has started no runner {blocked}. Fix: {}.",
6509                    blocked.remedy
6510                ),
6511            });
6512        }
6513        self.launches_blocked = blocked;
6514        self
6515    }
6516
6517    /// Why the service starts no runner, when its launches are blocked.
6518    #[must_use]
6519    pub const fn launches_blocked(&self) -> Option<&crate::launch_health::LaunchesBlocked> {
6520        self.launches_blocked.as_ref()
6521    }
6522
6523    /// Reports that the daemon cannot read its own stored credential, and the
6524    /// exact command that ends it. See [`record_credential_unreadable`].
6525    #[must_use]
6526    pub fn with_credential_unreadable(mut self, since: Option<DateTime<Utc>>) -> Self {
6527        if let Some(since) = since {
6528            let mode = self.start_mode().unwrap_or_default();
6529            self.problems.push(StatusProblem {
6530                subject: CREDENTIAL_UNREADABLE_SUBJECT,
6531                detail: format!(
6532                    "the service has been unable to read its stored GitHub credential since {}, \
6533                     so it starts no runner; the diagnostic log has the store's own answer.{} \
6534                     Sign in again: {}.",
6535                    since.to_rfc3339(),
6536                    if cfg!(target_os = "macos") {
6537                        " That is what a keychain item written by a different build looks like."
6538                    } else {
6539                        ""
6540                    },
6541                    sign_in_instruction(mode)
6542                ),
6543            });
6544        }
6545        self
6546    }
6547
6548    /// What this host's default runner root grants, and to whom.
6549    ///
6550    /// `None` when the platform default could not even be resolved. The
6551    /// descriptor inside has been through
6552    /// [`crate::runner_root_access::redact`], so it names the well-known
6553    /// trustees and says "an account" for everything else — no more identity
6554    /// than the `account` line above it already prints.
6555    #[must_use]
6556    pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
6557        self.runner_root
6558            .as_ref()
6559            .map(|(path, report)| (path.as_path(), report))
6560    }
6561
6562    /// Whether a registration exists at all.
6563    #[must_use]
6564    pub const fn is_installed(&self) -> bool {
6565        self.registration.is_some() || self.record.is_some()
6566    }
6567
6568    /// Whether the daemon is running now.
6569    #[must_use]
6570    pub fn is_running(&self) -> bool {
6571        self.registration
6572            .as_ref()
6573            .is_some_and(|registration| registration.running)
6574    }
6575
6576    /// **False whenever anything is wrong**, including a stale binary path.
6577    #[must_use]
6578    pub fn is_healthy(&self) -> bool {
6579        self.problems.is_empty()
6580    }
6581
6582    /// Everything that is wrong.
6583    #[must_use]
6584    pub fn problems(&self) -> &[StatusProblem] {
6585        &self.problems
6586    }
6587
6588    /// True statements that are not faults — a login-mode registration not
6589    /// running unattended, or an agent that has not yet reached GitHub.
6590    #[must_use]
6591    pub fn notes(&self) -> &[String] {
6592        &self.notes
6593    }
6594
6595    /// Reports that the daemon starts no runner because a required host check
6596    /// fails from its own session. That is the service not doing its job, so
6597    /// it is a problem: a host whose daemon refused every runner used to read
6598    /// `verdict healthy`.
6599    #[must_use]
6600    pub fn with_host_unfit(mut self, unfit: Option<crate::host_fitness::HostUnfitRecord>) -> Self {
6601        if let Some(unfit) = unfit {
6602            self.problems.push(StatusProblem {
6603                subject: HOST_UNFIT_SUBJECT,
6604                detail: format!(
6605                    "the service has started no runner since {}, because a required host check \
6606                     fails from its own session: {}",
6607                    unfit.since.to_rfc3339(),
6608                    unfit.describe()
6609                ),
6610            });
6611        }
6612        self
6613    }
6614
6615    /// Reports a daemon that is running and has stopped making progress. See
6616    /// [`crate::daemon_heartbeat`]: launchd, systemd and the SCM all report
6617    /// such a daemon running, so this used to read `verdict healthy`.
6618    #[must_use]
6619    pub fn with_stall(mut self, stall: Option<String>) -> Self {
6620        if let Some(detail) = stall {
6621            self.problems.push(StatusProblem {
6622                subject: DAEMON_STALLED_SUBJECT,
6623                detail,
6624            });
6625        }
6626        self
6627    }
6628
6629    /// Records whether this registration comes back by itself after an
6630    /// unattended restart, as a note. See [`unattended_gap`].
6631    #[must_use]
6632    pub fn with_unattended_gap(mut self, gap: Option<UnattendedGap>) -> Self {
6633        if let Some(gap) = &gap {
6634            self.notes.push(gap.reason.clone());
6635        }
6636        self.unattended_gap = gap;
6637        self
6638    }
6639
6640    /// Why this registration does not come back by itself after an
6641    /// unattended restart, when it does not.
6642    #[must_use]
6643    pub const fn unattended_gap(&self) -> Option<&UnattendedGap> {
6644        self.unattended_gap.as_ref()
6645    }
6646
6647    /// The service manager's command that restarts this registration, for its
6648    /// start mode (boot when unknown). See [`ServiceIdentity::restart_command`].
6649    #[must_use]
6650    pub fn restart_command(&self) -> String {
6651        self.identity
6652            .restart_command(self.start_mode().unwrap_or(StartMode::Boot))
6653    }
6654
6655    /// The recorded start mode. Journey 5 step 4. For another installation's
6656    /// service whose record this account cannot read, the mode the service
6657    /// manager's domain implies.
6658    #[must_use]
6659    pub fn start_mode(&self) -> Option<StartMode> {
6660        self.record
6661            .as_ref()
6662            .map(|record| record.start_mode)
6663            .or_else(|| {
6664                self.registration
6665                    .as_ref()
6666                    .filter(|_| self.is_unread())
6667                    .map(|registration| registration.start_mode)
6668            })
6669    }
6670
6671    /// The binary the service runs: the recorded one, or for another
6672    /// installation's service whose record this account cannot read, the one
6673    /// the service manager names.
6674    #[must_use]
6675    pub fn service_binary(&self) -> Option<PathBuf> {
6676        self.record
6677            .as_ref()
6678            .map(|record| record.binary.clone())
6679            .or_else(|| {
6680                self.registration
6681                    .as_ref()
6682                    .filter(|_| self.is_unread())
6683                    .and_then(Registration::binary)
6684            })
6685    }
6686
6687    /// The resolved absolute binary path and what became of it. Journey 5
6688    /// step 4 and `05-infrastructure.md` item 6.
6689    #[must_use]
6690    pub const fn binary(&self) -> Option<&BinaryPath> {
6691        self.binary.as_ref()
6692    }
6693
6694    /// The diagnostic log path. `05-infrastructure.md` item 4.
6695    #[must_use]
6696    pub fn log_file(&self) -> &Path {
6697        &self.log_file
6698    }
6699
6700    /// The last successful GitHub contact. Journey 5 step 4.
6701    #[must_use]
6702    pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
6703        self.last_github_contact
6704    }
6705
6706    /// Which store the daemon reads, and whether that agrees with the
6707    /// registration.
6708    #[must_use]
6709    pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
6710        self.store.as_ref()
6711    }
6712
6713    /// The record `install` wrote.
6714    #[must_use]
6715    pub const fn record(&self) -> Option<&InstallRecord> {
6716        self.record.as_ref()
6717    }
6718
6719    /// What the service manager says.
6720    #[must_use]
6721    pub const fn registration(&self) -> Option<&Registration> {
6722        self.registration.as_ref()
6723    }
6724}
6725
6726impl fmt::Display for ServiceStatus {
6727    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6728        writeln!(f, "Service: {}", self.identity)?;
6729        match (&self.record, &self.registration) {
6730            (None, None) => {
6731                writeln!(
6732                    f,
6733                    "  installed                 no. `service install` registers `{} {}`.",
6734                    SERVICE_NAME,
6735                    DAEMON_ARGUMENTS.join(" ")
6736                )?;
6737            }
6738            _ => {
6739                let manager = self
6740                    .registration
6741                    .as_ref()
6742                    .map(|registration| registration.manager.manager());
6743                writeln!(
6744                    f,
6745                    "  installed                 {}",
6746                    manager.unwrap_or("yes, but no service manager knows it")
6747                )?;
6748                writeln!(
6749                    f,
6750                    "  state                     {}",
6751                    if self.is_running() {
6752                        "running"
6753                    } else {
6754                        "not running"
6755                    }
6756                )?;
6757            }
6758        }
6759        if let Some(foreign) = &self.foreign {
6760            writeln!(
6761                f,
6762                "  installed from            {} -- another installation's, not this account's ({})",
6763                foreign.installed_by(),
6764                foreign.local_root.display()
6765            )?;
6766            // Without the record, the service manager's own facts.
6767            if foreign.is_unread()
6768                && let Some(registration) = &self.registration
6769            {
6770                writeln!(f, "  start mode                {}", registration.start_mode)?;
6771                if let Some(account) = &registration.account {
6772                    writeln!(f, "  account                   {account}")?;
6773                }
6774                if let Some(binary) = self.service_binary() {
6775                    writeln!(f, "  binary                    {}", binary.display())?;
6776                }
6777            }
6778        }
6779        if let Some(record) = &self.record {
6780            writeln!(f, "  start mode                {}", record.start_mode)?;
6781            let recorded = record.account.to_string();
6782            let account = registered_account(self.registration.as_ref(), &recorded);
6783            writeln!(f, "  account                   {account}")?;
6784            writeln!(f, "  restart on failure        {}", record.restart())?;
6785            writeln!(
6786                f,
6787                "  arguments                 {}",
6788                record.arguments.join(" ")
6789            )?;
6790        }
6791        if let Some(binary) = &self.binary {
6792            writeln!(f, "  binary                    {binary}")?;
6793        }
6794        writeln!(f, "  diagnostic log            {}", self.log_file.display())?;
6795        if let Some((path, report)) = &self.runner_root {
6796            // Named as the *default* rather than as "the runner root", because
6797            // it is only the effective one until an operator runs
6798            // `host set-runtime-root`. This crate cannot see that setting — it
6799            // lives in the application's store — so an unqualified label here
6800            // would contradict the `runner root … (host-configured)` row that
6801            // `status` and `host show` print from the value that is in force.
6802            writeln!(f, "  default runner root       {}", path.display())?;
6803            if *report != RootAccessReport::NotApplicable {
6804                writeln!(f, "  default root access       {report}")?;
6805            }
6806        }
6807        if let Some(store) = &self.store {
6808            writeln!(f, "  secret store              {store}")?;
6809        }
6810        // Not known, rather than "never", for a service whose records were not
6811        // read: its contact file is in a directory this account cannot open.
6812        if !self.is_unread() {
6813            writeln!(
6814                f,
6815                "  last GitHub contact       {}",
6816                match self.last_github_contact {
6817                    Some(at) => at.to_rfc3339(),
6818                    None => "never".to_string(),
6819                }
6820            )?;
6821        }
6822        for note in &self.notes {
6823            writeln!(f, "  note                      {note}")?;
6824        }
6825        for problem in &self.problems {
6826            writeln!(f, "  ERROR                     {problem}")?;
6827        }
6828        write!(f, "  verdict                   {}", self.verdict())
6829    }
6830}
6831
6832// ---------------------------------------------------------------------------
6833// The in-memory double
6834// ---------------------------------------------------------------------------
6835
6836/// A [`ControlFactory`] that registers nothing and remembers everything.
6837///
6838/// Public rather than `#[cfg(test)]` on purpose. `f3` builds three commands on
6839/// [`ServiceOperations`] and has to be able to test them without a service
6840/// manager, and a double that lived behind `#[cfg(test)]` here would be
6841/// invisible from another crate — which would leave `f3` either untested or
6842/// writing a second double that drifts from this one.
6843///
6844/// It is not a simulator. It stores what it was asked to register and reports it
6845/// back, which is exactly enough to exercise the logic that sits *above* a
6846/// service manager: the lock refusal, the record, the stale-path detection, the
6847/// start-mode switch, and the promise that uninstall deletes nothing else.
6848#[derive(Debug, Clone, Default)]
6849pub struct RecordingControls {
6850    state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
6851}
6852
6853#[derive(Debug, Default)]
6854struct RecordingState {
6855    registrations: BTreeMap<(StartMode, String), Registration>,
6856    definitions: BTreeMap<String, ServiceDefinition>,
6857    calls: Vec<String>,
6858    #[cfg(test)]
6859    install_failures: BTreeMap<StartMode, String>,
6860    #[cfg(test)]
6861    after_install: BTreeMap<StartMode, TestInstallSideEffect>,
6862}
6863
6864#[cfg(test)]
6865#[derive(Debug, Clone)]
6866enum TestInstallSideEffect {
6867    HideDirectory { directory: PathBuf, hidden: PathBuf },
6868}
6869
6870impl RecordingControls {
6871    /// A factory holding no registrations.
6872    #[must_use]
6873    pub fn new() -> Self {
6874        Self::default()
6875    }
6876
6877    /// Every call made through this factory, in order, as
6878    /// `"<operation> <name> (<mode>)"`.
6879    #[must_use]
6880    pub fn calls(&self) -> Vec<String> {
6881        self.state.lock().expect("not poisoned").calls.clone()
6882    }
6883
6884    /// Every registration currently held, with the domain it is in.
6885    #[must_use]
6886    pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
6887        self.state
6888            .lock()
6889            .expect("not poisoned")
6890            .registrations
6891            .iter()
6892            .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
6893            .collect()
6894    }
6895
6896    /// The definition applied for a registration, if it is held.
6897    #[must_use]
6898    pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
6899        self.state
6900            .lock()
6901            .expect("not poisoned")
6902            .definitions
6903            .get(name)
6904            .cloned()
6905    }
6906
6907    /// Edits a held registration, as something outside this product would.
6908    ///
6909    /// This is how the divergence and start-type problems are made reachable
6910    /// from a test: `sc config`, `launchctl` and `systemctl` can all change a
6911    /// registration after installation, and a status command that could not be
6912    /// shown detecting that would be a status command nobody had tested against
6913    /// the case it exists for.
6914    ///
6915    /// Does nothing when no registration of that name is held.
6916    pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
6917        let mut state = self.state.lock().expect("not poisoned");
6918        if let Some((_, registration)) = state
6919            .registrations
6920            .iter_mut()
6921            .find(|((_, held), _)| held == name)
6922        {
6923            edit(registration);
6924        }
6925    }
6926
6927    #[cfg(test)]
6928    fn fail_next_install(&self, mode: StartMode, detail: &str) {
6929        self.state
6930            .lock()
6931            .expect("not poisoned")
6932            .install_failures
6933            .insert(mode, detail.to_string());
6934    }
6935
6936    #[cfg(test)]
6937    fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
6938        self.state
6939            .lock()
6940            .expect("not poisoned")
6941            .after_install
6942            .insert(
6943                mode,
6944                TestInstallSideEffect::HideDirectory { directory, hidden },
6945            );
6946    }
6947}
6948
6949impl ControlFactory for RecordingControls {
6950    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6951        Ok(Box::new(RecordingControl {
6952            mode,
6953            state: std::sync::Arc::clone(&self.state),
6954        }))
6955    }
6956}
6957
6958#[derive(Debug)]
6959struct RecordingControl {
6960    mode: StartMode,
6961    state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
6962}
6963
6964impl RecordingControl {
6965    fn note(&self, operation: &str, name: &str) {
6966        self.state
6967            .lock()
6968            .expect("not poisoned")
6969            .calls
6970            .push(format!("{operation} {name} ({})", self.mode));
6971    }
6972}
6973
6974impl ServiceControl for RecordingControl {
6975    fn manager(&self) -> DefinitionKind {
6976        // The double reports the kind this host's real backend would, so a test
6977        // that asserts on the manager name asserts something true of the
6978        // platform it is running on.
6979        host_definition_kind(self.mode)
6980    }
6981
6982    fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6983        self.note("install", plan.identity().name());
6984        #[cfg(test)]
6985        if let Some(detail) = self
6986            .state
6987            .lock()
6988            .expect("not poisoned")
6989            .install_failures
6990            .remove(&self.mode)
6991        {
6992            return Err(ServiceError::Control {
6993                operation: "install",
6994                name: plan.identity().name().to_string(),
6995                manager: "recording control",
6996                detail,
6997            });
6998        }
6999        // The real definition, not a stub. Rendering is pure, so the double can
7000        // afford it -- and a stub would make `Installed::review` report that the
7001        // definition confirms nothing, which is exactly the shape of false
7002        // assurance a test double must never hand back.
7003        let definition = ServiceDefinition::for_host(plan)?;
7004        let mut state = self.state.lock().expect("not poisoned");
7005        state.registrations.insert(
7006            (self.mode, plan.identity().name().to_string()),
7007            Registration {
7008                manager: host_definition_kind(self.mode),
7009                start_mode: self.mode,
7010                command_line: plan.command_line(),
7011                account: (*plan.account() != ServiceAccount::InvokingUser)
7012                    .then(|| plan.account().as_str().to_string()),
7013                running: false,
7014                starts_automatically: true,
7015                restart_delay: Some(plan.restart().delay()),
7016            },
7017        );
7018        state
7019            .definitions
7020            .insert(plan.identity().name().to_string(), definition.clone());
7021        #[cfg(test)]
7022        let side_effect = state.after_install.remove(&self.mode);
7023        drop(state);
7024        #[cfg(test)]
7025        if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
7026            std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
7027            std::fs::write(&directory, b"blocks recreation")
7028                .expect("test fault can block record directory recreation");
7029        }
7030        Ok(definition)
7031    }
7032
7033    fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7034        self.note("uninstall", identity.name());
7035        let mut state = self.state.lock().expect("not poisoned");
7036        state.definitions.remove(identity.name());
7037        Ok(state
7038            .registrations
7039            .remove(&(self.mode, identity.name().to_string()))
7040            .is_some())
7041    }
7042
7043    fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7044        self.note("query", identity.name());
7045        Ok(self
7046            .state
7047            .lock()
7048            .expect("not poisoned")
7049            .registrations
7050            .get(&(self.mode, identity.name().to_string()))
7051            .cloned())
7052    }
7053
7054    fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7055        self.note("start", identity.name());
7056        let mut state = self.state.lock().expect("not poisoned");
7057        match state
7058            .registrations
7059            .get_mut(&(self.mode, identity.name().to_string()))
7060        {
7061            Some(registration) => {
7062                registration.running = true;
7063                Ok(())
7064            }
7065            None => Err(ServiceError::NotInstalled {
7066                name: identity.name().to_string(),
7067                operation: "start",
7068            }),
7069        }
7070    }
7071
7072    fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7073        self.note("stop", identity.name());
7074        let mut state = self.state.lock().expect("not poisoned");
7075        match state
7076            .registrations
7077            .get_mut(&(self.mode, identity.name().to_string()))
7078        {
7079            Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
7080            None => Err(ServiceError::NotInstalled {
7081                name: identity.name().to_string(),
7082                operation: "stop",
7083            }),
7084        }
7085    }
7086}
7087
7088impl ControlFactory for HostControls {
7089    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7090        sys::control(mode)
7091    }
7092
7093    fn installed_definition(&self, path: &Path) -> Option<String> {
7094        std::fs::read_to_string(path).ok()
7095    }
7096
7097    fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
7098        crate::unattended_login::probe()
7099    }
7100}
7101
7102/// The operator's home directory, when the platform reports one.
7103///
7104/// Only login-mode registrations need it — a LaunchAgent and a systemd user
7105/// unit both live under it — and it is resolved lazily so that a boot-mode
7106/// operation on an account with no profile is not refused for wanting something
7107/// it never uses.
7108#[must_use]
7109pub fn host_home() -> Option<PathBuf> {
7110    directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
7111}
7112
7113/// The Unix backends' name for the same thing.
7114#[cfg(unix)]
7115fn home_directory() -> Option<PathBuf> {
7116    host_home()
7117}
7118
7119/// Runs a command and returns its exit status with both streams as text.
7120///
7121/// The three Unix backends and the Windows Task Scheduler backend all drive a
7122/// stock command-line tool, and all four want the same three things back. The
7123/// tools are chosen for machine-readable, locale-independent output wherever one
7124/// exists — `systemctl is-active`, `launchctl print`, `schtasks /XML` — because
7125/// parsing a localised human-facing table is how a status command starts lying
7126/// on somebody else's machine.
7127fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
7128    let output = std::process::Command::new(program)
7129        .args(arguments)
7130        .output()?;
7131    Ok((
7132        output.status.success(),
7133        String::from_utf8_lossy(&output.stdout).into_owned(),
7134        String::from_utf8_lossy(&output.stderr).into_owned(),
7135    ))
7136}
7137
7138/// Which manager holds a given start-mode domain on the platform this binary
7139/// was built for.
7140#[must_use]
7141pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
7142    if cfg!(windows) {
7143        match mode {
7144            StartMode::Boot => DefinitionKind::WindowsService,
7145            StartMode::Login => DefinitionKind::WindowsScheduledTask,
7146        }
7147    } else if cfg!(target_os = "macos") {
7148        DefinitionKind::LaunchdPlist
7149    } else {
7150        DefinitionKind::SystemdUnit
7151    }
7152}
7153
7154/// Enables a launchd label after it has been bootstrapped.
7155///
7156/// `launchctl bootstrap` deliberately preserves a label's disabled bit. An
7157/// `enable` failure therefore means the new registration cannot satisfy its
7158/// start policy. Compensate by booting it out and removing the plist so a
7159/// failed install cannot look installed to `service status`.
7160#[cfg(any(target_os = "macos", test))]
7161fn enable_launchd_registration(
7162    mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
7163    domain: &str,
7164    service_target: &str,
7165    plist: &Path,
7166    name: &str,
7167    elevation_remedy: &'static str,
7168) -> Result<(), ServiceError> {
7169    let (enabled, cause) = launchctl(&[
7170        std::ffi::OsStr::new("enable"),
7171        std::ffi::OsStr::new(service_target),
7172    ]);
7173    if enabled {
7174        return Ok(());
7175    }
7176
7177    let (booted_out, bootout_detail) = launchctl(&[
7178        std::ffi::OsStr::new("bootout"),
7179        std::ffi::OsStr::new(service_target),
7180    ]);
7181    let removed = std::fs::remove_file(plist);
7182    if !booted_out || removed.is_err() {
7183        return Err(ServiceError::Rollback {
7184            operation: "enable launchd registration",
7185            name: name.to_string(),
7186            cause,
7187            rollback: format!(
7188                "launchctl bootout {domain}: {}; remove {}: {}",
7189                if booted_out {
7190                    "succeeded".to_string()
7191                } else {
7192                    bootout_detail
7193                },
7194                plist.display(),
7195                removed
7196                    .err()
7197                    .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
7198            ),
7199        });
7200    }
7201
7202    if cause.to_ascii_lowercase().contains("permission denied") {
7203        Err(ServiceError::NeedsElevation {
7204            operation: "enable",
7205            name: name.to_string(),
7206            detail: cause,
7207            remedy: elevation_remedy,
7208        })
7209    } else {
7210        Err(ServiceError::Control {
7211            operation: "enable",
7212            name: name.to_string(),
7213            manager: "launchd",
7214            detail: cause,
7215        })
7216    }
7217}
7218
7219// ---------------------------------------------------------------------------
7220// Windows
7221// ---------------------------------------------------------------------------
7222
7223/// A stop request delivered by the Windows Service Control Manager.
7224///
7225/// The application owns the drain policy; this platform boundary only turns
7226/// `SERVICE_CONTROL_STOP`/`SHUTDOWN` into an awaitable notification.
7227#[derive(Debug, Clone)]
7228pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
7229
7230impl ServiceShutdown {
7231    /// Waits until SCM asks the service to stop or shut down.
7232    pub async fn wait(mut self) {
7233        if !*self.0.borrow() {
7234            let _ = self.0.changed().await;
7235        }
7236    }
7237}
7238
7239/// Runs the production process as a real Windows service host.
7240///
7241/// `StartServiceCtrlDispatcher` must run in the service process's main thread,
7242/// so the callback is handed through a process-global slot to the entrypoint
7243/// invoked by SCM. The slot is single-use by design: one process hosts exactly
7244/// one own-process service.
7245#[cfg(windows)]
7246pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
7247where
7248    F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
7249{
7250    windows_host::run(Box::new(run))
7251}
7252
7253#[cfg(windows)]
7254mod windows_host {
7255    use std::ffi::OsString;
7256    use std::sync::{Arc, Mutex, OnceLock, mpsc};
7257    use std::time::Duration;
7258
7259    use windows_service::service::{
7260        ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
7261        ServiceType,
7262    };
7263    use windows_service::service_control_handler::{
7264        self, ServiceControlHandlerResult, ServiceStatusHandle,
7265    };
7266
7267    use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
7268
7269    type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
7270
7271    struct Invocation {
7272        run: Runner,
7273        result: mpsc::SyncSender<Result<u8, String>>,
7274    }
7275
7276    static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
7277
7278    windows_service::define_windows_service!(ffi_service_main, service_main);
7279
7280    pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
7281        let (result_tx, result_rx) = mpsc::sync_channel(1);
7282        let slot = INVOCATION.get_or_init(|| Mutex::new(None));
7283        let mut invocation = slot
7284            .lock()
7285            .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
7286        if invocation.is_some() {
7287            return Err(host_error(
7288                "prepare",
7289                "the service-host slot was already used",
7290            ));
7291        }
7292        *invocation = Some(Invocation {
7293            run,
7294            result: result_tx,
7295        });
7296        drop(invocation);
7297
7298        windows_service::service_dispatcher::start("", ffi_service_main)
7299            .map_err(|error| host_error("connect", &error.to_string()))?;
7300        result_rx
7301            .recv()
7302            .map_err(|error| host_error("finish", &error.to_string()))?
7303            .map_err(|detail| host_error("run", &detail))
7304    }
7305
7306    fn service_main(_arguments: Vec<OsString>) {
7307        let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
7308            return;
7309        };
7310        let result = run_service(invocation.run);
7311        let _ = invocation.result.send(result);
7312    }
7313
7314    fn run_service(run: Runner) -> Result<u8, String> {
7315        let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
7316        let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
7317        let handler_status = Arc::clone(&status);
7318        let handler = move |control| match control {
7319            ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
7320            ServiceControl::Stop | ServiceControl::Shutdown => {
7321                if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
7322                    let _ = handle.set_service_status(service_status(
7323                        ServiceState::StopPending,
7324                        ServiceControlAccept::empty(),
7325                        1,
7326                        Duration::from_secs(300),
7327                        0,
7328                    ));
7329                }
7330                let _ = stop_tx.send(true);
7331                ServiceControlHandlerResult::NoError
7332            }
7333            _ => ServiceControlHandlerResult::NotImplemented,
7334        };
7335        let handle = service_control_handler::register("", handler)
7336            .map_err(|error| format!("cannot register the service control handler: {error}"))?;
7337        *status
7338            .lock()
7339            .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
7340        handle
7341            .set_service_status(service_status(
7342                ServiceState::Running,
7343                ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
7344                0,
7345                Duration::default(),
7346                0,
7347            ))
7348            .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
7349
7350        let exit = run(ServiceShutdown(stop_rx));
7351        handle
7352            .set_service_status(service_status(
7353                ServiceState::Stopped,
7354                ServiceControlAccept::empty(),
7355                0,
7356                Duration::default(),
7357                u32::from(exit),
7358            ))
7359            .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
7360        Ok(exit)
7361    }
7362
7363    fn service_status(
7364        state: ServiceState,
7365        accepted: ServiceControlAccept,
7366        checkpoint: u32,
7367        wait_hint: Duration,
7368        exit: u32,
7369    ) -> ServiceStatus {
7370        ServiceStatus {
7371            service_type: ServiceType::OWN_PROCESS,
7372            current_state: state,
7373            controls_accepted: accepted,
7374            exit_code: if exit == 0 {
7375                ServiceExitCode::Win32(0)
7376            } else {
7377                ServiceExitCode::ServiceSpecific(exit)
7378            },
7379            checkpoint,
7380            wait_hint,
7381            process_id: None,
7382        }
7383    }
7384
7385    fn host_error(operation: &'static str, detail: &str) -> ServiceError {
7386        ServiceError::Control {
7387            operation,
7388            name: SERVICE_NAME.to_string(),
7389            manager: "the Windows Service Control Manager",
7390            detail: detail.to_string(),
7391        }
7392    }
7393}
7394
7395#[cfg(windows)]
7396mod sys {
7397    //! Two managers, because Windows has two answers.
7398    //!
7399    //! `--start-at boot` is a service in the Service Control Manager, which is
7400    //! the only Windows facility that starts something before anybody logs in.
7401    //! `--start-at login` is a Task Scheduler task with a logon trigger, which
7402    //! is the only Windows facility that starts something when somebody does.
7403    //! There is no single mechanism that does both: service trigger-start has no
7404    //! logon trigger, and a scheduled task cannot run before a session exists.
7405
7406    use std::ffi::{OsStr, OsString};
7407    use std::time::{Duration, Instant};
7408
7409    use runner_manager_domain::model::StartMode;
7410    use windows_service::service::{
7411        ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
7412        ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
7413        ServiceState, ServiceType,
7414    };
7415    use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
7416
7417    use super::{
7418        DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
7419        ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
7420    };
7421
7422    /// `ERROR_SERVICE_DOES_NOT_EXIST`. Not an error here: it is the answer to
7423    /// "is this registered".
7424    const SERVICE_DOES_NOT_EXIST: i32 = 1060;
7425    /// `ERROR_SERVICE_MARKED_FOR_DELETE`. `DeleteService` is asynchronous: the
7426    /// registration remains in this state until every open service handle is
7427    /// closed, and callers must not mistake that short window for a leak.
7428    const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
7429    /// `ERROR_ACCESS_DENIED`.
7430    const ACCESS_DENIED: i32 = 5;
7431    const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
7432    const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
7433
7434    const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
7435         choose \"Run as administrator\".";
7436
7437    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7438        Ok(match mode {
7439            StartMode::Boot => Box::new(ScmControl),
7440            StartMode::Login => Box::new(TaskControl),
7441        })
7442    }
7443
7444    // -- the Service Control Manager -----------------------------------------
7445
7446    #[derive(Debug)]
7447    struct ScmControl;
7448
7449    /// Turns a `windows-service` failure into this module's error, keeping the
7450    /// "needs elevation" case separate: an operator told "access is denied" and
7451    /// an operator told "run this elevated" are not equally well served.
7452    fn scm_error(
7453        operation: &'static str,
7454        name: &str,
7455        error: &windows_service::Error,
7456    ) -> ServiceError {
7457        let raw = match error {
7458            windows_service::Error::Winapi(io) => io.raw_os_error(),
7459            _ => None,
7460        };
7461        let detail = match error {
7462            windows_service::Error::Winapi(io) => io.to_string(),
7463            other => other.to_string(),
7464        };
7465        if raw == Some(ACCESS_DENIED) {
7466            return ServiceError::NeedsElevation {
7467                operation,
7468                name: name.to_string(),
7469                detail,
7470                remedy: ELEVATION_REMEDY,
7471            };
7472        }
7473        ServiceError::Control {
7474            operation,
7475            name: name.to_string(),
7476            manager: "the Windows Service Control Manager",
7477            detail,
7478        }
7479    }
7480
7481    fn open_manager(
7482        access: ServiceManagerAccess,
7483        operation: &'static str,
7484        name: &str,
7485    ) -> Result<ServiceManager, ServiceError> {
7486        ServiceManager::local_computer(None::<&OsStr>, access)
7487            .map_err(|error| scm_error(operation, name, &error))
7488    }
7489
7490    impl ServiceControl for ScmControl {
7491        fn manager(&self) -> DefinitionKind {
7492            DefinitionKind::WindowsService
7493        }
7494
7495        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7496            let spec = windows_service_spec(plan);
7497            let manager = open_manager(
7498                ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
7499                "install",
7500                &spec.name,
7501            )?;
7502            let info = ServiceInfo {
7503                name: OsString::from(&spec.name),
7504                display_name: OsString::from(&spec.display_name),
7505                // Never INTERACTIVE_PROCESS: see `windows_service_descriptor`.
7506                service_type: ServiceType::OWN_PROCESS,
7507                start_type: if spec.automatic_start {
7508                    ServiceStartType::AutoStart
7509                } else {
7510                    ServiceStartType::OnDemand
7511                },
7512                error_control: ServiceErrorControl::Normal,
7513                executable_path: plan.binary().to_path_buf(),
7514                launch_arguments: plan.arguments().to_vec(),
7515                dependencies: Vec::new(),
7516                // `None` is LocalSystem, which is the account `d2`'s DACL admits.
7517                account_name: spec.account.as_ref().map(OsString::from),
7518                // No password is ever taken, asked for, or stored. An account
7519                // that needed one would be an account this installer refuses.
7520                account_password: None,
7521            };
7522            let service = manager
7523                .create_service(
7524                    &info,
7525                    ServiceAccess::CHANGE_CONFIG
7526                        | ServiceAccess::QUERY_CONFIG
7527                        | ServiceAccess::QUERY_STATUS
7528                        | ServiceAccess::START
7529                        | ServiceAccess::STOP
7530                        | ServiceAccess::DELETE,
7531                )
7532                .map_err(|error| scm_error("install", &spec.name, &error))?;
7533            service
7534                .set_description(&spec.description)
7535                .map_err(|error| scm_error("describe", &spec.name, &error))?;
7536            service
7537                .update_failure_actions(ServiceFailureActions {
7538                    reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
7539                    reboot_msg: None,
7540                    command: None,
7541                    // Three identical restart actions rather than one: the
7542                    // Service Control Manager applies the first action to the
7543                    // first failure, the second to the second, and the last to
7544                    // every failure after that. One action would leave the
7545                    // second and later failures unhandled, which is a service
7546                    // that comes back once and then stays down.
7547                    actions: Some(vec![
7548                        ServiceAction {
7549                            action_type: ServiceActionType::Restart,
7550                            delay: spec.restart.delay(),
7551                        };
7552                        3
7553                    ]),
7554                })
7555                .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7556            // Without this the failure actions apply only to a crash, and a
7557            // daemon that exits non-zero is not a crash.
7558            service
7559                .set_failure_actions_on_non_crash_failures(true)
7560                .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7561            Ok(ServiceDefinition::windows_service(plan))
7562        }
7563
7564        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7565            let manager =
7566                open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
7567            let service = match manager.open_service(
7568                identity.name(),
7569                ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
7570            ) {
7571                Ok(service) => Some(service),
7572                Err(error) if is_missing(&error) => return Ok(false),
7573                // A previous caller has already asked SCM to delete this
7574                // registration. That is the desired state, not an uninstall
7575                // failure. Wait for SCM to release the name so a following
7576                // `service install` can use it immediately.
7577                Err(error) if is_marked_for_delete(&error) => None,
7578                Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
7579            };
7580            if let Some(service) = service {
7581                // A running service can be deleted, but it lingers until it stops.
7582                // Stopping first is what makes `uninstall` followed by `install`
7583                // work in one sitting, which is what the start-mode switch needs.
7584                if let Ok(status) = service.query_status()
7585                    && status.current_state != ServiceState::Stopped
7586                {
7587                    let _ = service.stop();
7588                }
7589                match service.delete() {
7590                    Ok(()) => {}
7591                    // DeleteService is idempotent from the operator's point of
7592                    // view. Another process can mark the service between our
7593                    // OpenService and DeleteService calls.
7594                    Err(error) if is_marked_for_delete(&error) => {}
7595                    Err(error) => {
7596                        return Err(scm_error("uninstall", identity.name(), &error));
7597                    }
7598                }
7599                drop(service);
7600            }
7601
7602            // `DeleteService` marks a registration for deletion and returns;
7603            // SCM removes it only after the last service handle closes. Drop
7604            // ours before polling, then wait for the observable postcondition
7605            // promised by `uninstall`: an immediate status check must not see
7606            // a registration that is merely on its way out. This also keeps a
7607            // stop/uninstall/install sequence deterministic on busy hosts.
7608            let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
7609                match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
7610                    Ok(service) => {
7611                        drop(service);
7612                        Ok(false)
7613                    }
7614                    Err(error) if is_missing(&error) => Ok(true),
7615                    Err(error) if is_marked_for_delete(&error) => Ok(false),
7616                    Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
7617                }
7618            })?;
7619            if !absent {
7620                return Err(ServiceError::Control {
7621                    operation: "verify uninstall of",
7622                    name: identity.name().to_string(),
7623                    manager: "the Windows Service Control Manager",
7624                    detail: format!(
7625                        "the registration was still visible {} seconds after DeleteService; \
7626                         retry `service uninstall` from an elevated prompt",
7627                        DELETE_TIMEOUT.as_secs()
7628                    ),
7629                });
7630            }
7631            Ok(true)
7632        }
7633
7634        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7635            let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
7636            let service = match manager.open_service(
7637                identity.name(),
7638                ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
7639            ) {
7640                Ok(service) => service,
7641                Err(error) if is_missing(&error) => return Ok(None),
7642                Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
7643            };
7644            let config = service
7645                .query_config()
7646                .map_err(|error| scm_error("inspect", identity.name(), &error))?;
7647            let status = service
7648                .query_status()
7649                .map_err(|error| scm_error("inspect", identity.name(), &error))?;
7650            let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
7651                actions
7652                    .actions
7653                    .and_then(|actions| actions.into_iter().next())
7654                    .filter(|action| action.action_type == ServiceActionType::Restart)
7655                    .map(|action| action.delay)
7656            });
7657            Ok(Some(Registration {
7658                manager: DefinitionKind::WindowsService,
7659                start_mode: StartMode::Boot,
7660                // `lpBinaryPathName` holds the executable *and* its arguments
7661                // as one string, which is why this module carries its own
7662                // parser rather than trusting `executable_path`'s name.
7663                command_line: config.executable_path.to_string_lossy().into_owned(),
7664                account: config
7665                    .account_name
7666                    .map(|account| super::windows_service_account(&account.to_string_lossy())),
7667                running: status.current_state == ServiceState::Running,
7668                starts_automatically: config.start_type == ServiceStartType::AutoStart,
7669                restart_delay,
7670            }))
7671        }
7672
7673        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7674            let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
7675            let service = manager
7676                .open_service(identity.name(), ServiceAccess::START)
7677                .map_err(|error| scm_error("start", identity.name(), &error))?;
7678            service
7679                .start::<&OsStr>(&[])
7680                .map_err(|error| scm_error("start", identity.name(), &error))
7681        }
7682
7683        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7684            let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
7685            let service = manager
7686                .open_service(
7687                    identity.name(),
7688                    ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
7689                )
7690                .map_err(|error| scm_error("stop", identity.name(), &error))?;
7691            let status = service
7692                .query_status()
7693                .map_err(|error| scm_error("stop", identity.name(), &error))?;
7694            if status.current_state == ServiceState::Stopped {
7695                return Ok(false);
7696            }
7697            service
7698                .stop()
7699                .map_err(|error| scm_error("stop", identity.name(), &error))?;
7700            Ok(true)
7701        }
7702    }
7703
7704    fn is_missing(error: &windows_service::Error) -> bool {
7705        matches!(error, windows_service::Error::Winapi(io)
7706            if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
7707    }
7708
7709    pub(super) fn is_marked_for_delete(error: &windows_service::Error) -> bool {
7710        matches!(error, windows_service::Error::Winapi(io)
7711            if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
7712    }
7713
7714    pub(super) fn wait_until_scm_absent(
7715        timeout: Duration,
7716        poll_interval: Duration,
7717        mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
7718    ) -> Result<bool, ServiceError> {
7719        let deadline = Instant::now() + timeout;
7720        loop {
7721            if probe_absent()? {
7722                return Ok(true);
7723            }
7724            if Instant::now() >= deadline {
7725                return Ok(false);
7726            }
7727            std::thread::sleep(poll_interval);
7728        }
7729    }
7730
7731    // -- Task Scheduler ------------------------------------------------------
7732
7733    #[derive(Debug)]
7734    struct TaskControl;
7735
7736    fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
7737        if detail.to_ascii_lowercase().contains("access is denied") {
7738            return ServiceError::NeedsElevation {
7739                operation,
7740                name: name.to_string(),
7741                detail,
7742                remedy: ELEVATION_REMEDY,
7743            };
7744        }
7745        ServiceError::Control {
7746            operation,
7747            name: name.to_string(),
7748            manager: "Windows Task Scheduler",
7749            detail,
7750        }
7751    }
7752
7753    fn schtasks(
7754        operation: &'static str,
7755        name: &str,
7756        arguments: &[&OsStr],
7757    ) -> Result<(bool, String), ServiceError> {
7758        match run("schtasks.exe", arguments) {
7759            Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
7760            Err(error) => Err(task_error(operation, name, error.to_string())),
7761        }
7762    }
7763
7764    /// Task Scheduler reads `/XML` files as UTF-16, so the document is written
7765    /// as UTF-16 little-endian with a byte-order mark rather than as UTF-8.
7766    fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
7767        let mut bytes = vec![0xFF, 0xFE];
7768        for unit in text.encode_utf16() {
7769            bytes.extend_from_slice(&unit.to_le_bytes());
7770        }
7771        std::fs::write(path, bytes)
7772    }
7773
7774    impl ServiceControl for TaskControl {
7775        fn manager(&self) -> DefinitionKind {
7776            DefinitionKind::WindowsScheduledTask
7777        }
7778
7779        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7780            let name = plan.identity().name().to_string();
7781            let principal = TaskPrincipal::current()?;
7782            let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
7783            let directory = tempfile::tempdir()
7784                .map_err(|error| task_error("install", &name, error.to_string()))?;
7785            let document = directory.path().join("task.xml");
7786            write_utf16(&document, definition.text())
7787                .map_err(|error| task_error("install", &name, error.to_string()))?;
7788            let (ok, message) = schtasks(
7789                "install",
7790                &name,
7791                &[
7792                    OsStr::new("/Create"),
7793                    OsStr::new("/TN"),
7794                    OsStr::new(&name),
7795                    OsStr::new("/XML"),
7796                    document.as_os_str(),
7797                ],
7798            )?;
7799            if !ok {
7800                return Err(task_error("install", &name, message));
7801            }
7802            Ok(definition)
7803        }
7804
7805        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7806            if self.query(identity)?.is_none() {
7807                return Ok(false);
7808            }
7809            let name = identity.name().to_string();
7810            let (ok, message) = schtasks(
7811                "uninstall",
7812                &name,
7813                &[
7814                    OsStr::new("/Delete"),
7815                    OsStr::new("/TN"),
7816                    OsStr::new(&name),
7817                    OsStr::new("/F"),
7818                ],
7819            )?;
7820            if !ok {
7821                return Err(task_error("uninstall", &name, message));
7822            }
7823            Ok(true)
7824        }
7825
7826        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7827            let name = identity.name().to_string();
7828            let (ok, document) = schtasks(
7829                "inspect",
7830                &name,
7831                &[
7832                    OsStr::new("/Query"),
7833                    OsStr::new("/TN"),
7834                    OsStr::new(&name),
7835                    OsStr::new("/XML"),
7836                    OsStr::new("ONE"),
7837                ],
7838            )?;
7839            if !ok {
7840                // `schtasks` reports a missing task and a broken Task Scheduler
7841                // the same way, with a non-zero exit; there is no distinct code.
7842                // Treating it as absence is the safe reading: the caller either
7843                // installs, which will then fail loudly, or reports "not
7844                // installed", which is what an operator with no task sees.
7845                return Ok(None);
7846            }
7847            let command = xml_value(&document, "Command").unwrap_or_default();
7848            let arguments = xml_value(&document, "Arguments").unwrap_or_default();
7849            let command_line = if arguments.is_empty() {
7850                super::quote_argument(&command)
7851            } else {
7852                format!("{} {arguments}", super::quote_argument(&command))
7853            };
7854            Ok(Some(Registration {
7855                manager: DefinitionKind::WindowsScheduledTask,
7856                start_mode: StartMode::Login,
7857                command_line,
7858                account: super::scheduled_task_account(xml_value(&document, "UserId")),
7859                running: task_is_running(&name),
7860                starts_automatically: super::windows_login_task_starts_automatically(&document),
7861                restart_delay: xml_value(&document, "Interval")
7862                    .as_deref()
7863                    .and_then(parse_iso8601),
7864            }))
7865        }
7866
7867        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7868            let name = identity.name().to_string();
7869            let (ok, message) = schtasks(
7870                "start",
7871                &name,
7872                &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
7873            )?;
7874            if ok {
7875                Ok(())
7876            } else {
7877                Err(task_error("start", &name, message))
7878            }
7879        }
7880
7881        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7882            let running = self
7883                .query(identity)?
7884                .is_some_and(|registration| registration.running);
7885            if !running {
7886                return Ok(false);
7887            }
7888            let name = identity.name().to_string();
7889            let (ok, message) = schtasks(
7890                "stop",
7891                &name,
7892                &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
7893            )?;
7894            if ok {
7895                Ok(true)
7896            } else {
7897                Err(task_error("stop", &name, message))
7898            }
7899        }
7900    }
7901
7902    /// Whether Task Scheduler reports the task as running.
7903    ///
7904    /// **This is the one value in this module read from localised output.**
7905    /// `schtasks /Query /FO CSV` prints a `Status` column in the machine's
7906    /// display language, and Task Scheduler exposes no locale-independent
7907    /// equivalent short of COM. On a non-English Windows this therefore reports
7908    /// `false` for a task that is in fact running.
7909    ///
7910    /// That is stated rather than hidden because of what does *not* depend on
7911    /// it: Journey 5's liveness question is answered by the last successful
7912    /// GitHub contact, which is a timestamp this product writes itself and no
7913    /// locale can move. Nothing in the Definition of Done rests on this
7914    /// boolean.
7915    fn task_is_running(name: &str) -> bool {
7916        let Ok((true, stdout, _)) = run(
7917            "schtasks.exe",
7918            &[
7919                OsStr::new("/Query"),
7920                OsStr::new("/TN"),
7921                OsStr::new(name),
7922                OsStr::new("/FO"),
7923                OsStr::new("CSV"),
7924                OsStr::new("/NH"),
7925            ],
7926        ) else {
7927            return false;
7928        };
7929        stdout
7930            .lines()
7931            .filter_map(|line| line.rsplit(',').next())
7932            .any(|status| {
7933                status
7934                    .trim()
7935                    .trim_matches('"')
7936                    .eq_ignore_ascii_case("running")
7937            })
7938    }
7939
7940    /// Parses the `PT<n>M` shape this module writes, and the `PT<n>S` shape
7941    /// Task Scheduler would accept if it took seconds. Anything richer -- days,
7942    /// hours, a combination -- is `None` rather than a guess.
7943    fn parse_iso8601(value: &str) -> Option<Duration> {
7944        let rest = value.strip_prefix("PT")?;
7945        if let Some(minutes) = rest.strip_suffix('M') {
7946            return minutes
7947                .parse::<u64>()
7948                .ok()
7949                .map(|minutes| Duration::from_secs(minutes * 60));
7950        }
7951        rest.strip_suffix('S')?
7952            .parse::<u64>()
7953            .ok()
7954            .map(Duration::from_secs)
7955    }
7956}
7957
7958// ---------------------------------------------------------------------------
7959// Unix: shared plumbing
7960// ---------------------------------------------------------------------------
7961
7962/// Writes a definition file, creating its directory, and says when the refusal
7963/// was a permissions one.
7964///
7965/// Both Unix backends write a file into a directory only `root` can write —
7966/// `/Library/LaunchDaemons` and `/etc/systemd/system` — and an operator who ran
7967/// `service install` without `sudo` deserves to be told that rather than handed
7968/// an `EACCES`.
7969#[cfg(unix)]
7970fn write_definition(
7971    operation: &'static str,
7972    name: &str,
7973    path: &Path,
7974    text: &str,
7975    remedy: &'static str,
7976) -> Result<(), ServiceError> {
7977    if let Some(parent) = path.parent()
7978        && let Err(error) = std::fs::create_dir_all(parent)
7979        && error.kind() != std::io::ErrorKind::AlreadyExists
7980    {
7981        return Err(definition_error(operation, name, error, remedy, parent));
7982    }
7983    std::fs::write(path, text)
7984        .map_err(|error| definition_error(operation, name, error, remedy, path))
7985}
7986
7987/// Replaces an installed definition with [`DefinitionDrift::rendered`].
7988///
7989/// Through a temporary file in the same directory and a rename, so a failure
7990/// at any point leaves the old definition in place rather than a truncated
7991/// one. The mode is set to `0644` before the rename, as `service install`
7992/// leaves it: launchd refuses a plist that others can write.
7993#[cfg(all(unix, any(target_os = "macos", test)))]
7994fn replace_definition(name: &str, drift: &DefinitionDrift) -> Result<(), ServiceError> {
7995    use std::io::Write as _;
7996
7997    let path = drift.path.as_path();
7998    let directory = path.parent().unwrap_or_else(|| Path::new("/"));
7999    let failed = |error: std::io::Error, at: &Path| {
8000        definition_error("rewrite", name, error, SUDO_REMEDY, at)
8001    };
8002    let mut staged =
8003        tempfile::NamedTempFile::new_in(directory).map_err(|error| failed(error, directory))?;
8004    staged
8005        .write_all(drift.rendered.as_bytes())
8006        .and_then(|()| staged.as_file().sync_all())
8007        .map_err(|error| failed(error, staged.path()))?;
8008    std::fs::set_permissions(
8009        staged.path(),
8010        <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o644),
8011    )
8012    .map_err(|error| failed(error, path))?;
8013    staged
8014        .persist(path)
8015        .map(drop)
8016        .map_err(|error| failed(error.error, path))
8017}
8018
8019#[cfg(unix)]
8020fn definition_error(
8021    operation: &'static str,
8022    name: &str,
8023    error: std::io::Error,
8024    remedy: &'static str,
8025    path: &Path,
8026) -> ServiceError {
8027    let detail = format!("{}: {error}", path.display());
8028    if error.kind() == std::io::ErrorKind::PermissionDenied {
8029        ServiceError::NeedsElevation {
8030            operation,
8031            name: name.to_string(),
8032            detail,
8033            remedy,
8034        }
8035    } else {
8036        ServiceError::Control {
8037            operation,
8038            name: name.to_string(),
8039            manager: "the local service manager",
8040            detail,
8041        }
8042    }
8043}
8044
8045/// `sudo` is the remedy on both Unixes, and the message says which command.
8046#[cfg(unix)]
8047const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
8048     `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
8049     agent not running until you sign in.";
8050
8051// ---------------------------------------------------------------------------
8052// macOS
8053// ---------------------------------------------------------------------------
8054
8055#[cfg(target_os = "macos")]
8056mod sys {
8057    //! One manager, two domains. `--start-at boot` is a LaunchDaemon in
8058    //! `/Library/LaunchDaemons`, loaded into launchd's `system` domain;
8059    //! `--start-at login` is a LaunchAgent in the operator's own
8060    //! `~/Library/LaunchAgents`, loaded into `gui/<uid>`.
8061    //!
8062    //! `launchctl print` is the only inspection command here whose output is
8063    //! parsed, and it is English-only on every macOS release — launchd has no
8064    //! localisation for it. The registration's *existence* is decided by the
8065    //! plist file rather than by that output, so a launchd that changed its
8066    //! wording would cost this module the running/not-running line and nothing
8067    //! else.
8068
8069    use std::ffi::OsStr;
8070    use std::path::PathBuf;
8071    use std::time::Duration;
8072
8073    use runner_manager_domain::model::StartMode;
8074
8075    use super::{
8076        DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
8077        SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
8078        enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
8079        write_definition, xml_value,
8080    };
8081
8082    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
8083        Ok(Box::new(LaunchdControl { mode }))
8084    }
8085
8086    #[derive(Debug)]
8087    struct LaunchdControl {
8088        mode: StartMode,
8089    }
8090
8091    impl LaunchdControl {
8092        /// launchd's domain target for this start mode.
8093        fn domain(&self) -> String {
8094            match self.mode {
8095                StartMode::Boot => "system".to_string(),
8096                // SAFETY: `getuid` reads the calling process's real user id and
8097                // cannot fail; it takes no arguments and touches no memory.
8098                StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
8099            }
8100        }
8101
8102        fn service_target(&self, identity: &ServiceIdentity) -> String {
8103            format!("{}/{}", self.domain(), identity.launchd_label())
8104        }
8105
8106        /// Where this domain's plist lives, when the domain has a home to put
8107        /// it in.
8108        fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
8109            let file = format!("{}.plist", identity.launchd_label());
8110            match self.mode {
8111                StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
8112                StartMode::Login => {
8113                    home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
8114                }
8115            }
8116        }
8117
8118        fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
8119            ServiceError::Control {
8120                operation,
8121                name: name.to_string(),
8122                manager: "launchd",
8123                detail,
8124            }
8125        }
8126
8127        fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
8128            match run("launchctl", arguments) {
8129                Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
8130                Err(error) => (false, error.to_string()),
8131            }
8132        }
8133    }
8134
8135    impl ServiceControl for LaunchdControl {
8136        fn manager(&self) -> DefinitionKind {
8137            DefinitionKind::LaunchdPlist
8138        }
8139
8140        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8141            let name = plan.identity().name().to_string();
8142            let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
8143            let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
8144                return Err(self.failed(
8145                    "install",
8146                    &name,
8147                    "this account has no home directory, so there is nowhere to put a \
8148                     LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
8149                     /Library/LaunchDaemons."
8150                        .to_string(),
8151                ));
8152            };
8153            write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8154            let target = self.domain();
8155            let (ok, message) = self.launchctl(&[
8156                OsStr::new("bootstrap"),
8157                OsStr::new(&target),
8158                path.as_os_str(),
8159            ]);
8160            if !ok {
8161                // Leave nothing behind: a plist launchd refused is a file that
8162                // would make the next `service status` claim a registration
8163                // that does not exist.
8164                let _ = std::fs::remove_file(&path);
8165                if message.to_ascii_lowercase().contains("permission denied") {
8166                    return Err(ServiceError::NeedsElevation {
8167                        operation: "install",
8168                        name,
8169                        detail: message,
8170                        remedy: SUDO_REMEDY,
8171                    });
8172                }
8173                return Err(self.failed("install", &name, message));
8174            }
8175            // A previously disabled label stays disabled through a bootstrap,
8176            // which is a service that is installed and will never start.
8177            let service_target = self.service_target(plan.identity());
8178            enable_launchd_registration(
8179                |arguments| self.launchctl(arguments),
8180                &target,
8181                &service_target,
8182                &path,
8183                &name,
8184                SUDO_REMEDY,
8185            )?;
8186            Ok(definition)
8187        }
8188
8189        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8190            let Some(path) = self.plist_path(identity) else {
8191                return Ok(false);
8192            };
8193            if !path.exists() {
8194                return Ok(false);
8195            }
8196            let target = self.service_target(identity);
8197            let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
8198            if !ok
8199                && !message.to_ascii_lowercase().contains("no such process")
8200                && !message.contains("113")
8201            {
8202                if message.to_ascii_lowercase().contains("permission denied") {
8203                    return Err(ServiceError::NeedsElevation {
8204                        operation: "uninstall",
8205                        name: identity.name().to_string(),
8206                        detail: message,
8207                        remedy: SUDO_REMEDY,
8208                    });
8209                }
8210                return Err(self.failed("uninstall", identity.name(), message));
8211            }
8212            // Only the definition. `05-infrastructure.md` item 5: nothing else
8213            // on this host is touched here or anywhere below it.
8214            std::fs::remove_file(&path).map_err(|error| {
8215                super::definition_error(
8216                    "uninstall",
8217                    identity.name(),
8218                    error,
8219                    SUDO_REMEDY,
8220                    path.as_path(),
8221                )
8222            })?;
8223            Ok(true)
8224        }
8225
8226        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8227            let Some(path) = self.plist_path(identity) else {
8228                return Ok(None);
8229            };
8230            let Ok(document) = std::fs::read_to_string(&path) else {
8231                return Ok(None);
8232            };
8233            let target = self.service_target(identity);
8234            let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
8235            Ok(Some(Registration {
8236                manager: DefinitionKind::LaunchdPlist,
8237                start_mode: self.mode,
8238                command_line: program_arguments(&document),
8239                // A LaunchDaemon without `UserName` runs as root; a LaunchAgent
8240                // runs as whoever signs in, which names no one in particular.
8241                account: plist_string_value(&document, "UserName").or_else(|| {
8242                    (self.mode == StartMode::Boot)
8243                        .then(|| super::ServiceAccount::Root.as_str().to_string())
8244                }),
8245                running: loaded && printed.contains("state = running"),
8246                starts_automatically: document.contains("<key>RunAtLoad</key>")
8247                    && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
8248                restart_delay: xml_value(
8249                    super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
8250                    "integer",
8251                )
8252                .and_then(|value| value.parse::<u64>().ok())
8253                .map(Duration::from_secs),
8254            }))
8255        }
8256
8257        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8258            let target = self.service_target(identity);
8259            let (ok, message) = self.launchctl(&[
8260                OsStr::new("kickstart"),
8261                OsStr::new("-k"),
8262                OsStr::new(&target),
8263            ]);
8264            if ok {
8265                Ok(())
8266            } else {
8267                Err(self.failed("start", identity.name(), message))
8268            }
8269        }
8270
8271        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8272            let running = self
8273                .query(identity)?
8274                .is_some_and(|registration| registration.running);
8275            if !running {
8276                return Ok(false);
8277            }
8278            let target = self.service_target(identity);
8279            let (ok, message) = self.launchctl(&[
8280                OsStr::new("kill"),
8281                OsStr::new("SIGTERM"),
8282                OsStr::new(&target),
8283            ]);
8284            if ok {
8285                Ok(true)
8286            } else {
8287                Err(self.failed("stop", identity.name(), message))
8288            }
8289        }
8290    }
8291
8292    /// Boots the job out and bootstraps it again from `plist`, from a shell
8293    /// that outlives this process.
8294    ///
8295    /// `setsid` puts the shell in a session and process group of its own. On a
8296    /// bootout launchd sends SIGTERM to the job's whole process group, the
8297    /// process that asked for the bootout included, and spares a child in
8298    /// another session (measured on macOS 27). Without `setsid` the helper
8299    /// survived that only by luck: one run in four left the job unloaded. The
8300    /// work runs in a background subshell, so the outer shell exits at once and
8301    /// is reaped here rather than left behind. `bootout` blocks until the daemon
8302    /// has exited; the bootstrap is retried for a minute because launchd
8303    /// refuses it while the old job is still being torn down. When the job is
8304    /// still loaded after a failed bootout, every bootstrap fails and the
8305    /// running daemon carries on as it was.
8306    pub(super) fn reload_detached(
8307        identity: &ServiceIdentity,
8308        mode: StartMode,
8309        plist: &std::path::Path,
8310    ) -> Result<(), ServiceError> {
8311        use std::os::unix::process::CommandExt;
8312
8313        const SCRIPT: &str = r#"(
8314  sleep 1
8315  /bin/launchctl bootout "$1"
8316  tries=0
8317  until /bin/launchctl bootstrap "$2" "$3"; do
8318    tries=$((tries + 1))
8319    if [ "$tries" -ge 60 ]; then
8320      echo "runner-manager: launchd did not load $3 again; run: $4" >&2
8321      exit 1
8322    fi
8323    sleep 1
8324  done
8325) </dev/null &"#;
8326
8327        let control = LaunchdControl { mode };
8328        // Once the job is booted out nothing holds the single-instance lock,
8329        // so a reinstall in the same start mode is the remedy.
8330        let reinstall = match mode {
8331            StartMode::Boot => "sudo runner-manager service install --start-at boot",
8332            StartMode::Login => "runner-manager service install --start-at login",
8333        };
8334        let mut command = std::process::Command::new("/bin/sh");
8335        command
8336            .args(["-c", SCRIPT, "runner-manager-reload"])
8337            .arg(control.service_target(identity))
8338            .arg(control.domain())
8339            .arg(plist)
8340            .arg(reinstall)
8341            .stdin(std::process::Stdio::null());
8342        // SAFETY: one async-signal-safe system call between `fork` and `exec`,
8343        // touching no memory, allocator or lock.
8344        unsafe {
8345            command.pre_exec(|| {
8346                if libc::setsid() == -1 {
8347                    return Err(std::io::Error::last_os_error());
8348                }
8349                Ok(())
8350            });
8351        }
8352        let status = command.status().map_err(|error| {
8353            control.failed("reload", identity.name(), format!("/bin/sh: {error}"))
8354        })?;
8355        if status.success() {
8356            Ok(())
8357        } else {
8358            Err(control.failed(
8359                "reload",
8360                identity.name(),
8361                format!("the reload helper exited with {status}"),
8362            ))
8363        }
8364    }
8365
8366    /// Rebuilds the command line from a plist's `ProgramArguments` array.
8367    fn program_arguments(document: &str) -> String {
8368        let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
8369            return String::new();
8370        };
8371        let Some(end) = rest.find("</array>") else {
8372            return String::new();
8373        };
8374        let mut out = Vec::new();
8375        let mut cursor = &rest[..end];
8376        while let Some(open) = cursor.find("<string>") {
8377            let after = &cursor[open + "<string>".len()..];
8378            let Some(close) = after.find("</string>") else {
8379                break;
8380            };
8381            out.push(quote_argument(&super::xml_unescape(&after[..close])));
8382            cursor = &after[close..];
8383        }
8384        out.join(" ")
8385    }
8386}
8387
8388// ---------------------------------------------------------------------------
8389// Linux and the other Unixes
8390// ---------------------------------------------------------------------------
8391
8392#[cfg(all(unix, not(target_os = "macos")))]
8393mod sys {
8394    //! One manager, two domains. `--start-at boot` is a system unit in
8395    //! `/etc/systemd/system`, wanted by `multi-user.target`; `--start-at login`
8396    //! is a user unit in `~/.config/systemd/user`, wanted by `default.target`.
8397    //!
8398    //! Every inspection here goes through `systemctl cat` and `systemctl show`,
8399    //! whose output is machine-readable `key=value` text rather than a
8400    //! sentence, so nothing in this module depends on the machine's display
8401    //! language.
8402
8403    use std::ffi::OsStr;
8404    use std::path::PathBuf;
8405
8406    use runner_manager_domain::model::StartMode;
8407
8408    use super::{
8409        DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
8410        SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
8411        home_directory, ini_directives, run, systemd_exec_command_line, systemd_timespan,
8412        write_definition,
8413    };
8414
8415    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
8416        Ok(Box::new(SystemdControl { mode }))
8417    }
8418
8419    #[derive(Debug)]
8420    struct SystemdControl {
8421        mode: StartMode,
8422    }
8423
8424    impl SystemdControl {
8425        fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
8426            let file = identity.systemd_unit();
8427            match self.mode {
8428                StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
8429                StartMode::Login => {
8430                    home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
8431                }
8432            }
8433        }
8434
8435        /// `systemctl`, with `--user` for the login domain.
8436        fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
8437            let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
8438            if self.mode == StartMode::Login {
8439                all.push(OsStr::new("--user"));
8440            }
8441            all.extend(arguments.iter().map(OsStr::new));
8442            match run("systemctl", &all) {
8443                Ok((ok, stdout, stderr)) => (
8444                    ok,
8445                    if stdout.trim().is_empty() {
8446                        stderr
8447                    } else {
8448                        stdout
8449                    },
8450                ),
8451                Err(error) => (false, error.to_string()),
8452            }
8453        }
8454
8455        fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
8456            ServiceError::Control {
8457                operation,
8458                name: name.to_string(),
8459                manager: "systemd",
8460                detail,
8461            }
8462        }
8463    }
8464
8465    impl ServiceControl for SystemdControl {
8466        fn manager(&self) -> DefinitionKind {
8467            DefinitionKind::SystemdUnit
8468        }
8469
8470        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8471            use std::os::unix::fs::MetadataExt as _;
8472
8473            let name = plan.identity().name().to_string();
8474            if self.mode == StartMode::Boot {
8475                // The credential store's directory as well as the four: the
8476                // service rewrites the credential there when GitHub rotates it.
8477                let refusals = super::permitted_paths(DefinitionKind::SystemdUnit, plan)
8478                    .iter()
8479                    .filter_map(|path| {
8480                        let path = std::path::Path::new(path);
8481                        let owner = std::fs::metadata(path).ok().map(|metadata| metadata.uid());
8482                        super::boot_directory_refusal(path, owner)
8483                    })
8484                    .collect::<Vec<_>>();
8485                if !refusals.is_empty() {
8486                    return Err(self.failed(
8487                        "install",
8488                        &name,
8489                        format!(
8490                            "{}. Run `{}` without --data-dir, which uses {} and {}; a \
8491                             directory there that root does not own is handed back with \
8492                             `sudo chown -R root:root <directory>`",
8493                            refusals.join("; "),
8494                            super::sudo_command("service install --start-at boot"),
8495                            crate::paths::SYSTEM_DATA_ROOT,
8496                            crate::paths::SYSTEM_LOGS_DIR,
8497                        ),
8498                    ));
8499                }
8500            }
8501            let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
8502            let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
8503                return Err(self.failed(
8504                    "install",
8505                    &name,
8506                    "this account has no home directory, so there is nowhere to put a systemd \
8507                     user unit. Use --start-at boot, which installs a system unit under \
8508                     /etc/systemd/system."
8509                        .to_string(),
8510                ));
8511            };
8512            write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8513            let unit = plan.identity().systemd_unit();
8514            let (reloaded, message) = self.systemctl(&["daemon-reload"]);
8515            if !reloaded {
8516                let _ = std::fs::remove_file(&path);
8517                return Err(self.failed("install", &name, message));
8518            }
8519            let (enabled, message) = self.systemctl(&["enable", &unit]);
8520            if !enabled {
8521                let _ = std::fs::remove_file(&path);
8522                let _ = self.systemctl(&["daemon-reload"]);
8523                return Err(self.failed("install", &name, message));
8524            }
8525            Ok(definition)
8526        }
8527
8528        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8529            let Some(path) = self.unit_path(identity) else {
8530                return Ok(false);
8531            };
8532            if !path.exists() {
8533                return Ok(false);
8534            }
8535            let unit = identity.systemd_unit();
8536            // `--now` stops it as well as disabling it. A failure here is not
8537            // fatal: the unit file is about to go, and refusing to remove it
8538            // because `systemctl` disliked something would leave the host with
8539            // a unit nothing manages.
8540            let _ = self.systemctl(&["disable", "--now", &unit]);
8541            std::fs::remove_file(&path).map_err(|error| {
8542                super::definition_error(
8543                    "uninstall",
8544                    identity.name(),
8545                    error,
8546                    SUDO_REMEDY,
8547                    path.as_path(),
8548                )
8549            })?;
8550            let _ = self.systemctl(&["daemon-reload"]);
8551            Ok(true)
8552        }
8553
8554        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8555            let Some(path) = self.unit_path(identity) else {
8556                return Ok(None);
8557            };
8558            if !path.exists() {
8559                return Ok(None);
8560            }
8561            let unit = identity.systemd_unit();
8562            // `cat` includes administrator drop-ins in systemd's order. Reading
8563            // only our base file hides User/ExecStart/RestartSec overrides, so
8564            // the file is read only when `systemctl` cannot answer.
8565            let effective = match self.systemctl(&["cat", &unit]) {
8566                (true, text) => text,
8567                _ => match std::fs::read_to_string(&path) {
8568                    Ok(document) => document,
8569                    Err(_) => return Ok(None),
8570                },
8571            };
8572            let directives = ini_directives(&effective, "Service");
8573            // One `show` answers what `is-active` and `is-enabled` each did.
8574            let (_, shown) = self.systemctl(&[
8575                "show",
8576                "--property=ActiveState",
8577                "--property=UnitFileState",
8578                &unit,
8579            ]);
8580            let state = |key: &str| {
8581                shown
8582                    .lines()
8583                    .find_map(|line| line.strip_prefix(key)?.strip_prefix('='))
8584                    .map(str::trim)
8585            };
8586            Ok(Some(Registration {
8587                manager: DefinitionKind::SystemdUnit,
8588                start_mode: self.mode,
8589                command_line: directives
8590                    .get("ExecStart")
8591                    .map(|value| systemd_exec_command_line(value))
8592                    .unwrap_or_default(),
8593                // A system unit without `User=` runs as root; a user unit runs
8594                // as whoever owns the session, which names no one in particular.
8595                account: directives
8596                    .get("User")
8597                    .filter(|user| !user.is_empty())
8598                    .cloned()
8599                    .or_else(|| {
8600                        (self.mode == StartMode::Boot)
8601                            .then(|| super::ServiceAccount::Root.as_str().to_string())
8602                    }),
8603                running: state("ActiveState") == Some("active"),
8604                starts_automatically: state("UnitFileState") == Some("enabled"),
8605                restart_delay: directives
8606                    .get("RestartSec")
8607                    .and_then(|value| systemd_timespan(value)),
8608            }))
8609        }
8610
8611        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8612            let unit = identity.systemd_unit();
8613            let (ok, message) = self.systemctl(&["start", &unit]);
8614            if ok {
8615                Ok(())
8616            } else {
8617                Err(self.failed("start", identity.name(), message))
8618            }
8619        }
8620
8621        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8622            let running = self
8623                .query(identity)?
8624                .is_some_and(|registration| registration.running);
8625            if !running {
8626                return Ok(false);
8627            }
8628            let unit = identity.systemd_unit();
8629            let (ok, message) = self.systemctl(&["stop", &unit]);
8630            if ok {
8631                Ok(true)
8632            } else {
8633                Err(self.failed("stop", identity.name(), message))
8634            }
8635        }
8636    }
8637}
8638
8639/// What the macOS tests that load real launch agents share.
8640#[cfg(all(test, target_os = "macos"))]
8641pub(crate) mod launchd_fixture {
8642    use std::path::{Path, PathBuf};
8643
8644    /// Opts a macOS host into the tests that load launch agents into the
8645    /// caller's GUI session. CI's macOS leg sets it, and
8646    /// `privileged_tests_are_wired_into_ci.rs` asserts that it still does.
8647    const ACCEPTANCE: &str = "RUNNER_MANAGER_LAUNCHD_ACCEPTANCE";
8648
8649    /// Whether this run may load launch agents. Says so when it may not, since
8650    /// the test then passes having measured nothing.
8651    pub(crate) fn enabled() -> bool {
8652        let enabled = std::env::var_os(ACCEPTANCE).is_some_and(|value| value == "1");
8653        if !enabled {
8654            eprintln!("skipped: set {ACCEPTANCE}=1 to load test launch agents");
8655        }
8656        enabled
8657    }
8658
8659    /// A copy of the running test binary in `directory`, for a launchd job to
8660    /// run as its program.
8661    ///
8662    /// A copy, because a launchd job whose program lives on an external volume
8663    /// waits on a privacy approval nobody gives before `main` runs (measured
8664    /// with a target directory under `/Volumes`), and the temporary directory
8665    /// is on the boot volume.
8666    pub(crate) fn job_binary(directory: &Path) -> PathBuf {
8667        let binary = directory.join("launch-agent-job");
8668        std::fs::copy(std::env::current_exe().expect("test binary"), &binary)
8669            .expect("test binary copied");
8670        binary
8671    }
8672
8673    /// A launch agent loaded into the caller's GUI domain, booted out on drop.
8674    pub(crate) struct LoadedAgent(String);
8675
8676    impl LoadedAgent {
8677        /// Bootstraps `plist`, whose `Label` is `label`, and fails the test when
8678        /// launchd refuses it.
8679        pub(crate) fn bootstrap(label: &str, plist: &Path) -> Self {
8680            // SAFETY: `getuid` reads the calling process's real user id and
8681            // cannot fail.
8682            let domain = format!("gui/{}", unsafe { libc::getuid() });
8683            // Armed before the bootstrap, so a half-loaded job is removed too.
8684            let loaded = Self(format!("{domain}/{label}"));
8685            let bootstrap = std::process::Command::new("launchctl")
8686                .arg("bootstrap")
8687                .arg(&domain)
8688                .arg(plist)
8689                .output()
8690                .expect("launchctl runs");
8691            assert!(
8692                bootstrap.status.success(),
8693                "launchctl bootstrap {domain} refused {label}, so nothing was measured: {}",
8694                String::from_utf8_lossy(&bootstrap.stderr)
8695            );
8696            loaded
8697        }
8698
8699        /// `gui/<uid>/<label>`.
8700        pub(crate) fn target(&self) -> &str {
8701            &self.0
8702        }
8703    }
8704
8705    impl Drop for LoadedAgent {
8706        fn drop(&mut self) {
8707            let _ = std::process::Command::new("launchctl")
8708                .args(["bootout", &self.0])
8709                .output();
8710        }
8711    }
8712}
8713
8714#[cfg(test)]
8715mod tests {
8716    use super::*;
8717
8718    use std::collections::BTreeMap;
8719
8720    #[test]
8721    fn task_scheduler_omitting_default_enabled_still_means_automatic() {
8722        assert!(windows_login_task_starts_automatically(
8723            "<Task><Triggers><LogonTrigger></LogonTrigger></Triggers></Task>"
8724        ));
8725        assert!(windows_login_task_starts_automatically(
8726            "<Task><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Task>"
8727        ));
8728        assert!(!windows_login_task_starts_automatically(
8729            "<Task><LogonTrigger><Enabled>false</Enabled></LogonTrigger></Task>"
8730        ));
8731        assert!(!windows_login_task_starts_automatically(
8732            "<Task><BootTrigger></BootTrigger></Task>"
8733        ));
8734    }
8735
8736    // -----------------------------------------------------------------------
8737    // Fixtures
8738    // -----------------------------------------------------------------------
8739
8740    /// A plan against paths that exist on no platform, so that every renderer
8741    /// can be asserted on every leg of the CI matrix.
8742    fn linux_plan(mode: StartMode) -> InstallPlan {
8743        InstallPlan::unchecked(
8744            ServiceIdentity::product(),
8745            mode,
8746            "/opt/runner-manager/bin/runner-manager",
8747            ServiceDirectories {
8748                config: PathBuf::from("/var/lib/runner-manager/config"),
8749                state: PathBuf::from("/var/lib/runner-manager/state"),
8750                runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
8751                logs: PathBuf::from("/var/log/runner-manager"),
8752            },
8753        )
8754        .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
8755    }
8756
8757    fn windows_plan(mode: StartMode) -> InstallPlan {
8758        InstallPlan::unchecked(
8759            ServiceIdentity::product(),
8760            mode,
8761            "C:\\Program Files\\runner-manager\\runner-manager.exe",
8762            ServiceDirectories {
8763                config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
8764                state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
8765                runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
8766                logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
8767            },
8768        )
8769    }
8770
8771    /// Replaces one fragment of a rendered definition, and **fails the test if
8772    /// the fragment was not there**.
8773    ///
8774    /// Without the assertion a renderer change would quietly turn every
8775    /// "widened definition is rejected" test below into a test that reviews the
8776    /// unmodified definition and passes for the wrong reason. This is the
8777    /// single guard that keeps that class of vacuity out of this file.
8778    fn edited(text: &str, from: &str, to: &str) -> String {
8779        assert!(
8780            text.contains(from),
8781            "the rendered definition does not contain `{from}`, so this test would assert \
8782             nothing about a widened one"
8783        );
8784        text.replace(from, to)
8785    }
8786
8787    /// Every file under `root`, keyed by its path relative to `root`.
8788    fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
8789        fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
8790            let Ok(entries) = std::fs::read_dir(directory) else {
8791                return;
8792            };
8793            for entry in entries.flatten() {
8794                let path = entry.path();
8795                if path.is_dir() {
8796                    walk(&path, out);
8797                } else if let Ok(bytes) = std::fs::read(&path) {
8798                    out.insert(path, bytes);
8799                }
8800            }
8801        }
8802        let mut out = BTreeMap::new();
8803        for root in roots {
8804            walk(root, &mut out);
8805        }
8806        out
8807    }
8808
8809    struct Host {
8810        _root: tempfile::TempDir,
8811        paths: AppPaths,
8812        binary: PathBuf,
8813        /// A runner root inside this host's own temporary tree.
8814        ///
8815        /// Without it every `install` in this module would create and
8816        /// re-permission the **real** `%SystemDrive%\rman` on the machine
8817        /// running the tests, and would fail outright on any machine where that
8818        /// directory already exists with the access control `C:\` gives it.
8819        /// A sibling of the four application-data directories rather than a
8820        /// child of one, so `b1`'s overlap check has nothing to object to.
8821        runner_root: LocalAbsolutePath,
8822        controls: RecordingControls,
8823    }
8824
8825    impl Host {
8826        fn new() -> Self {
8827            let root = tempfile::tempdir().expect("a temporary directory");
8828            let paths = AppPaths::rooted_at(root.path());
8829            paths.create_all().expect("the four directories");
8830            let binary = root.path().join(if cfg!(windows) {
8831                "runner-manager.exe"
8832            } else {
8833                "runner-manager"
8834            });
8835            std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
8836            let runner_root = LocalAbsolutePath::new(
8837                root.path()
8838                    .join("runner-root")
8839                    .to_str()
8840                    .expect("a unicode temporary path"),
8841            )
8842            .expect("a local absolute path");
8843            Self {
8844                _root: root,
8845                paths,
8846                binary,
8847                runner_root,
8848                controls: RecordingControls::new(),
8849            }
8850        }
8851
8852        fn operations(&self) -> ServiceOperations {
8853            ServiceOperations::with_controls(
8854                self.paths.clone(),
8855                ServiceIdentity::product(),
8856                std::sync::Arc::new(self.controls.clone()),
8857            )
8858            .with_runner_root(self.runner_root.clone())
8859        }
8860
8861        fn request(&self, mode: StartMode) -> InstallRequest {
8862            InstallRequest::new(mode).for_binary(&self.binary)
8863        }
8864    }
8865
8866    #[cfg(windows)]
8867    #[test]
8868    fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
8869        let probes = std::cell::Cell::new(0);
8870        let absent =
8871            super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
8872                let next = probes.get() + 1;
8873                probes.set(next);
8874                Ok(next == 3)
8875            })
8876            .expect("the simulated SCM probe succeeds");
8877
8878        assert!(absent);
8879        assert_eq!(
8880            probes.get(),
8881            3,
8882            "uninstall must recheck after transient presence instead of treating it as a leak"
8883        );
8884    }
8885
8886    #[cfg(windows)]
8887    #[test]
8888    fn windows_recognises_an_already_pending_service_deletion() {
8889        let error = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(1072));
8890
8891        assert!(super::sys::is_marked_for_delete(&error));
8892    }
8893
8894    #[test]
8895    fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
8896        let root = tempfile::tempdir().expect("a temporary directory");
8897        let plist = root.path().join("fixture.plist");
8898        std::fs::write(&plist, b"fixture").expect("a plist fixture");
8899        let calls = std::cell::RefCell::new(Vec::new());
8900
8901        let error = enable_launchd_registration(
8902            |arguments| {
8903                let call = arguments
8904                    .iter()
8905                    .map(|argument| argument.to_string_lossy().into_owned())
8906                    .collect::<Vec<_>>();
8907                let operation = call[0].clone();
8908                calls.borrow_mut().push(call);
8909                if operation == "enable" {
8910                    (false, "label remains disabled".to_string())
8911                } else {
8912                    (true, String::new())
8913                }
8914            },
8915            "system",
8916            "system/com.openai.runner-manager-selftest",
8917            &plist,
8918            "runner-manager-selftest",
8919            "rerun with administrative rights",
8920        )
8921        .expect_err("enable failure must fail the install");
8922
8923        assert!(
8924            matches!(
8925                error,
8926                ServiceError::Control {
8927                    operation: "enable",
8928                    ..
8929                }
8930            ),
8931            "{error}"
8932        );
8933        assert_eq!(calls.borrow().len(), 2);
8934        assert_eq!(calls.borrow()[0][0], "enable");
8935        assert_eq!(calls.borrow()[1][0], "bootout");
8936        assert!(!plist.exists(), "rollback must remove the plist");
8937    }
8938
8939    // -----------------------------------------------------------------------
8940    // Quoting, and reading a command line back
8941    // -----------------------------------------------------------------------
8942
8943    #[test]
8944    fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
8945        let plan = windows_plan(StartMode::Boot);
8946        let command_line = plan.command_line();
8947        assert!(
8948            command_line.starts_with('"'),
8949            "a path with a space must be quoted, got {command_line}"
8950        );
8951        assert_eq!(
8952            executable_from_command_line(&command_line).as_deref(),
8953            Some(plan.binary())
8954        );
8955    }
8956
8957    #[test]
8958    fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
8959        let plan = linux_plan(StartMode::Boot);
8960        let command_line = plan.command_line();
8961        assert!(!command_line.starts_with('"'), "got {command_line}");
8962        assert_eq!(
8963            executable_from_command_line(&command_line).as_deref(),
8964            Some(plan.binary())
8965        );
8966    }
8967
8968    #[test]
8969    fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
8970        // Not a path anybody has, but it is the input that separates a real
8971        // implementation of Windows' quoting rules from a `split_whitespace`.
8972        let awkward = r#"C:\odd "name"\rm.exe"#;
8973        let quoted = quote_argument(awkward);
8974        assert_eq!(
8975            executable_from_command_line(&format!("{quoted} daemon run"))
8976                .as_deref()
8977                .map(Path::to_string_lossy)
8978                .as_deref(),
8979            Some(awkward)
8980        );
8981    }
8982
8983    #[test]
8984    fn an_empty_command_line_has_no_executable() {
8985        assert_eq!(executable_from_command_line("   "), None);
8986        assert_eq!(executable_from_command_line(""), None);
8987    }
8988
8989    #[test]
8990    fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
8991        let awkward = r#"DOMAIN\R&D <team> "ops""#;
8992        assert_eq!(
8993            xml_escape(awkward),
8994            "DOMAIN\\R&amp;D &lt;team&gt; &quot;ops&quot;"
8995        );
8996        assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
8997    }
8998
8999    // -----------------------------------------------------------------------
9000    // The restart policy is bounded at both ends
9001    // -----------------------------------------------------------------------
9002
9003    #[test]
9004    fn a_restart_delay_under_the_floor_is_refused() {
9005        let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
9006            .expect_err("half a second is under the one-second floor");
9007        assert!(
9008            matches!(error, ServiceError::RestartDelay { .. }),
9009            "{error}"
9010        );
9011    }
9012
9013    #[test]
9014    fn a_restart_delay_over_the_ceiling_is_refused() {
9015        let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
9016            .expect_err("an hour is over the five-minute ceiling");
9017        assert!(
9018            matches!(error, ServiceError::RestartDelay { .. }),
9019            "{error}"
9020        );
9021    }
9022
9023    #[test]
9024    fn a_reset_window_no_longer_than_the_delay_is_refused() {
9025        let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
9026            .expect_err("a window equal to the delay can never elapse between restarts");
9027        assert!(
9028            matches!(error, ServiceError::RestartResetWindow { .. }),
9029            "{error}"
9030        );
9031    }
9032
9033    #[test]
9034    fn a_delay_inside_the_bound_is_accepted() {
9035        let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
9036            .expect("twenty seconds is inside the bound");
9037        assert_eq!(policy.delay(), Duration::from_secs(20));
9038        assert_eq!(policy.reset_after(), Duration::from_secs(300));
9039    }
9040
9041    // -----------------------------------------------------------------------
9042    // Identity
9043    // -----------------------------------------------------------------------
9044
9045    #[test]
9046    fn a_fixture_identity_can_never_be_the_product_identity() {
9047        let fixture = ServiceIdentity::fixture("abc123");
9048        assert!(fixture.is_fixture());
9049        assert!(!ServiceIdentity::product().is_fixture());
9050        assert_ne!(fixture.name(), ServiceIdentity::product().name());
9051        assert_ne!(
9052            fixture.launchd_label(),
9053            ServiceIdentity::product().launchd_label()
9054        );
9055        assert_ne!(
9056            fixture.systemd_unit(),
9057            ServiceIdentity::product().systemd_unit()
9058        );
9059    }
9060
9061    #[test]
9062    fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
9063        let fixture = ServiceIdentity::fixture("A b/c:\\d");
9064        assert!(
9065            fixture
9066                .name()
9067                .chars()
9068                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
9069            "got {}",
9070            fixture.name()
9071        );
9072    }
9073
9074    // -----------------------------------------------------------------------
9075    // The systemd unit
9076    // -----------------------------------------------------------------------
9077
9078    #[test]
9079    fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
9080        let unit = systemd_unit(&linux_plan(StartMode::Boot));
9081        assert!(unit.contains("KillMode=process\n"), "{unit}");
9082        assert!(unit.contains("Restart=on-failure\n"), "{unit}");
9083        assert!(unit.contains("RestartSec=15\n"), "{unit}");
9084        assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
9085        assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
9086        assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
9087    }
9088
9089    #[test]
9090    fn the_boot_unit_reads_the_live_store_instead_of_a_frozen_systemd_copy() {
9091        let unit = systemd_unit(&linux_plan(StartMode::Boot));
9092        assert!(
9093            !unit.contains("LoadCredential="),
9094            "a startup snapshot would shadow every rotated credential until restart:\n{unit}"
9095        );
9096    }
9097
9098    #[test]
9099    fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
9100        let unit = systemd_unit(&linux_plan(StartMode::Login));
9101        assert!(
9102            !unit.contains("LoadCredential="),
9103            "a user unit must not name a root-owned credential file, got:\n{unit}"
9104        );
9105        assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
9106    }
9107
9108    #[test]
9109    fn the_unit_can_atomically_replace_the_credential_and_write_only_required_directories() {
9110        let plan = linux_plan(StartMode::Boot);
9111        let unit = systemd_unit(&plan);
9112        let directives = ini_directives(&unit, "Service");
9113        let listed = split_quoted(
9114            directives
9115                .get("ReadWritePaths")
9116                .expect("the unit names its writable paths"),
9117        );
9118        assert_eq!(listed.len(), 5, "{listed:?}");
9119        for path in plan.directories().all() {
9120            assert!(
9121                listed.iter().any(|entry| entry == &path.to_string_lossy()),
9122                "{} is missing from {listed:?}",
9123                path.display()
9124            );
9125        }
9126        assert!(
9127            listed
9128                .iter()
9129                .any(|entry| entry == "/var/lib/runner-manager/secrets"),
9130            "atomic credential replacement needs its parent directory in {listed:?}"
9131        );
9132    }
9133
9134    #[test]
9135    fn the_unit_records_the_absolute_binary_path() {
9136        let plan = linux_plan(StartMode::Boot);
9137        let unit = systemd_unit(&plan);
9138        assert!(
9139            unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
9140            "{unit}"
9141        );
9142    }
9143
9144    // -----------------------------------------------------------------------
9145    // The launchd property list
9146    // -----------------------------------------------------------------------
9147
9148    #[test]
9149    fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
9150        let plist = launchd_plist(&linux_plan(StartMode::Boot));
9151        // A bare `KeepAlive` would also restart a job the operator stopped
9152        // deliberately, which turns `service stop` into a fight with launchd.
9153        assert!(
9154            plist.contains(
9155                "<key>KeepAlive</key>\n  <dict>\n    <key>SuccessfulExit</key>\n    <false/>\n"
9156            ),
9157            "{plist}"
9158        );
9159        assert!(
9160            plist.contains("<key>ThrottleInterval</key>\n  <integer>15</integer>"),
9161            "{plist}"
9162        );
9163    }
9164
9165    #[test]
9166    fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
9167        let daemon = launchd_plist(&linux_plan(StartMode::Boot));
9168        assert_eq!(
9169            plist_string_value(&daemon, "UserName").as_deref(),
9170            Some("root")
9171        );
9172        assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
9173
9174        let agent = launchd_plist(&linux_plan(StartMode::Login));
9175        assert_eq!(
9176            plist_string_value(&agent, "UserName"),
9177            None,
9178            "a LaunchAgent already runs as the operator:\n{agent}"
9179        );
9180    }
9181
9182    #[test]
9183    fn the_plist_runs_the_daemon_and_its_runners_as_an_interactive_job() {
9184        for mode in [StartMode::Boot, StartMode::Login] {
9185            let plan = linux_plan(mode);
9186            let plist = launchd_plist(&plan);
9187            assert_eq!(
9188                plist_string_value(&plist, "ProcessType").as_deref(),
9189                Some("Interactive"),
9190                "launchd applies ProcessType to every runner the daemon starts:\n{plist}"
9191            );
9192            let review = review_least_privilege(
9193                &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, plist),
9194                &plan,
9195            );
9196            assert!(
9197                review
9198                    .controls()
9199                    .iter()
9200                    .any(|control| control == "ProcessType=Interactive"),
9201                "{review}"
9202            );
9203            assert!(
9204                !review
9205                    .findings()
9206                    .iter()
9207                    .any(|finding| finding.subject == "ProcessType"),
9208                "{review}"
9209            );
9210        }
9211    }
9212
9213    /// The value 0.4.31 and earlier installed, and launchd's default, both
9214    /// throttle every runner. That is a shortfall the update repairs, not an
9215    /// excess: a scheduling class grants no authority.
9216    #[test]
9217    fn a_plist_that_throttles_its_runners_is_a_shortfall() {
9218        let plan = linux_plan(StartMode::Login);
9219        let rendered = launchd_plist(&plan);
9220        let interactive = "<key>ProcessType</key>\n  <string>Interactive</string>\n";
9221        for (label, throttled) in [
9222            (
9223                "Background",
9224                edited(
9225                    &rendered,
9226                    interactive,
9227                    "<key>ProcessType</key>\n  <string>Background</string>\n",
9228                ),
9229            ),
9230            ("absent", edited(&rendered, interactive, "")),
9231        ] {
9232            let review = review_least_privilege(
9233                &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, throttled),
9234                &plan,
9235            );
9236            assert!(review.is_least_privilege(), "{label}: {review}");
9237            assert!(
9238                review.findings().iter().any(|finding| {
9239                    finding.kind == FindingKind::Shortfall
9240                        && finding.subject == "ProcessType"
9241                        && finding.detail.contains(label)
9242                }),
9243                "{label}: {review}"
9244            );
9245        }
9246    }
9247
9248    #[test]
9249    fn a_plist_with_an_old_process_type_is_drift_and_an_operator_edit_is_not() {
9250        let host = Host::new();
9251        let identity = ServiceIdentity::product();
9252        let plan = InstallPlan::resolve(
9253            identity.clone(),
9254            &host.request(StartMode::Login),
9255            ServiceDirectories::of(&host.paths),
9256        )
9257        .expect("a resolvable plan");
9258        let installed = launchd_plist(&plan);
9259        let definition =
9260            ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, installed.clone());
9261        let mut record = InstallRecord::of(&plan, &definition, Utc::now());
9262        record.installed_by_version = "0.4.31".into();
9263        let path = PathBuf::from("/Users/op/Library/LaunchAgents/runner-manager.plist");
9264
9265        assert_eq!(
9266            launchd_definition_drift(&identity, &record, path.clone(), &installed),
9267            None
9268        );
9269        let edited_by_operator = edited(
9270            &installed,
9271            "</dict>\n</plist>\n",
9272            "  <key>Nice</key>\n  <integer>0</integer>\n</dict>\n</plist>\n",
9273        );
9274        assert_eq!(
9275            launchd_definition_drift(&identity, &record, path.clone(), &edited_by_operator),
9276            None,
9277            "the daemon repairs this drift itself, so it must not key on edits it did not cause"
9278        );
9279
9280        let written_by_0_4_31 = edited(
9281            &installed,
9282            "<string>Interactive</string>",
9283            "<string>Background</string>",
9284        );
9285        let drift = launchd_definition_drift(&identity, &record, path, &written_by_0_4_31)
9286            .expect("a Background plist is drift");
9287        assert_eq!(drift.manager, DefinitionKind::LaunchdPlist);
9288        assert_eq!(drift.start_mode, StartMode::Login);
9289        assert_eq!(
9290            drift.rendered, installed,
9291            "the repair must write exactly what `service install` writes"
9292        );
9293
9294        let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
9295            .with_definition_drift(Some(drift));
9296        let problem = status
9297            .problems()
9298            .iter()
9299            .find(|problem| problem.subject == "definition")
9300            .expect("the drift is reported");
9301        // The remedy is a restart: a running daemon holds the lock that refuses
9302        // `service install`.
9303        assert!(
9304            problem.detail.contains("0.4.31")
9305                && problem.detail.contains(
9306                    "launchctl kickstart -k gui/$(id -u)/io.github.IvanMurzak.runner-manager`"
9307                )
9308                && !problem.detail.contains("service install")
9309                && !problem.detail.contains("systemctl")
9310                && !problem.detail.contains("sudo"),
9311            "{}",
9312            problem.detail
9313        );
9314    }
9315
9316    #[cfg(unix)]
9317    #[test]
9318    fn a_replaced_definition_is_the_rendering_and_readable_by_launchd() {
9319        use std::os::unix::fs::PermissionsExt as _;
9320
9321        let root = tempfile::tempdir().expect("a temporary directory");
9322        let path = root
9323            .path()
9324            .join("io.github.IvanMurzak.runner-manager.plist");
9325        std::fs::write(&path, "<string>Background</string>").expect("old plist");
9326        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
9327        let drift = DefinitionDrift {
9328            path: path.clone(),
9329            installed_by_version: "0.4.31".into(),
9330            rendered: launchd_plist(&linux_plan(StartMode::Login)),
9331            start_mode: StartMode::Login,
9332            manager: DefinitionKind::LaunchdPlist,
9333        };
9334
9335        replace_definition("runner-manager", &drift).expect("replaced");
9336
9337        assert_eq!(std::fs::read_to_string(&path).unwrap(), drift.rendered);
9338        assert_eq!(
9339            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
9340            0o644
9341        );
9342        assert_eq!(
9343            std::fs::read_dir(root.path()).unwrap().count(),
9344            1,
9345            "no staging file is left beside the plist"
9346        );
9347    }
9348
9349    /// The job of [`a_job_reloads_itself_from_its_rewritten_plist`], run by
9350    /// launchd and by nothing else.
9351    ///
9352    /// It does what the daemon does when it starts: when its own plist (kept
9353    /// in its working directory) does not carry [`LAUNCHD_PROCESS_TYPE`], it
9354    /// rewrites the plist and asks for the reload from inside the job that the
9355    /// reload boots out. Either way it then stays up, to be booted out by the
9356    /// helper or, once reloaded, by the test.
9357    #[cfg(target_os = "macos")]
9358    #[test]
9359    #[ignore = "run only by launchd, as the job of the detached-reload test"]
9360    fn detached_reload_job() {
9361        let label = std::env::var("XPC_SERVICE_NAME").unwrap_or_default();
9362        let tag = label
9363            .split_once("-selftest-")
9364            .map(|(_, tag)| tag.to_string())
9365            .unwrap_or_else(|| panic!("run only by launchd for the reload test, not {label:?}"));
9366        let identity = ServiceIdentity::fixture(&tag);
9367        assert_eq!(identity.launchd_label(), label);
9368        let path = std::env::current_dir()
9369            .expect("working directory")
9370            .join(format!("{label}.plist"));
9371        let installed = std::fs::read_to_string(&path).expect("own plist");
9372        if plist_string_value(&installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE) {
9373            let drift = DefinitionDrift {
9374                path: path.clone(),
9375                installed_by_version: "0.4.31".into(),
9376                rendered: installed.replace(
9377                    "<string>Background</string>",
9378                    &format!("<string>{LAUNCHD_PROCESS_TYPE}</string>"),
9379                ),
9380                start_mode: StartMode::Login,
9381                manager: DefinitionKind::LaunchdPlist,
9382            };
9383            replace_definition(identity.name(), &drift).expect("plist replaced");
9384            super::sys::reload_detached(&identity, StartMode::Login, &path)
9385                .expect("helper started");
9386        }
9387        std::thread::sleep(Duration::from_secs(120));
9388    }
9389
9390    /// A job that rewrites its own plist and asks for the reload comes back
9391    /// under the rewritten plist.
9392    ///
9393    /// The request comes from inside the job, as the daemon's does, so the
9394    /// helper has to outlive the bootout of the very job that started it.
9395    /// launchd sends SIGTERM to the job's whole process group, so a helper that
9396    /// stayed in it would sometimes die before its bootstrap and leave the job
9397    /// unloaded. Measured with `setsid` removed, that failed one run in four
9398    /// here: this test catches it, though not on every run.
9399    #[cfg(target_os = "macos")]
9400    #[test]
9401    fn a_job_reloads_itself_from_its_rewritten_plist() {
9402        if !super::launchd_fixture::enabled() {
9403            return;
9404        }
9405        let root = tempfile::tempdir().expect("a temporary directory");
9406        let identity = ServiceIdentity::fixture(&format!("reload-{}", std::process::id()));
9407        let label = identity.launchd_label();
9408        let path = root.path().join(format!("{label}.plist"));
9409        let binary = super::launchd_fixture::job_binary(root.path());
9410        let mut plist = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
9411        plist.push_str("<plist version=\"1.0\">\n<dict>\n");
9412        plist.push_str(&plist_string("Label", &label));
9413        plist.push_str("  <key>ProgramArguments</key>\n  <array>\n");
9414        for argument in [
9415            binary.to_string_lossy().as_ref(),
9416            "--ignored",
9417            "--exact",
9418            "service::tests::detached_reload_job",
9419            "--test-threads=1",
9420        ] {
9421            plist.push_str(&format!("    <string>{}</string>\n", xml_escape(argument)));
9422        }
9423        plist.push_str("  </array>\n  <key>RunAtLoad</key>\n  <true/>\n");
9424        plist.push_str(&plist_string(
9425            "WorkingDirectory",
9426            &root.path().to_string_lossy(),
9427        ));
9428        plist.push_str(&plist_string("ProcessType", "Background"));
9429        plist.push_str("</dict>\n</plist>\n");
9430        std::fs::write(&path, plist).expect("plist written");
9431
9432        let loaded = super::launchd_fixture::LoadedAgent::bootstrap(&label, &path);
9433        let spawn_type = || {
9434            let printed = std::process::Command::new("launchctl")
9435                .args(["print", loaded.target()])
9436                .output()
9437                .expect("launchctl runs");
9438            String::from_utf8_lossy(&printed.stdout)
9439                .lines()
9440                .find_map(|line| {
9441                    line.trim()
9442                        .strip_prefix("spawn type = ")
9443                        .map(str::to_string)
9444                })
9445        };
9446        assert_eq!(spawn_type().as_deref(), Some("background (5)"));
9447
9448        let deadline = std::time::Instant::now() + Duration::from_secs(90);
9449        while spawn_type().as_deref() != Some("interactive (4)") {
9450            assert!(
9451                std::time::Instant::now() < deadline,
9452                "the job did not come back under its rewritten plist; launchd reports {:?}",
9453                spawn_type()
9454            );
9455            std::thread::sleep(Duration::from_millis(250));
9456        }
9457        let rewritten = std::fs::read_to_string(&path).expect("plist readable");
9458        assert_eq!(
9459            plist_string_value(&rewritten, "ProcessType").as_deref(),
9460            Some(LAUNCHD_PROCESS_TYPE)
9461        );
9462    }
9463
9464    #[test]
9465    fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
9466        let plist = launchd_plist(&linux_plan(StartMode::Boot));
9467        assert!(
9468            plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
9469            "{plist}"
9470        );
9471        assert!(plist.contains("<string>daemon</string>"), "{plist}");
9472        assert!(plist.contains("<string>run</string>"), "{plist}");
9473    }
9474
9475    #[test]
9476    fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
9477        assert_eq!(
9478            ServiceIdentity::product().launchd_label(),
9479            "io.github.IvanMurzak.runner-manager"
9480        );
9481    }
9482
9483    // -----------------------------------------------------------------------
9484    // The Task Scheduler document
9485    // -----------------------------------------------------------------------
9486
9487    #[test]
9488    fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
9489        let xml = windows_scheduled_task_xml(
9490            &windows_plan(StartMode::Login),
9491            &TaskPrincipal::named("HOST\\operator"),
9492        );
9493        assert!(xml.contains("<LogonTrigger>"), "{xml}");
9494        assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
9495        assert!(
9496            xml.contains("<LogonType>InteractiveToken</LogonType>"),
9497            "{xml}"
9498        );
9499        assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
9500        assert!(
9501            xml.contains("<Interval>PT1M</Interval>"),
9502            "Task Scheduler takes whole minutes only, and rejects the registration outright \
9503             for anything finer:\n{xml}"
9504        );
9505    }
9506
9507    #[test]
9508    fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
9509        // Measured against the real Task Scheduler, which answers `PT15S` with
9510        // "The task XML contains a value which is incorrectly formatted or out
9511        // of range". Rounding *up* is what keeps "does not restart-loop faster
9512        // than that bound" true on this manager.
9513        for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
9514            let policy =
9515                RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
9516                    .expect("inside the supported range");
9517            assert_eq!(
9518                policy
9519                    .effective_delay(DefinitionKind::WindowsScheduledTask)
9520                    .as_secs(),
9521                enforced,
9522                "a {asked}s delay must be enforced as {enforced}s"
9523            );
9524            assert!(
9525                policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
9526                "rounding must never shorten the bound"
9527            );
9528        }
9529    }
9530
9531    #[test]
9532    fn every_other_manager_enforces_the_delay_exactly_as_configured() {
9533        let policy = RestartPolicy::default();
9534        for kind in [
9535            DefinitionKind::WindowsService,
9536            DefinitionKind::LaunchdPlist,
9537            DefinitionKind::SystemdUnit,
9538        ] {
9539            assert_eq!(
9540                policy.effective_delay(kind),
9541                policy.delay(),
9542                "{kind:?} takes seconds and enforces exactly what it is given"
9543            );
9544        }
9545    }
9546
9547    #[test]
9548    fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
9549        let host = Host::new();
9550        let operations = host.operations();
9551        operations
9552            .install(&host.request(StartMode::Login))
9553            .expect("an install at login");
9554        // What a real Task Scheduler reports back for a 15-second policy.
9555        host.controls.edit("runner-manager", |registration| {
9556            registration.manager = DefinitionKind::WindowsScheduledTask;
9557            registration.restart_delay = Some(Duration::from_secs(60));
9558        });
9559
9560        let status = operations.status().expect("a status");
9561        assert!(
9562            status.is_healthy(),
9563            "minute granularity is the manager's, not a mis-registration: {status}"
9564        );
9565        assert!(
9566            status
9567                .notes()
9568                .iter()
9569                .any(|note| note.contains("whole minutes")),
9570            "but the operator must be told why 15 became 60: {status}"
9571        );
9572
9573        // The discriminator: a delay that is neither the configured one nor its
9574        // rounding is still a fault.
9575        host.controls.edit("runner-manager", |registration| {
9576            registration.restart_delay = Some(Duration::from_secs(1));
9577        });
9578        assert!(
9579            !operations.status().expect("a status").is_healthy(),
9580            "a one-second delay is not what any manager was asked for"
9581        );
9582    }
9583
9584    #[test]
9585    fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
9586        let plan = windows_plan(StartMode::Login);
9587        let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
9588        assert_eq!(
9589            xml_value(&xml, "Command").as_deref(),
9590            Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
9591            "{xml}"
9592        );
9593        assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
9594    }
9595
9596    #[test]
9597    fn an_account_name_holding_xml_punctuation_is_escaped() {
9598        let xml = windows_scheduled_task_xml(
9599            &windows_plan(StartMode::Login),
9600            &TaskPrincipal::named("R&D\\ops"),
9601        );
9602        assert!(xml.contains("<UserId>R&amp;D\\ops</UserId>"), "{xml}");
9603        assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
9604    }
9605
9606    // -----------------------------------------------------------------------
9607    // The Windows service descriptor
9608    // -----------------------------------------------------------------------
9609
9610    #[test]
9611    fn the_service_starts_automatically_under_the_account_the_store_admits() {
9612        let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
9613        let directives = ini_directives(&text, "windows-service");
9614        assert_eq!(
9615            directives.get("StartType").map(String::as_str),
9616            Some("AutoStart")
9617        );
9618        assert_eq!(
9619            directives.get("Account").map(String::as_str),
9620            Some("NT AUTHORITY\\SYSTEM")
9621        );
9622        assert_eq!(
9623            directives.get("ServiceType").map(String::as_str),
9624            Some("OWN_PROCESS")
9625        );
9626        assert_eq!(
9627            directives
9628                .get("FailureActionRestartDelaySecs")
9629                .map(String::as_str),
9630            Some("15")
9631        );
9632        assert_eq!(
9633            directives
9634                .get("FailureActionsOnNonCrashFailures")
9635                .map(String::as_str),
9636            Some("true"),
9637            "without this flag a non-zero exit is not a failure the manager restarts"
9638        );
9639    }
9640
9641    #[test]
9642    fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
9643        let spec = windows_service_spec(&windows_plan(StartMode::Boot));
9644        assert_eq!(spec.account, None);
9645        assert!(spec.automatic_start);
9646        assert!(
9647            spec.command_line.contains("daemon run"),
9648            "{}",
9649            spec.command_line
9650        );
9651    }
9652
9653    // -----------------------------------------------------------------------
9654    // Where each definition is installed
9655    // -----------------------------------------------------------------------
9656
9657    #[test]
9658    fn each_definition_goes_where_its_platform_expects_it() {
9659        let home = PathBuf::from("/home/op");
9660        assert_eq!(
9661            ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
9662            Some(Path::new(
9663                "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
9664            ))
9665        );
9666        assert_eq!(
9667            ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
9668            Some(Path::new(
9669                "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
9670            ))
9671        );
9672        assert_eq!(
9673            ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
9674            Some(Path::new("/etc/systemd/system/runner-manager.service"))
9675        );
9676        assert_eq!(
9677            ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
9678            Some(Path::new(
9679                "/home/op/.config/systemd/user/runner-manager.service"
9680            ))
9681        );
9682        assert_eq!(
9683            ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
9684            None,
9685            "the Service Control Manager has no file"
9686        );
9687    }
9688
9689    #[test]
9690    fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
9691        assert_eq!(
9692            ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
9693            None
9694        );
9695        assert_eq!(
9696            ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
9697            None
9698        );
9699    }
9700
9701    // -----------------------------------------------------------------------
9702    // The least-privilege review: the passing case, then every failing one
9703    // -----------------------------------------------------------------------
9704
9705    #[test]
9706    fn the_rendered_definitions_are_all_least_privilege() {
9707        let linux = linux_plan(StartMode::Boot);
9708        let windows = windows_plan(StartMode::Boot);
9709        for (definition, plan) in [
9710            (ServiceDefinition::systemd(&linux, None), &linux),
9711            (ServiceDefinition::launchd(&linux, None), &linux),
9712            (ServiceDefinition::windows_service(&windows), &windows),
9713        ] {
9714            let review = review_least_privilege(&definition, plan);
9715            assert!(
9716                review.is_least_privilege(),
9717                "{:?} should be least privilege, got:\n{review}",
9718                definition.kind()
9719            );
9720            assert!(
9721                !review.controls().is_empty(),
9722                "a review that confirms nothing proves nothing: {:?}",
9723                definition.kind()
9724            );
9725        }
9726    }
9727
9728    #[test]
9729    fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
9730        let plan = windows_plan(StartMode::Login);
9731        let definition =
9732            ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
9733        let review = review_least_privilege(&definition, &plan);
9734        assert!(review.is_least_privilege(), "{review}");
9735        assert!(
9736            review
9737                .controls()
9738                .iter()
9739                .any(|control| control.contains("LeastPrivilege")),
9740            "{review}"
9741        );
9742    }
9743
9744    #[test]
9745    fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
9746        let plan = linux_plan(StartMode::Boot);
9747        let rendered = systemd_unit(&plan);
9748        let widened = edited(
9749            &rendered,
9750            "ReadWritePaths=/var/lib/runner-manager/config",
9751            "ReadWritePaths=/etc /var/lib/runner-manager/config",
9752        );
9753        let review = review_least_privilege(
9754            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
9755            &plan,
9756        );
9757        assert!(!review.is_least_privilege(), "{review}");
9758        assert!(
9759            review
9760                .excesses()
9761                .iter()
9762                .any(|finding| finding.detail.contains("/etc")),
9763            "the review must name the directory it objects to: {review}"
9764        );
9765    }
9766
9767    #[test]
9768    fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
9769        let plan = linux_plan(StartMode::Boot);
9770        let rendered = systemd_unit(&plan);
9771        let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
9772        let review = review_least_privilege(
9773            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
9774            &plan,
9775        );
9776        assert!(!review.is_least_privilege(), "{review}");
9777        assert!(
9778            review
9779                .excesses()
9780                .iter()
9781                .any(|finding| finding.subject == "NoNewPrivileges"),
9782            "{review}"
9783        );
9784    }
9785
9786    /// Whether reviewing `unit` against `plan` reports `kind` for `subject`.
9787    fn reports(unit: &str, plan: &InstallPlan, subject: &str, kind: FindingKind) -> bool {
9788        review_least_privilege(
9789            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, unit.to_string()),
9790            plan,
9791        )
9792        .findings()
9793        .iter()
9794        .any(|finding| finding.subject == subject && finding.kind == kind)
9795    }
9796
9797    #[test]
9798    fn no_unit_breaks_tar_by_answering_openat2_with_enosys() {
9799        for mode in [StartMode::Boot, StartMode::Login] {
9800            let unit = systemd_unit(&linux_plan(mode));
9801            assert!(
9802                !unit.contains("RestrictSUIDSGID"),
9803                "RestrictSUIDSGID fails every tar extraction in a job: {unit}"
9804            );
9805        }
9806    }
9807
9808    #[test]
9809    fn every_unit_drops_every_capability_and_boot_units_hide_home_trees() {
9810        let boot = systemd_unit(&linux_plan(StartMode::Boot));
9811        let directives = ini_directives(&boot, "Service");
9812        assert_eq!(directives["CapabilityBoundingSet"], "");
9813        assert_eq!(directives["AmbientCapabilities"], "");
9814        assert_eq!(directives["ProtectHome"], "tmpfs");
9815        assert!(!directives.contains_key("BindPaths"));
9816
9817        let login = systemd_unit(&linux_plan(StartMode::Login));
9818        let directives = ini_directives(&login, "Service");
9819        assert_eq!(directives["CapabilityBoundingSet"], "");
9820        assert!(!directives.contains_key("ProtectHome"));
9821
9822        let unprotected = boot.replace("ProtectHome=tmpfs\n", "");
9823        assert!(reports(
9824            &unprotected,
9825            &linux_plan(StartMode::Boot),
9826            "ProtectHome",
9827            FindingKind::Excess
9828        ));
9829        let inverted = boot.replace(
9830            "CapabilityBoundingSet=\n",
9831            "CapabilityBoundingSet=~CAP_SYS_ADMIN\n",
9832        );
9833        assert!(reports(
9834            &inverted,
9835            &linux_plan(StartMode::Boot),
9836            "CapabilityBoundingSet",
9837            FindingKind::Excess
9838        ));
9839    }
9840
9841    #[test]
9842    fn status_reports_the_registered_account_and_falls_back_to_the_record() {
9843        let recorded = ServiceAccount::for_start_mode(StartMode::Boot).to_string();
9844        for (registered, shown) in [(Some("ivan"), "ivan"), (None, recorded.as_str())] {
9845            let host = Host::new();
9846            let operations = host.operations();
9847            operations.install(&host.request(StartMode::Boot)).unwrap();
9848            host.controls.edit("runner-manager", |registration| {
9849                registration.account = registered.map(str::to_string);
9850            });
9851            let status = operations.status().unwrap().to_string();
9852            assert!(
9853                status.contains(&format!("account                   {shown}\n")),
9854                "{status}"
9855            );
9856        }
9857    }
9858
9859    #[cfg(target_os = "linux")]
9860    #[test]
9861    fn a_boot_install_refuses_a_unit_a_drop_in_runs_as_another_account() {
9862        let host = Host::new();
9863        let operations = host.operations();
9864        operations.install(&host.request(StartMode::Boot)).unwrap();
9865        host.controls.edit("runner-manager", |registration| {
9866            registration.account = Some("ivan".to_string());
9867        });
9868        let refused = operations
9869            .install(&host.request(StartMode::Boot))
9870            .expect_err("a unit running as ivan cannot use root's tree");
9871        let message = refused.to_string();
9872        assert!(message.contains("run as ivan"), "{message}");
9873        assert!(message.contains("Remove `User=`"), "{message}");
9874    }
9875
9876    #[test]
9877    fn manager_account_spellings_match_the_record() {
9878        assert_eq!(
9879            windows_service_account("LocalSystem"),
9880            ServiceAccount::LocalSystem.as_str()
9881        );
9882        assert_eq!(windows_service_account(".\\svc-runner"), ".\\svc-runner");
9883        assert_eq!(
9884            scheduled_task_account(Some("S-1-5-21-1-2-3-1001".into())),
9885            None
9886        );
9887        assert_eq!(
9888            scheduled_task_account(Some("HOST\\op".into())),
9889            Some("HOST\\op".to_string())
9890        );
9891    }
9892
9893    #[test]
9894    fn exec_start_prefixes_are_not_part_of_the_binary() {
9895        for (value, expected) in [
9896            ("/opt/rm daemon run", "/opt/rm daemon run"),
9897            ("-/opt/rm daemon run", "/opt/rm daemon run"),
9898            ("+!/opt/rm daemon run", "/opt/rm daemon run"),
9899            ("!!/opt/rm daemon run", "/opt/rm daemon run"),
9900            ("@/opt/rm runner-manager daemon run", "/opt/rm daemon run"),
9901            ("-@\"/opt/my rm\" rm daemon", "\"/opt/my rm\" daemon"),
9902        ] {
9903            assert_eq!(systemd_exec_command_line(value), expected, "{value}");
9904        }
9905        assert_eq!(
9906            executable_from_command_line(&systemd_exec_command_line("-/opt/rm daemon")),
9907            Some(PathBuf::from("/opt/rm"))
9908        );
9909    }
9910
9911    #[test]
9912    fn restart_sec_accepts_systemd_time_spans() {
9913        for (value, expected) in [
9914            ("15", Duration::from_secs(15)),
9915            ("15s", Duration::from_secs(15)),
9916            ("1min", Duration::from_secs(60)),
9917            ("1min 30s", Duration::from_secs(90)),
9918            ("1min30s", Duration::from_secs(90)),
9919            ("2h", Duration::from_secs(7_200)),
9920            ("500ms", Duration::from_millis(500)),
9921            ("1.5s", Duration::from_millis(1_500)),
9922        ] {
9923            assert_eq!(systemd_timespan(value), Some(expected), "{value}");
9924        }
9925        for value in ["", "infinity", "soon", "5 parsecs", "s"] {
9926            assert_eq!(systemd_timespan(value), None, "{value}");
9927        }
9928    }
9929
9930    #[cfg(unix)]
9931    #[test]
9932    fn the_system_tree_is_reported_as_roots_installation_under_var_lib() {
9933        let system = ServiceDirectories::of(&AppPaths::system());
9934        assert_eq!(system.root(), Path::new("/var/lib/runner-manager"));
9935        assert_eq!(
9936            data_root_owner(&system.root(), Some(Path::new("/home/op"))).as_deref(),
9937            Some("root")
9938        );
9939    }
9940
9941    #[test]
9942    fn a_boot_service_refuses_directories_it_could_not_open() {
9943        let var = Path::new("/var/lib/runner-manager/state");
9944        assert_eq!(boot_directory_refusal(var, Some(0)), None);
9945        let refused = boot_directory_refusal(var, Some(1000)).expect("another owner");
9946        assert!(refused.contains("uid 1000"), "{refused}");
9947        assert!(boot_directory_refusal(var, None).is_some());
9948        for hidden in ["/home/op/.config/runner-manager", "/root/.local/state/rm"] {
9949            let refused =
9950                boot_directory_refusal(Path::new(hidden), Some(0)).expect("a hidden tree");
9951            assert!(refused.contains("cannot see"), "{refused}");
9952        }
9953    }
9954
9955    #[cfg(unix)]
9956    #[test]
9957    fn a_moved_tree_keeps_files_modes_and_links_but_not_the_skipped_entry() {
9958        use std::os::unix::fs::PermissionsExt as _;
9959
9960        let from = tempfile::tempdir().unwrap();
9961        let to = tempfile::tempdir().unwrap();
9962        let target = to.path().join("state");
9963        std::fs::create_dir_all(from.path().join("journal/2026")).unwrap();
9964        std::fs::write(from.path().join("journal/2026/attempt"), b"entry").unwrap();
9965        std::fs::write(from.path().join("secret"), b"x").unwrap();
9966        std::fs::set_permissions(
9967            from.path().join("secret"),
9968            std::fs::Permissions::from_mode(0o600),
9969        )
9970        .unwrap();
9971        std::os::unix::fs::symlink("journal", from.path().join("latest")).unwrap();
9972        std::fs::create_dir(from.path().join("bin")).unwrap();
9973
9974        copy_tree(from.path(), &target, Some("bin")).unwrap();
9975
9976        assert_eq!(
9977            std::fs::read(target.join("journal/2026/attempt")).unwrap(),
9978            b"entry"
9979        );
9980        let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
9981        assert_eq!(mode(&target.join("secret")), 0o600);
9982        assert_eq!(mode(&target.join("journal")), 0o700);
9983        assert_eq!(
9984            std::fs::read_link(target.join("latest")).unwrap(),
9985            Path::new("journal")
9986        );
9987        assert!(!target.join("bin").exists());
9988
9989        // An occupied target is set aside whole, not merged into or deleted.
9990        std::fs::write(target.join("stale.sqlite3-wal"), b"stale").unwrap();
9991        set_aside(&target, "20261110T000000Z").unwrap();
9992        assert!(!target.exists());
9993        let aside = to.path().join("state.before-move-20261110T000000Z");
9994        assert_eq!(
9995            std::fs::read(aside.join("stale.sqlite3-wal")).unwrap(),
9996            b"stale"
9997        );
9998        copy_tree(from.path(), &target, Some("bin")).unwrap();
9999        assert!(!target.join("stale.sqlite3-wal").exists());
10000        set_aside(&to.path().join("absent"), "x").unwrap();
10001    }
10002
10003    #[test]
10004    fn systemd_drop_ins_replace_base_service_directives() {
10005        let base = systemd_unit(&linux_plan(StartMode::Boot));
10006        let combined = format!(
10007            "# /etc/systemd/system/runner-manager.service\n{base}\n\
10008             # /etc/systemd/system/runner-manager.service.d/account.conf\n\
10009             [Service]\nUser=ivan\nRestartSec=30\nExecStart=\n\
10010             ExecStart=/opt/runner-manager daemon run\n"
10011        );
10012        let directives = ini_directives(&combined, "Service");
10013        assert_eq!(directives["User"], "ivan");
10014        assert_eq!(directives["RestartSec"], "30");
10015        assert_eq!(directives["ExecStart"], "/opt/runner-manager daemon run");
10016        assert_eq!(directives["ProtectSystem"], "strict");
10017    }
10018
10019    #[test]
10020    fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
10021        let plan = linux_plan(StartMode::Boot);
10022        let rendered = systemd_unit(&plan);
10023        let widened = edited(
10024            &rendered,
10025            "CapabilityBoundingSet=\n",
10026            "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
10027        );
10028        let review = review_least_privilege(
10029            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10030            &plan,
10031        );
10032        assert!(!review.is_least_privilege(), "{review}");
10033        assert!(
10034            review
10035                .excesses()
10036                .iter()
10037                .any(|finding| finding.subject == "CapabilityBoundingSet"),
10038            "{review}"
10039        );
10040    }
10041
10042    #[test]
10043    fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
10044        let plan = linux_plan(StartMode::Boot);
10045        let rendered = systemd_unit(&plan);
10046        let widened = edited(
10047            &rendered,
10048            "[Install]",
10049            "ListenStream=127.0.0.1:9000\n\n[Install]",
10050        );
10051        let review = review_least_privilege(
10052            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10053            &plan,
10054        );
10055        assert!(
10056            !review.is_least_privilege(),
10057            "07-security.md rule 2 forbids any inbound surface: {review}"
10058        );
10059    }
10060
10061    #[test]
10062    fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
10063        let plan = linux_plan(StartMode::Boot);
10064        let rendered = systemd_unit(&plan);
10065        let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
10066        let review = review_least_privilege(
10067            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
10068            &plan,
10069        );
10070        assert!(
10071            review.is_least_privilege(),
10072            "too little authority is not an excess: {review}"
10073        );
10074        assert!(
10075            review
10076                .findings()
10077                .iter()
10078                .any(|finding| finding.kind == FindingKind::Shortfall
10079                    && finding.detail.contains("runtime")),
10080            "{review}"
10081        );
10082    }
10083
10084    #[test]
10085    fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
10086        let plan = linux_plan(StartMode::Login);
10087        let rendered = launchd_plist(&plan);
10088        let widened = edited(
10089            &rendered,
10090            "<key>ProcessType</key>",
10091            "<key>UserName</key>\n  <string>root</string>\n  <key>ProcessType</key>",
10092        );
10093        let review = review_least_privilege(
10094            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10095            &plan,
10096        );
10097        assert!(!review.is_least_privilege(), "{review}");
10098        assert!(
10099            review
10100                .excesses()
10101                .iter()
10102                .any(|finding| finding.subject == "UserName"),
10103            "{review}"
10104        );
10105    }
10106
10107    #[test]
10108    fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
10109        let plan = linux_plan(StartMode::Boot);
10110        let rendered = launchd_plist(&plan);
10111        let widened = edited(
10112            &rendered,
10113            "<key>SessionCreate</key>\n  <false/>",
10114            "<key>SessionCreate</key>\n  <true/>",
10115        );
10116        let review = review_least_privilege(
10117            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10118            &plan,
10119        );
10120        assert!(!review.is_least_privilege(), "{review}");
10121        assert!(
10122            review
10123                .excesses()
10124                .iter()
10125                .any(|finding| finding.subject == "SessionCreate"),
10126            "{review}"
10127        );
10128    }
10129
10130    #[test]
10131    fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
10132        let plan = linux_plan(StartMode::Boot);
10133        let rendered = launchd_plist(&plan);
10134        let widened = edited(
10135            &rendered,
10136            "<key>ProcessType</key>",
10137            "<key>MachServices</key>\n  <dict/>\n  <key>ProcessType</key>",
10138        );
10139        let review = review_least_privilege(
10140            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10141            &plan,
10142        );
10143        assert!(!review.is_least_privilege(), "{review}");
10144    }
10145
10146    #[test]
10147    fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
10148        let plan = windows_plan(StartMode::Login);
10149        let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
10150        let widened = edited(
10151            &rendered,
10152            "<RunLevel>LeastPrivilege</RunLevel>",
10153            "<RunLevel>HighestAvailable</RunLevel>",
10154        );
10155        let review = review_least_privilege(
10156            &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
10157            &plan,
10158        );
10159        assert!(!review.is_least_privilege(), "{review}");
10160        assert!(
10161            review
10162                .excesses()
10163                .iter()
10164                .any(|finding| finding.subject == "RunLevel"),
10165            "{review}"
10166        );
10167    }
10168
10169    #[test]
10170    fn a_task_that_would_store_a_password_is_not_least_privilege() {
10171        let plan = windows_plan(StartMode::Login);
10172        let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
10173        let widened = edited(
10174            &rendered,
10175            "<LogonType>InteractiveToken</LogonType>",
10176            "<LogonType>Password</LogonType>",
10177        );
10178        let review = review_least_privilege(
10179            &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
10180            &plan,
10181        );
10182        assert!(!review.is_least_privilege(), "{review}");
10183    }
10184
10185    #[test]
10186    fn an_interactive_windows_service_is_not_least_privilege() {
10187        let plan = windows_plan(StartMode::Boot);
10188        let rendered = windows_service_descriptor(&plan);
10189        let widened = edited(
10190            &rendered,
10191            "ServiceType=OWN_PROCESS",
10192            "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
10193        );
10194        let review = review_least_privilege(
10195            &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
10196            &plan,
10197        );
10198        assert!(!review.is_least_privilege(), "{review}");
10199        assert!(
10200            review
10201                .excesses()
10202                .iter()
10203                .any(|finding| finding.subject == "ServiceType"),
10204            "{review}"
10205        );
10206    }
10207
10208    #[test]
10209    fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
10210        let plan = windows_plan(StartMode::Boot);
10211        let rendered = windows_service_descriptor(&plan);
10212        let changed = edited(
10213            &rendered,
10214            "Account=NT AUTHORITY\\SYSTEM",
10215            "Account=NT AUTHORITY\\LocalService",
10216        );
10217        let review = review_least_privilege(
10218            &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
10219            &plan,
10220        );
10221        // LocalService is *less* privileged, so this is not an excess. It is
10222        // still wrong, because `d2`'s DACL names SY, BA and OW and nothing else:
10223        // the daemon would start and find no credential. Reporting it as a
10224        // shortfall rather than as an excess is the difference between a check
10225        // that understands the requirement and one that counts adjectives.
10226        assert!(review.is_least_privilege(), "{review}");
10227        assert!(
10228            review.findings().iter().any(|finding| {
10229                finding.kind == FindingKind::Shortfall && finding.subject == "Account"
10230            }),
10231            "{review}"
10232        );
10233    }
10234
10235    // -----------------------------------------------------------------------
10236    // The recorded absolute path, and what became of it
10237    // -----------------------------------------------------------------------
10238
10239    #[test]
10240    fn a_recorded_path_that_is_still_there_is_current() {
10241        let host = Host::new();
10242        let state = inspect_binary(&host.binary, Some(&host.binary));
10243        assert!(!state.is_error(), "{state}");
10244        assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
10245    }
10246
10247    #[test]
10248    fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
10249        let host = Host::new();
10250        // An `npm i -g` binary lives under the active Node installation's
10251        // global prefix. Switching Node versions moves the prefix, and the
10252        // recorded path stops existing while the registration survives.
10253        let recorded = host.binary.clone();
10254        let healthy = inspect_binary(&recorded, Some(&recorded));
10255        assert!(
10256            !healthy.is_error(),
10257            "the discriminator: before the binary moves, this must be healthy"
10258        );
10259
10260        std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
10261
10262        let state = inspect_binary(&recorded, Some(&recorded));
10263        assert!(state.is_error(), "{state}");
10264        assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
10265        assert!(
10266            state.to_string().contains("npm"),
10267            "the message must name the cause an operator will not otherwise connect: {state}"
10268        );
10269    }
10270
10271    #[test]
10272    fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
10273        let root = tempfile::tempdir().expect("a temporary directory");
10274        let state = inspect_binary(root.path(), None);
10275        assert!(state.is_error(), "{state}");
10276        assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
10277    }
10278
10279    #[test]
10280    fn a_registration_naming_a_different_binary_is_a_divergence() {
10281        let host = Host::new();
10282        let other = host.binary.with_file_name("something-else");
10283        let state = inspect_binary(&host.binary, Some(&other));
10284        assert!(state.is_error(), "{state}");
10285        assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
10286    }
10287
10288    #[test]
10289    fn absence_is_reported_before_divergence() {
10290        let host = Host::new();
10291        let recorded = host.binary.clone();
10292        std::fs::remove_file(&recorded).expect("removable");
10293        let other = recorded.with_file_name("something-else");
10294        // Both faults are true at once. The operator needs to hear about the
10295        // missing file, not about a disagreement between two paths of which one
10296        // does not exist.
10297        assert!(matches!(
10298            inspect_binary(&recorded, Some(&other)),
10299            BinaryPath::Missing { .. }
10300        ));
10301    }
10302
10303    // -----------------------------------------------------------------------
10304    // The record
10305    // -----------------------------------------------------------------------
10306
10307    #[test]
10308    fn the_record_round_trips_through_toml() {
10309        let host = Host::new();
10310        let plan = InstallPlan::resolve(
10311            ServiceIdentity::product(),
10312            &host.request(StartMode::Boot),
10313            ServiceDirectories::of(&host.paths),
10314        )
10315        .expect("a resolvable plan");
10316        let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
10317        let record = InstallRecord::of(&plan, &definition, Utc::now());
10318        record.write(&host.paths).expect("a writable record");
10319        let read = InstallRecord::read(&host.paths)
10320            .expect("a readable record")
10321            .expect("a record is there");
10322        assert_eq!(read, record);
10323        assert_eq!(read.binary, host.binary);
10324        assert!(read.binary.is_absolute());
10325    }
10326
10327    #[test]
10328    fn a_unit_written_by_an_older_build_is_reported_and_a_current_one_is_not() {
10329        let host = Host::new();
10330        let identity = ServiceIdentity::product();
10331        let plan = InstallPlan::resolve(
10332            identity.clone(),
10333            &host.request(StartMode::Boot),
10334            ServiceDirectories::of(&host.paths),
10335        )
10336        .expect("a resolvable plan");
10337        let installed = systemd_unit(&plan);
10338        let definition =
10339            ServiceDefinition::from_text(DefinitionKind::SystemdUnit, installed.clone());
10340        let mut record = InstallRecord::of(&plan, &definition, Utc::now());
10341        let path = PathBuf::from("/etc/systemd/system/runner-manager.service");
10342
10343        assert_eq!(
10344            systemd_definition_drift(&identity, &record, path.clone(), &installed),
10345            None,
10346            "the record must rebuild exactly the unit `service install` wrote"
10347        );
10348
10349        record.installed_by_version = "0.4.7".into();
10350        let frozen = installed.replace(
10351            "[Service]\n",
10352            "[Service]\nLoadCredential=runner-manager.user-access-token:/var/lib/runner-manager/secrets/user-access-token\n",
10353        );
10354        let drift = systemd_definition_drift(&identity, &record, path, &frozen)
10355            .expect("an outdated unit is drift");
10356        assert_eq!(drift.installed_by_version, "0.4.7");
10357        assert_eq!(drift.rendered, installed);
10358
10359        let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
10360            .with_definition_drift(Some(drift));
10361        assert!(!status.is_healthy());
10362        assert!(
10363            status
10364                .problems()
10365                .iter()
10366                .any(|problem| problem.subject == "definition" && problem.detail.contains("0.4.7")),
10367            "{status}"
10368        );
10369    }
10370
10371    #[test]
10372    fn a_rejected_credential_keeps_its_first_moment_and_makes_the_service_unhealthy() {
10373        let host = Host::new();
10374        assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
10375        let first = Utc::now() - chrono::Duration::hours(9);
10376        record_github_credential_rejected(&host.paths, first).unwrap();
10377        record_github_credential_rejected(&host.paths, Utc::now()).unwrap();
10378        let since = github_credential_rejected_since(&host.paths).unwrap();
10379        assert_eq!(since, Some(first));
10380
10381        let status = ServiceStatus::compose(
10382            ServiceIdentity::product(),
10383            None,
10384            None,
10385            None,
10386            None,
10387            &host.paths,
10388        )
10389        .with_credential_rejection(since);
10390        assert!(!status.is_healthy());
10391        assert_eq!(status.credential_rejected_since(), Some(first));
10392        assert!(status.to_string().contains("auth login"), "{status}");
10393
10394        clear_github_credential_rejection(&host.paths).unwrap();
10395        clear_github_credential_rejection(&host.paths).unwrap();
10396        assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
10397    }
10398
10399    /// A guest that started nothing for 13 days while this said `healthy`.
10400    #[test]
10401    fn blocked_launches_make_the_service_unhealthy_with_their_remedy() {
10402        use crate::launch_health::{
10403            BlockCause, Viewpoint, clear_launches_blocked, launches_blocked,
10404            record_launches_blocked,
10405        };
10406
10407        let host = Host::new();
10408        let compose = || {
10409            ServiceStatus::compose(
10410                ServiceIdentity::product(),
10411                None,
10412                None,
10413                None,
10414                None,
10415                &host.paths,
10416            )
10417            .with_launches_blocked(launches_blocked(&host.paths, Utc::now()))
10418        };
10419        assert!(
10420            !compose()
10421                .problems()
10422                .iter()
10423                .any(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
10424        );
10425        let blocked = BlockCause::FenceHeld {
10426            directory: PathBuf::from("launch-fence"),
10427            owner: None,
10428        }
10429        .blocked_since(Utc::now(), Viewpoint::Guest);
10430        record_launches_blocked(&host.paths, &blocked, Utc::now()).unwrap();
10431
10432        let status = compose();
10433        assert!(!status.is_healthy());
10434        assert_eq!(status.launches_blocked(), Some(&blocked));
10435        let problem = status
10436            .problems()
10437            .iter()
10438            .find(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
10439            .expect("reported under its own subject");
10440        assert!(problem.detail.contains("systemctl restart"), "{status}");
10441
10442        clear_launches_blocked(&host.paths).unwrap();
10443        assert_eq!(compose().launches_blocked(), None);
10444    }
10445
10446    /// The daemon's own "I cannot read my credential", which used to exist
10447    /// only in its log while `service status` said `healthy`.
10448    #[test]
10449    fn an_unreadable_credential_keeps_its_first_moment_and_names_the_exact_command() {
10450        let host = Host::new();
10451        assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
10452        let first = Utc::now() - chrono::Duration::minutes(40);
10453        record_credential_unreadable(&host.paths, first).unwrap();
10454        record_credential_unreadable(&host.paths, Utc::now()).unwrap();
10455        let since = credential_unreadable_since(&host.paths).unwrap();
10456        assert_eq!(since, Some(first), "a restart loop must not move the start");
10457
10458        let status = ServiceStatus::compose(
10459            ServiceIdentity::product(),
10460            None,
10461            None,
10462            None,
10463            None,
10464            &host.paths,
10465        )
10466        .with_credential_unreadable(since);
10467        assert!(!status.is_healthy());
10468        let problem = status
10469            .problems()
10470            .iter()
10471            .find(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT)
10472            .expect("reported under its own subject");
10473        // No record, so the default start mode, whose exact command is named.
10474        assert!(
10475            problem
10476                .detail
10477                .contains(&sign_in_instruction(StartMode::default())),
10478            "{status}"
10479        );
10480
10481        clear_credential_unreadable(&host.paths).unwrap();
10482        clear_credential_unreadable(&host.paths).unwrap();
10483        assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
10484        let healthy = ServiceStatus::compose(
10485            ServiceIdentity::product(),
10486            None,
10487            None,
10488            None,
10489            None,
10490            &host.paths,
10491        )
10492        .with_credential_unreadable(None);
10493        assert!(
10494            !healthy
10495                .problems()
10496                .iter()
10497                .any(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT),
10498            "{healthy}"
10499        );
10500    }
10501
10502    #[test]
10503    fn a_sudo_command_names_a_binary_sudo_would_not_find() {
10504        assert_eq!(sudo_program(None), "runner-manager");
10505        assert_eq!(
10506            sudo_program(Some(Path::new("/usr/local/bin/runner-manager"))),
10507            "runner-manager"
10508        );
10509        assert_eq!(
10510            sudo_program(Some(Path::new(
10511                "/home/op/.nvm/versions/node/v26.13.0/lib/node_modules/@ivan-murzak/\
10512                 runner-manager-linux-x64/bin/runner-manager"
10513            ))),
10514            "/home/op/.nvm/versions/node/v26.13.0/lib/node_modules/@ivan-murzak/\
10515             runner-manager-linux-x64/bin/runner-manager"
10516        );
10517        assert_eq!(
10518            sudo_program(Some(Path::new("/home/o'p/my bin/runner-manager"))),
10519            "'/home/o'\\''p/my bin/runner-manager'"
10520        );
10521        assert_eq!(
10522            sudo_command("service status"),
10523            "sudo runner-manager service status"
10524        );
10525    }
10526
10527    #[test]
10528    fn the_sign_in_instruction_names_the_start_mode_it_writes() {
10529        assert!(sign_in_instruction(StartMode::Login).contains("auth login --start-at login`"));
10530        assert!(sign_in_instruction(StartMode::Boot).contains("auth login --start-at boot`"));
10531        assert_eq!(
10532            sign_in_instruction(StartMode::Boot).starts_with("`sudo "),
10533            cfg!(unix),
10534            "a machine-scoped store needs root on Unix"
10535        );
10536        assert_eq!(
10537            sign_in_instruction(StartMode::Login).contains("SSH"),
10538            cfg!(target_os = "macos"),
10539            "only the macOS login keychain is out of an SSH session's reach"
10540        );
10541    }
10542
10543    /// The record is readable by the account whose directory it is in.
10544    ///
10545    /// A boot-mode `service install` runs under `sudo`, and `NamedTempFile`
10546    /// creates at `0600`, so the record landed `root`-owned and unreadable by
10547    /// the operator — `service status` then failed on their own host with
10548    /// `Permission denied`. Ownership cannot be reproduced in an unprivileged
10549    /// test, but the mode that made ownership fatal can, and it is the half
10550    /// this code controls.
10551    #[cfg(unix)]
10552    #[test]
10553    fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
10554        use std::os::unix::fs::PermissionsExt as _;
10555
10556        let host = Host::new();
10557        let plan = InstallPlan::resolve(
10558            ServiceIdentity::product(),
10559            &host.request(StartMode::Boot),
10560            ServiceDirectories::of(&host.paths),
10561        )
10562        .expect("a resolvable plan");
10563        let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
10564        InstallRecord::of(&plan, &definition, Utc::now())
10565            .write(&host.paths)
10566            .expect("a writable record");
10567
10568        let mode = std::fs::metadata(InstallRecord::path(&host.paths))
10569            .expect("the record is there")
10570            .permissions()
10571            .mode()
10572            & 0o777;
10573        assert_eq!(
10574            mode, 0o644,
10575            "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
10576             service install` wrote, and `service status` fails on their own host. It holds no \
10577             credential and sits in a 0700 directory, so 0644 discloses nothing"
10578        );
10579    }
10580
10581    /// A record written before the service ran a copy of its own must still
10582    /// load. It reads as `None`, which is the truth about it: that registration
10583    /// names the package manager's file and cannot be upgraded under itself.
10584    #[test]
10585    fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
10586        let host = Host::new();
10587        let path = InstallRecord::path(&host.paths);
10588        std::fs::write(
10589            &path,
10590            format!(
10591                "schema_version = {RECORD_SCHEMA_VERSION}
10592service_name = \"runner-manager\"
10593                 manager = \"systemd\"
10594start_mode = \"boot\"
10595account = \"root\"
10596                 binary = \"/x\"
10597arguments = []
10598restart_delay_secs = 15
10599                 restart_reset_secs = 600
10600log_file = \"/x\"
10601                 installed_at = \"2026-01-01T00:00:00Z\"
10602installed_by_version = \"0.1.0\"
10603                 [directories]
10604config = \"/a\"
10605state = \"/b\"
10606runtime = \"/c\"
10607logs = \"/d\"
10608"
10609            ),
10610        )
10611        .expect("a writable record");
10612        let read = InstallRecord::read(&host.paths)
10613            .expect("a record missing an optional field is still readable")
10614            .expect("a record is there");
10615        assert_eq!(
10616            read.source_binary, None,
10617            "the legacy layout has no source, and must not invent one"
10618        );
10619    }
10620
10621    /// The field that makes an upgrade possible survives the write.
10622    #[test]
10623    fn a_registration_remembers_the_file_it_was_copied_from() {
10624        let host = Host::new();
10625        let source = host.binary.with_file_name("npm-installed-runner-manager");
10626        std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
10627        let plan = InstallPlan::resolve(
10628            ServiceIdentity::product(),
10629            &host.request(StartMode::Boot).copied_from(&source),
10630            ServiceDirectories::of(&host.paths),
10631        )
10632        .expect("a resolvable plan");
10633        let definition = ServiceDefinition::from_text(
10634            DefinitionKind::SystemdUnit,
10635            "[Service]
10636",
10637        );
10638        let record = InstallRecord::of(&plan, &definition, Utc::now());
10639        record.write(&host.paths).expect("a writable record");
10640
10641        let read = InstallRecord::read(&host.paths)
10642            .expect("a readable record")
10643            .expect("a record is there");
10644        assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
10645        assert_ne!(
10646            read.source_binary.as_deref(),
10647            Some(read.binary.as_path()),
10648            "the whole point is that the two are different files: one the service holds open,              one the package manager is free to replace"
10649        );
10650    }
10651
10652    #[test]
10653    fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
10654        let host = Host::new();
10655        let path = InstallRecord::path(&host.paths);
10656        std::fs::write(
10657            &path,
10658            format!(
10659                "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
10660                 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
10661                 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
10662                 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
10663                 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
10664                RECORD_SCHEMA_VERSION + 1
10665            ),
10666        )
10667        .expect("a writable record");
10668        let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
10669        assert!(
10670            matches!(error, ServiceError::RecordUnreadable { .. }),
10671            "{error}"
10672        );
10673        assert!(
10674            error.to_string().contains("service uninstall"),
10675            "the message must say how to recover: {error}"
10676        );
10677    }
10678
10679    #[test]
10680    fn no_record_is_not_an_error() {
10681        let host = Host::new();
10682        assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
10683        assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
10684    }
10685
10686    // -----------------------------------------------------------------------
10687    // The last successful GitHub contact
10688    // -----------------------------------------------------------------------
10689
10690    #[test]
10691    fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
10692        let host = Host::new();
10693        assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
10694    }
10695
10696    #[test]
10697    fn the_heartbeat_round_trips_to_the_second() {
10698        let host = Host::new();
10699        let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
10700            .expect("a valid timestamp")
10701            .with_timezone(&Utc);
10702        record_github_contact(&host.paths, at).expect("a writable heartbeat");
10703        assert_eq!(
10704            last_github_contact(&host.paths).expect("readable"),
10705            Some(at)
10706        );
10707    }
10708
10709    #[test]
10710    fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
10711        let host = Host::new();
10712        std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
10713        let error = last_github_contact(&host.paths)
10714            .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
10715        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
10716    }
10717
10718    /// The surface the three-hour outage did not have.
10719    ///
10720    /// A root that refuses a launch does so before any attempt row exists, and
10721    /// the daemon's log scrubs the paths out of the sentence, so this file is
10722    /// the only place the directory and the remediation can reach the operator.
10723    #[test]
10724    fn a_runner_root_refusal_round_trips_for_service_status() {
10725        let host = Host::new();
10726        let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
10727
10728        assert!(
10729            runner_root_refusals(&host.paths)
10730                .expect("readable")
10731                .is_empty(),
10732            "no record means every policy is placing runners"
10733        );
10734
10735        record_runner_root_refusal(
10736            &host.paths,
10737            "policy-a",
10738            at,
10739            "denied_by_privacy_policy",
10740            "/Volumes/NVME/runners",
10741            "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
10742        )
10743        .expect("a writable record");
10744
10745        let refusals = runner_root_refusals(&host.paths).expect("readable");
10746        assert_eq!(refusals.len(), 1);
10747        assert_eq!(refusals[0].policy, "policy-a");
10748        assert_eq!(refusals[0].at, at);
10749        assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
10750        assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
10751        assert!(
10752            refusals[0].detail.contains("/Volumes/NVME/runners")
10753                && refusals[0].detail.contains("Full Disk Access"),
10754            "the path and the remediation are the whole point of this file: {refusals:?}"
10755        );
10756    }
10757
10758    /// The regression that would put the outage straight back.
10759    ///
10760    /// A host runs several policies and they do not share a fate. When the
10761    /// record was one host-wide slot, the policy that placed a runner deleted
10762    /// the record of the policy that could not — on the same reconcile pass —
10763    /// and `service status` reported a healthy host that had started zero
10764    /// runners for an entire target.
10765    #[test]
10766    fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
10767        let host = Host::new();
10768        let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
10769        record_runner_root_refusal(
10770            &host.paths,
10771            "broken",
10772            at,
10773            "denied_by_privacy_policy",
10774            "/Volumes/NVME/runners",
10775            "detail",
10776        )
10777        .expect("a writable record");
10778        record_runner_root_refusal(
10779            &host.paths,
10780            "also-broken",
10781            at,
10782            "not_writable",
10783            "/srv/other",
10784            "detail",
10785        )
10786        .expect("a writable record");
10787
10788        // The policy with its own working root succeeds and clears only itself.
10789        clear_runner_root_refusal(&host.paths, "healthy")
10790            .expect("clearing an absent policy is fine");
10791        clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
10792
10793        let refusals = runner_root_refusals(&host.paths).expect("readable");
10794        assert_eq!(
10795            refusals
10796                .iter()
10797                .map(|r| r.policy.as_str())
10798                .collect::<Vec<_>>(),
10799            vec!["broken"],
10800            "the policy that is still refused must keep its record"
10801        );
10802    }
10803
10804    /// Clearing is what keeps a fixed host from reporting a stale fault, and the
10805    /// file goes when the last entry does.
10806    #[test]
10807    fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
10808        let host = Host::new();
10809        record_runner_root_refusal(
10810            &host.paths,
10811            "p",
10812            Utc::now(),
10813            "not_writable",
10814            "/srv/x",
10815            "detail",
10816        )
10817        .expect("a writable record");
10818
10819        clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
10820        assert!(
10821            !root_refusal_path(&host.paths).exists(),
10822            "a host with nothing refused leaves nothing behind"
10823        );
10824        clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
10825    }
10826
10827    /// Same rule as the heartbeat: absence means "every policy is placing
10828    /// runners", so a record that cannot be parsed must not be reported as one.
10829    #[test]
10830    fn a_malformed_refusal_is_an_error_and_not_silently_none() {
10831        let host = Host::new();
10832        std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
10833        let error = runner_root_refusals(&host.paths)
10834            .expect_err("an unparseable record is not the same as no record");
10835        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
10836    }
10837
10838    /// The half of this feature that turns the file into a diagnosis, and the
10839    /// half that had no test: `service status` must actually read it.
10840    ///
10841    /// A **note** and not a problem, deliberately. `service status` runs as
10842    /// whoever typed it, must stay readable on a host with nothing installed,
10843    /// and drives an exit code -- and nothing an operator can type clears this
10844    /// record, so a problem would fail that exit code on a host whose root was
10845    /// fixed but which has had no queued job since, with a printed remedy
10846    /// (`service uninstall && service install`) that does not touch `state/`.
10847    #[test]
10848    fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
10849        let host = Host::new();
10850        record_runner_root_refusal(
10851            &host.paths,
10852            "policy-a",
10853            DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
10854            "denied_by_privacy_policy",
10855            "/Volumes/NVME/runners",
10856            "Grant Full Disk Access to the program that runs the service",
10857        )
10858        .expect("a writable record");
10859
10860        let status = host.operations().status().expect("a readable status");
10861        let notes = status.notes().join("\n");
10862
10863        assert!(
10864            notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
10865            "the directory and the remediation the log had to scrub must appear here: {notes}"
10866        );
10867        assert!(
10868            notes.contains("policy-a"),
10869            "the operator has to know which target placed no runner: {notes}"
10870        );
10871        assert!(
10872            !status
10873                .problems()
10874                .iter()
10875                .any(|problem| problem.subject == "runner root"),
10876            "a record nothing an operator types can clear must not drive the exit code"
10877        );
10878    }
10879
10880    // -----------------------------------------------------------------------
10881    // -----------------------------------------------------------------------
10882    // Install
10883    // -----------------------------------------------------------------------
10884
10885    #[test]
10886    fn install_records_the_absolute_binary_path_and_the_four_directories() {
10887        let host = Host::new();
10888        let installed = host
10889            .operations()
10890            .install(&host.request(StartMode::Boot))
10891            .expect("an install against the recording controls");
10892
10893        assert_eq!(installed.record.binary, host.binary);
10894        assert!(installed.record.binary.is_absolute());
10895        assert_eq!(installed.record.start_mode, StartMode::Boot);
10896        assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
10897        assert_eq!(
10898            installed.record.directories,
10899            ServiceDirectories::of(&host.paths)
10900        );
10901        assert_eq!(
10902            installed.record.log_file,
10903            host.paths.logs_dir().join(LOG_FILE_STEM)
10904        );
10905        assert_eq!(installed.record.restart_delay_secs, 15);
10906
10907        let registrations = host.controls.registrations();
10908        assert_eq!(registrations.len(), 1);
10909        assert_eq!(registrations[0].0, StartMode::Boot);
10910        assert_eq!(registrations[0].1, "runner-manager");
10911    }
10912
10913    #[test]
10914    fn install_is_refused_while_the_single_instance_lock_is_held() {
10915        let host = Host::new();
10916        // The discriminator: with the lock free, the same call succeeds.
10917        {
10918            let operations = host.operations();
10919            operations
10920                .install(&host.request(StartMode::Boot))
10921                .expect("an install with the lock free");
10922            operations.uninstall().expect("a clean slate");
10923        }
10924
10925        let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
10926            .expect("this process takes the lock first");
10927
10928        let error = host
10929            .operations()
10930            .install(&host.request(StartMode::Boot))
10931            .expect_err("a second agent must not be registered while one is running");
10932        assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
10933        assert!(
10934            error.to_string().contains("already running"),
10935            "the message must be actionable: {error}"
10936        );
10937        assert!(
10938            host.controls.registrations().is_empty(),
10939            "a refused install must register nothing"
10940        );
10941        assert_eq!(
10942            InstallRecord::read(&host.paths).expect("readable"),
10943            None,
10944            "a refused install must write no record"
10945        );
10946    }
10947
10948    /// Installing over the same start mode replaces the registration.
10949    ///
10950    /// It used to be refused, and the refusal is what broke a real host: the
10951    /// caller has already swapped the binary the service runs by the time this
10952    /// is reached — that is the documented way to upgrade by hand — so refusing
10953    /// left a daemon running an unregistered binary and, on macOS, one the
10954    /// stored credential's keychain grant did not name.
10955    #[test]
10956    fn installing_over_the_same_start_mode_replaces_the_registration() {
10957        let host = Host::new();
10958        let operations = host.operations();
10959        operations
10960            .install(&host.request(StartMode::Boot))
10961            .expect("the first install");
10962
10963        let again = operations
10964            .install(&host.request(StartMode::Boot))
10965            .expect("an install over the same mode replaces rather than refusing");
10966
10967        assert!(
10968            again.replaced_existing,
10969            "the operator is told this replaced something rather than made it"
10970        );
10971        assert_eq!(
10972            host.controls.registrations().len(),
10973            1,
10974            "replacing must not leave two registrations behind"
10975        );
10976        assert_eq!(
10977            InstallRecord::read(&host.paths)
10978                .expect("readable")
10979                .expect("a record")
10980                .start_mode,
10981            StartMode::Boot
10982        );
10983    }
10984
10985    /// The *other* mode is still refused, because changing it moves the
10986    /// registration between two service managers and changes the account and
10987    /// the secret store with it. `set_start_mode` is what does that.
10988    #[test]
10989    fn installing_over_the_other_start_mode_is_refused() {
10990        let host = Host::new();
10991        let operations = host.operations();
10992        operations
10993            .install(&host.request(StartMode::Boot))
10994            .expect("the first install");
10995
10996        let error = operations
10997            .install(&host.request(StartMode::Login))
10998            .expect_err("a mode change is not an install");
10999        assert!(
11000            matches!(
11001                error,
11002                ServiceError::AlreadyInstalled {
11003                    existing: StartMode::Boot,
11004                    requested: StartMode::Login,
11005                    ..
11006                }
11007            ),
11008            "{error}"
11009        );
11010        assert!(
11011            !error
11012                .to_string()
11013                .contains("switch the start mode in place,"),
11014            "the old remedy named a capability no command offers; the terminal UI is where \
11015             the start mode moves: {error}"
11016        );
11017        assert_eq!(host.controls.registrations().len(), 1);
11018    }
11019
11020    #[test]
11021    fn install_rolls_back_the_registration_when_record_persistence_fails() {
11022        let host = Host::new();
11023        let record_path = InstallRecord::path(&host.paths);
11024        std::fs::create_dir(&record_path).expect("a directory blocks the record file");
11025
11026        let error = host
11027            .operations()
11028            .install(&host.request(StartMode::Boot))
11029            .expect_err("record persistence must fail");
11030
11031        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11032        assert!(
11033            host.controls.registrations().is_empty(),
11034            "a failed install must not leave a live unrecorded registration"
11035        );
11036        assert!(
11037            host.controls
11038                .calls()
11039                .iter()
11040                .any(|call| call == "uninstall runner-manager (boot)"),
11041            "the registration must be explicitly rolled back: {:?}",
11042            host.controls.calls()
11043        );
11044        assert!(
11045            !host.runner_root.as_path().exists(),
11046            "the rollback must take the runner root this install created with it; a directory \
11047             prepared for a registration that does not exist is litter, and on Windows it is \
11048             litter with a security descriptor"
11049        );
11050    }
11051
11052    // -----------------------------------------------------------------------
11053    // The runner root (b2)
11054    // -----------------------------------------------------------------------
11055
11056    #[test]
11057    fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
11058        use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
11059
11060        // The mapping `prepare_runner_root` reads, asserted against the same
11061        // function the registration's own principal comes from — which is the
11062        // point of deriving it there rather than restating it.
11063        assert_eq!(
11064            ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
11065            ServiceAccount::LocalSystem
11066        );
11067        assert_eq!(
11068            ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
11069            ServiceAccount::InvokingUser
11070        );
11071
11072        let boot = default_root_sddl(&RootAdmission::LocalSystem);
11073        assert!(!grants_broad_write(&boot), "{boot}");
11074        assert!(
11075            !boot.contains("S-1-5-21"),
11076            "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
11077        );
11078
11079        // A login task runs under a *filtered* token — `RunLevel` is
11080        // `LeastPrivilege`, see `windows_scheduled_task_xml` — in which
11081        // Administrators is deny-only. Without an ace of its own the account
11082        // the task runs as would be admitted by nothing at all.
11083        let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
11084        assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
11085        assert!(!grants_broad_write(&login), "{login}");
11086    }
11087
11088    #[test]
11089    fn an_install_reports_the_runner_root_it_prepared() {
11090        let host = Host::new();
11091        let installed = host
11092            .operations()
11093            .install(&host.request(StartMode::Boot))
11094            .expect("an install");
11095        let rendered = installed.runner_root.to_string();
11096        assert!(
11097            !rendered.contains("S-1-5-21"),
11098            "the report must add no identity to the output: {rendered}"
11099        );
11100        if cfg!(windows) {
11101            assert_eq!(
11102                installed.runner_root.path(),
11103                Some(host.runner_root.as_path())
11104            );
11105            assert!(
11106                host.runner_root.as_path().is_dir(),
11107                "the directory jobs would run in has to exist once the service is registered"
11108            );
11109        } else {
11110            assert_eq!(
11111                installed.runner_root,
11112                crate::runner_root_access::RootAccessSummary::NotApplicable,
11113                "macOS and Linux keep the runtime directory they have always used"
11114            );
11115        }
11116    }
11117
11118    #[test]
11119    fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
11120        let host = Host::new();
11121        let operations = host.operations();
11122        operations
11123            .install(&host.request(StartMode::Boot))
11124            .expect("an install at boot");
11125
11126        let change = operations
11127            .set_start_mode(StartMode::Login)
11128            .expect("a switch to login");
11129
11130        assert!(change.changed);
11131        if cfg!(windows) {
11132            assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
11133            assert!(
11134                host.runner_root.as_path().is_dir(),
11135                "the switch must not remove the directory it reconciled"
11136            );
11137        }
11138        // The operator is told, because the account that may write there has
11139        // moved with the mode.
11140        assert!(
11141            change.to_string().contains("runner root"),
11142            "{}",
11143            change.to_string()
11144        );
11145    }
11146
11147    #[test]
11148    fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
11149        let host = Host::new();
11150        let operations = host.operations();
11151        operations
11152            .install(&host.request(StartMode::Boot))
11153            .expect("an install at boot");
11154
11155        let change = operations
11156            .set_start_mode(StartMode::Boot)
11157            .expect("a switch to the mode already in force");
11158
11159        assert!(!change.changed);
11160        assert_eq!(
11161            change.runner_root,
11162            crate::runner_root_access::RootAccessSummary::NotApplicable,
11163            "nothing moves, so nothing about the root's access control has to; reconciling here \
11164             would turn a no-op command into one that can fail on a permission it does not need"
11165        );
11166    }
11167
11168    #[cfg(windows)]
11169    #[test]
11170    fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
11171        let host = Host::new();
11172        host.controls
11173            .fail_next_install(StartMode::Boot, "injected registration failure");
11174
11175        let error = host
11176            .operations()
11177            .install(&host.request(StartMode::Boot))
11178            .expect_err("the manager refuses the registration");
11179
11180        assert!(matches!(error, ServiceError::Control { .. }), "{error}");
11181        assert!(
11182            !host.runner_root.as_path().exists(),
11183            "the directory was created for a registration that does not exist"
11184        );
11185    }
11186
11187    #[cfg(windows)]
11188    #[test]
11189    fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
11190        let host = Host::new();
11191        // What a directory created below `C:\` with inheritance left on looks
11192        // like, built deliberately because a per-account `%TEMP%` never
11193        // produces one by accident.
11194        crate::runner_root_access::create_with_descriptor_for_tests(
11195            host.runner_root.as_path(),
11196            "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
11197        )
11198        .expect("a deliberately open runner root");
11199        let before = crate::runner_root_access::report(host.runner_root.as_path());
11200
11201        let error = host
11202            .operations()
11203            .install(&host.request(StartMode::Boot))
11204            .expect_err("an open runner root is refused");
11205
11206        assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
11207        assert!(
11208            error.to_string().contains("nothing was registered"),
11209            "{error}"
11210        );
11211        assert!(
11212            host.controls.registrations().is_empty(),
11213            "the refusal has to come before the platform is asked to register anything: {:?}",
11214            host.controls.calls()
11215        );
11216        assert_eq!(
11217            crate::runner_root_access::report(host.runner_root.as_path()),
11218            before,
11219            "an open directory is refused rather than tightened: its contents cannot be trusted, \
11220             so adopting it would be worse than declining it"
11221        );
11222    }
11223
11224    #[cfg(windows)]
11225    #[test]
11226    fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
11227        let host = Host::new();
11228        let operations = host.operations();
11229        operations
11230            .install(&host.request(StartMode::Boot))
11231            .expect("an install");
11232        assert!(host.runner_root.as_path().is_dir());
11233
11234        operations.uninstall().expect("an uninstall");
11235
11236        assert!(
11237            host.runner_root.as_path().is_dir(),
11238            "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
11239             runner root may hold an operator's retained workspaces."
11240        );
11241    }
11242
11243    #[test]
11244    fn install_reviews_what_it_registered() {
11245        let host = Host::new();
11246        let installed = host
11247            .operations()
11248            .install(&host.request(StartMode::Boot))
11249            .expect("an install");
11250        assert!(
11251            installed.review.is_least_privilege(),
11252            "{}",
11253            installed.review
11254        );
11255        assert!(
11256            !installed.review.controls().is_empty(),
11257            "a review that confirms nothing proves nothing: {}",
11258            installed.review
11259        );
11260        assert_eq!(
11261            installed.review.kind(),
11262            host_definition_kind(StartMode::Boot),
11263            "the review must be of the definition this host's manager was given"
11264        );
11265        assert!(
11266            !installed.review.account().justification().is_empty(),
11267            "a privileged account with no stated reason is an unreviewed one"
11268        );
11269    }
11270
11271    // -----------------------------------------------------------------------
11272    // Uninstall deletes nothing else
11273    // -----------------------------------------------------------------------
11274
11275    #[test]
11276    fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
11277        let host = Host::new();
11278        let operations = host.operations();
11279        operations
11280            .install(&host.request(StartMode::Boot))
11281            .expect("an install");
11282
11283        // The files `05-infrastructure.md` says must survive. Written *after*
11284        // the install so that the snapshot below is of a host in the state an
11285        // operator's would be in.
11286        let config = host.paths.config_dir();
11287        std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
11288        std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
11289        std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
11290        std::fs::write(
11291            host.paths
11292                .state_dir()
11293                .join("packages/2.330.0/runner.tar.gz"),
11294            b"cached package",
11295        )
11296        .expect("writable");
11297        std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
11298        std::fs::write(
11299            host.paths.state_dir().join("secrets/user-access-token"),
11300            b"a stand-in for the stored credential",
11301        )
11302        .expect("writable");
11303        std::fs::write(
11304            host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
11305            b"diagnostics",
11306        )
11307        .expect("writable");
11308
11309        let roots: Vec<PathBuf> = host
11310            .paths
11311            .all()
11312            .iter()
11313            .map(|(_, path)| (*path).to_path_buf())
11314            .collect();
11315        let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
11316        let before = snapshot(&roots);
11317
11318        // Non-vacuity: a comparison of two empty maps would pass whatever
11319        // `uninstall` did.
11320        assert!(
11321            before.len() >= 6,
11322            "the fixture must actually contain the files this test is about, got {before:#?}"
11323        );
11324        let record_path = InstallRecord::path(&host.paths);
11325        assert!(
11326            before.contains_key(&record_path),
11327            "the install record must be present before uninstall"
11328        );
11329
11330        let uninstalled = operations.uninstall().expect("an uninstall");
11331        assert!(uninstalled.removed_registration);
11332        assert!(uninstalled.removed_record);
11333
11334        let after = snapshot(&roots);
11335
11336        // Exactly one thing changed, and it is the registration's own record.
11337        let mut expected = before.clone();
11338        expected.remove(&record_path);
11339        assert_eq!(
11340            after, expected,
11341            "uninstall must remove its own record and nothing else"
11342        );
11343        assert!(
11344            !record_path.exists(),
11345            "the record itself must go, or `uninstall` did nothing at all"
11346        );
11347        assert!(
11348            uninstalled
11349                .preserved
11350                .iter()
11351                .all(|path| roots.contains(&path.as_path())),
11352            "the preserved list must name the four directories: {uninstalled}"
11353        );
11354    }
11355
11356    #[test]
11357    fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
11358        let host = Host::new();
11359        let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
11360        assert!(!uninstalled.removed_registration);
11361        assert!(!uninstalled.removed_record);
11362    }
11363
11364    #[test]
11365    fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
11366        let host = Host::new();
11367        let operations = host.operations();
11368        operations
11369            .install(&host.request(StartMode::Boot))
11370            .expect("an install");
11371        std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
11372
11373        let uninstalled = operations.uninstall().expect("an uninstall");
11374        assert!(
11375            uninstalled.removed_registration,
11376            "a lost record must not strand a registration"
11377        );
11378        assert!(host.controls.registrations().is_empty());
11379    }
11380
11381    // -----------------------------------------------------------------------
11382    // Switching start mode
11383    // -----------------------------------------------------------------------
11384
11385    #[test]
11386    fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
11387        let host = Host::new();
11388        let operations = host.operations();
11389        operations
11390            .install(&host.request(StartMode::Boot))
11391            .expect("an install at boot");
11392
11393        // The discriminator. If the switch re-resolved the binary the way
11394        // `install` does, it would either fail here or silently record the test
11395        // binary instead. Removing the file makes the difference visible.
11396        std::fs::remove_file(&host.binary).expect("the installed binary goes away");
11397
11398        let change = operations
11399            .set_start_mode(StartMode::Login)
11400            .expect("a switch that does not reinstall the product");
11401        assert!(change.changed);
11402        assert_eq!(change.from, StartMode::Boot);
11403        assert_eq!(change.to, StartMode::Login);
11404        assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
11405
11406        let record = InstallRecord::read(&host.paths)
11407            .expect("readable")
11408            .expect("a record");
11409        assert_eq!(record.start_mode, StartMode::Login);
11410        assert_eq!(
11411            record.binary, host.binary,
11412            "the recorded path must survive the switch untouched"
11413        );
11414
11415        let registrations = host.controls.registrations();
11416        assert_eq!(registrations.len(), 1, "{registrations:?}");
11417        assert_eq!(registrations[0].0, StartMode::Login);
11418        assert!(
11419            registrations[0]
11420                .2
11421                .command_line
11422                .contains(&host.binary.to_string_lossy().into_owned()),
11423            "{:?}",
11424            registrations[0].2
11425        );
11426    }
11427
11428    #[test]
11429    fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
11430        let host = Host::new();
11431        let operations = host.operations();
11432        operations
11433            .install(&host.request(StartMode::Boot))
11434            .expect("an install at boot");
11435        let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
11436        host.controls
11437            .fail_next_install(StartMode::Login, "injected target failure");
11438
11439        let error = operations
11440            .set_start_mode(StartMode::Login)
11441            .expect_err("the target manager refuses the install");
11442
11443        assert!(matches!(error, ServiceError::Control { .. }), "{error}");
11444        assert_eq!(
11445            std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
11446            record_before
11447        );
11448        let registrations = host.controls.registrations();
11449        assert_eq!(registrations.len(), 1, "{registrations:?}");
11450        assert_eq!(registrations[0].0, StartMode::Boot);
11451    }
11452
11453    #[test]
11454    fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
11455        let host = Host::new();
11456        let operations = host.operations();
11457        operations
11458            .install(&host.request(StartMode::Boot))
11459            .expect("an install at boot");
11460        let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
11461        let config = host.paths.config_dir().to_path_buf();
11462        let hidden = config.with_file_name("config-hidden-by-fault");
11463        host.controls.hide_directory_after_install(
11464            StartMode::Login,
11465            config.clone(),
11466            hidden.clone(),
11467        );
11468
11469        let error = operations
11470            .set_start_mode(StartMode::Login)
11471            .expect_err("the injected filesystem fault prevents persistence");
11472
11473        std::fs::remove_file(&config).expect("remove the injected blocker");
11474        std::fs::rename(&hidden, &config).expect("restore the record directory");
11475        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11476        assert_eq!(
11477            std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
11478            record_before
11479        );
11480        let registrations = host.controls.registrations();
11481        assert_eq!(registrations.len(), 1, "{registrations:?}");
11482        assert_eq!(registrations[0].0, StartMode::Boot);
11483        assert!(
11484            host.controls
11485                .calls()
11486                .iter()
11487                .any(|call| call == "uninstall runner-manager (login)"),
11488            "the target must be rolled back: {:?}",
11489            host.controls.calls()
11490        );
11491    }
11492
11493    #[test]
11494    fn switching_to_the_mode_already_in_force_registers_nothing_again() {
11495        let host = Host::new();
11496        let operations = host.operations();
11497        operations
11498            .install(&host.request(StartMode::Boot))
11499            .expect("an install");
11500        let before = host.controls.calls().len();
11501
11502        let change = operations
11503            .set_start_mode(StartMode::Boot)
11504            .expect("a no-op switch");
11505        assert!(!change.changed);
11506        assert_eq!(
11507            host.controls.calls().len(),
11508            before,
11509            "a no-op switch must not touch the service manager"
11510        );
11511    }
11512
11513    #[test]
11514    fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
11515        let host = Host::new();
11516        let error = host
11517            .operations()
11518            .set_start_mode(StartMode::Login)
11519            .expect_err("there is nothing to switch");
11520        assert!(
11521            matches!(error, ServiceError::NotInstalled { .. }),
11522            "{error}"
11523        );
11524    }
11525
11526    // -----------------------------------------------------------------------
11527    // Status
11528    // -----------------------------------------------------------------------
11529
11530    #[test]
11531    fn status_reports_the_four_facts_journey_five_asks_for() {
11532        let host = Host::new();
11533        let operations = host.operations();
11534        operations
11535            .install(&host.request(StartMode::Boot))
11536            .expect("an install");
11537        let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
11538            .expect("a valid timestamp")
11539            .with_timezone(&Utc);
11540        record_github_contact(&host.paths, at).expect("a heartbeat");
11541
11542        let status = operations.status().expect("a status");
11543        assert_eq!(status.start_mode(), Some(StartMode::Boot));
11544        assert_eq!(
11545            status.binary().map(BinaryPath::recorded),
11546            Some(host.binary.as_path())
11547        );
11548        assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
11549        assert_eq!(status.last_github_contact(), Some(at));
11550        assert!(status.is_installed());
11551        assert!(status.is_healthy(), "{status}");
11552
11553        let printed = status.to_string();
11554        for fragment in [
11555            "start mode",
11556            "diagnostic log",
11557            "last GitHub contact",
11558            "binary",
11559        ] {
11560            assert!(printed.contains(fragment), "{printed}");
11561        }
11562    }
11563
11564    /// A record this account may not read is reported, and `status` keeps
11565    /// going.
11566    ///
11567    /// It used to end the command with `Permission denied`, which is exactly
11568    /// what an operator met on a boot-mode host: `sudo service install` wrote
11569    /// the record `0600` and `root`-owned, and `service status` — a command
11570    /// that asks no privilege of anybody — then printed nothing at all about a
11571    /// registration the service manager could describe perfectly well.
11572    ///
11573    /// Ownership cannot be reproduced without privileges; the mode that made it
11574    /// fatal can.
11575    #[cfg(unix)]
11576    #[test]
11577    fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
11578        use std::os::unix::fs::PermissionsExt as _;
11579
11580        // `root` is not subject to the mode bits and would read the record
11581        // regardless, leaving the assertions below testing nothing.
11582        // SAFETY: `geteuid` takes no argument and cannot fail.
11583        if unsafe { libc::geteuid() } == 0 {
11584            return;
11585        }
11586
11587        let host = Host::new();
11588        let operations = host.operations();
11589        operations
11590            .install(&host.request(StartMode::Boot))
11591            .expect("an install");
11592        assert!(
11593            operations.status().expect("a status").is_healthy(),
11594            "the discriminator: healthy before the record is made unreadable"
11595        );
11596
11597        let record = InstallRecord::path(&host.paths);
11598        std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
11599            .expect("the mode is applied");
11600
11601        let status = operations
11602            .status()
11603            .expect("a record this account may not read is reported, not thrown");
11604        assert!(status.is_installed(), "{status}");
11605        assert!(!status.is_healthy(), "{status}");
11606
11607        let printed = status.to_string();
11608        assert!(
11609            printed.contains("this account may not read it"),
11610            "the operator is told which of the two states this is: {printed}"
11611        );
11612        assert!(
11613            !printed.contains("there is no install record"),
11614            "a record that is there and unreadable is not a record that is missing, and the \
11615             missing one's remedy starts with `service uninstall`: {printed}"
11616        );
11617
11618        // Left readable so the fixture's own cleanup is not the thing that
11619        // fails on a host where TempDir removal walks the tree.
11620        std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
11621            .expect("the mode is restored");
11622    }
11623
11624    #[test]
11625    fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
11626        let host = Host::new();
11627        let operations = host.operations();
11628        operations
11629            .install(&host.request(StartMode::Boot))
11630            .expect("an install");
11631
11632        // The discriminator: healthy first, so a `is_healthy` that always
11633        // returned false could not pass this test.
11634        assert!(
11635            operations.status().expect("a status").is_healthy(),
11636            "the freshly installed host must be healthy"
11637        );
11638
11639        std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
11640
11641        let status = operations.status().expect("a status");
11642        assert!(!status.is_healthy(), "{status}");
11643        assert!(
11644            status
11645                .problems()
11646                .iter()
11647                .any(|problem| problem.subject == "binary"),
11648            "{status}"
11649        );
11650        assert!(status.to_string().contains("STALE"), "{status}");
11651    }
11652
11653    #[test]
11654    fn status_reports_a_registration_that_would_not_start_at_boot() {
11655        let host = Host::new();
11656        let operations = host.operations();
11657        operations
11658            .install(&host.request(StartMode::Boot))
11659            .expect("an install");
11660        assert!(operations.status().expect("a status").is_healthy());
11661
11662        host.controls.edit("runner-manager", |registration| {
11663            registration.starts_automatically = false;
11664        });
11665
11666        let status = operations.status().expect("a status");
11667        assert!(!status.is_healthy(), "{status}");
11668        assert!(
11669            status
11670                .problems()
11671                .iter()
11672                .any(|problem| problem.detail.contains("after a reboot")),
11673            "{status}"
11674        );
11675    }
11676
11677    #[test]
11678    fn install_starts_the_registration_and_a_later_stop_is_unhealthy() {
11679        let host = Host::new();
11680        let operations = host.operations();
11681        operations
11682            .install(&host.request(StartMode::Login))
11683            .expect("install and immediate start");
11684
11685        assert!(
11686            operations.status().expect("running status").is_running(),
11687            "install must not wait for the next login trigger"
11688        );
11689        operations.stop().expect("stop the registration");
11690        let status = operations.status().expect("stopped status");
11691        assert!(!status.is_healthy(), "{status}");
11692        assert!(
11693            status
11694                .problems()
11695                .iter()
11696                .any(|problem| problem.subject == "runtime"),
11697            "{status}"
11698        );
11699        let rendered = status.to_string();
11700        assert!(
11701            rendered.contains("runner-manager service install --start-at login"),
11702            "{rendered}"
11703        );
11704        assert!(
11705            !rendered.contains("runner-manager service start"),
11706            "{rendered}"
11707        );
11708    }
11709
11710    #[test]
11711    fn status_reports_a_restart_policy_something_else_edited() {
11712        let host = Host::new();
11713        let operations = host.operations();
11714        operations
11715            .install(&host.request(StartMode::Boot))
11716            .expect("an install");
11717        assert!(operations.status().expect("a status").is_healthy());
11718
11719        host.controls.edit("runner-manager", |registration| {
11720            registration.restart_delay = Some(Duration::from_secs(1));
11721        });
11722
11723        let status = operations.status().expect("a status");
11724        assert!(!status.is_healthy(), "{status}");
11725        assert!(
11726            status
11727                .problems()
11728                .iter()
11729                .any(|problem| problem.subject == "restart policy"),
11730            "{status}"
11731        );
11732    }
11733
11734    #[test]
11735    fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
11736        let host = Host::new();
11737        let operations = host.operations();
11738        operations
11739            .install(&host.request(StartMode::Boot))
11740            .expect("an install");
11741        let other = host.binary.with_file_name("someone-elses.exe");
11742        std::fs::write(&other, b"x").expect("writable");
11743
11744        host.controls.edit("runner-manager", |registration| {
11745            registration.command_line = quote_argument(&other.to_string_lossy());
11746        });
11747
11748        let status = operations.status().expect("a status");
11749        assert!(!status.is_healthy(), "{status}");
11750        assert!(
11751            matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
11752            "{status}"
11753        );
11754    }
11755
11756    #[test]
11757    fn status_reports_a_record_no_service_manager_knows_about() {
11758        let host = Host::new();
11759        let operations = host.operations();
11760        operations
11761            .install(&host.request(StartMode::Boot))
11762            .expect("an install");
11763        // Something removed the registration behind this product's back.
11764        for mode in [StartMode::Boot, StartMode::Login] {
11765            host.controls
11766                .control(mode)
11767                .expect("a control")
11768                .uninstall(&ServiceIdentity::product())
11769                .expect("removed");
11770        }
11771
11772        let status = operations.status().expect("a status");
11773        assert!(!status.is_healthy(), "{status}");
11774        assert!(
11775            status
11776                .problems()
11777                .iter()
11778                .any(|problem| problem.subject == "registration"),
11779            "{status}"
11780        );
11781    }
11782
11783    #[test]
11784    fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
11785        let host = Host::new();
11786        let status = host.operations().status().expect("a status");
11787        assert!(!status.is_installed());
11788        assert!(
11789            status.is_healthy(),
11790            "a host that never installed the service has no fault to report: {status}"
11791        );
11792        assert!(status.to_string().contains("installed"), "{status}");
11793    }
11794
11795    #[test]
11796    fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
11797        let host = Host::new();
11798        let operations = host.operations();
11799        operations
11800            .install(&host.request(StartMode::Login))
11801            .expect("an install at login");
11802        let status = operations.status().expect("a status");
11803        assert!(
11804            status
11805                .notes()
11806                .iter()
11807                .any(|note| note.contains("does not run until the operator signs in")),
11808            "05-infrastructure.md requires `service status` to say so: {status}"
11809        );
11810    }
11811
11812    /// A Mac that signs its runner account in automatically does resume, and
11813    /// `service status` used to tell it otherwise.
11814    #[test]
11815    fn a_mac_with_automatic_login_as_the_service_account_resumes_after_a_reboot() {
11816        use crate::unattended_login::{AutoLogin, UnattendedLogin};
11817        let automatic = UnattendedLogin {
11818            auto_login: AutoLogin::As("ivan".into()),
11819            filevault_on: Some(false),
11820        };
11821        assert_eq!(
11822            unattended_gap(StartMode::Login, "ivan", Some(&automatic)),
11823            None
11824        );
11825
11826        let off = UnattendedLogin {
11827            auto_login: AutoLogin::Off,
11828            filevault_on: Some(false),
11829        };
11830        let gap = unattended_gap(StartMode::Login, "ivan", Some(&off)).expect("a gap");
11831        assert!(gap.reason.contains("automatic login is off"), "{gap:?}");
11832        assert!(gap.reason.contains("Automatically log in as"), "{gap:?}");
11833
11834        let locked = UnattendedLogin {
11835            auto_login: AutoLogin::As("ivan".into()),
11836            filevault_on: Some(true),
11837        };
11838        let gap = unattended_gap(StartMode::Boot, "root", Some(&locked)).expect("a gap");
11839        assert!(gap.reason.contains("FileVault is on"), "{gap:?}");
11840
11841        // An unanswered probe is no gap: `host doctor` says what it could not
11842        // read.
11843        let unread = UnattendedLogin {
11844            auto_login: AutoLogin::As("ivan".into()),
11845            filevault_on: None,
11846        };
11847        assert_eq!(
11848            unattended_gap(StartMode::Login, "ivan", Some(&unread)),
11849            None
11850        );
11851
11852        // Where nothing can be probed, a login registration still says so.
11853        let gap = unattended_gap(StartMode::Login, "ivan", None).expect("a gap");
11854        assert!(
11855            gap.reason
11856                .contains("does not run until the operator signs in")
11857        );
11858        assert_eq!(unattended_gap(StartMode::Boot, "root", None), None);
11859    }
11860
11861    /// A daemon blocked in a file system call is still "running" to the
11862    /// service manager; its heartbeat is what says it is stuck.
11863    #[test]
11864    fn a_daemon_that_stopped_beating_makes_the_service_unhealthy() {
11865        let host = Host::new();
11866        let operations = host.operations();
11867        operations
11868            .install(&host.request(StartMode::Login))
11869            .expect("an install at login");
11870        let stalled_problem = |operations: &ServiceOperations| {
11871            operations
11872                .status()
11873                .expect("a status")
11874                .problems()
11875                .iter()
11876                .find(|problem| problem.subject == DAEMON_STALLED_SUBJECT)
11877                .map(|problem| problem.detail.clone())
11878        };
11879        crate::daemon_heartbeat::beat(&operations.paths, Utc::now()).unwrap();
11880        assert_eq!(stalled_problem(&operations), None, "beating");
11881
11882        crate::daemon_heartbeat::beat(
11883            &operations.paths,
11884            Utc::now() - chrono::Duration::minutes(20),
11885        )
11886        .unwrap();
11887        let detail = stalled_problem(&operations).expect("a stall is a problem");
11888        assert!(detail.contains("has made no progress since"), "{detail}");
11889        // The restart is the service manager's command for this login-mode
11890        // registration: the product has no `service stop` or `service start`.
11891        let restart = ServiceIdentity::product().restart_command(StartMode::Login);
11892        assert!(detail.contains(&restart), "{detail}");
11893        assert!(!detail.contains("runner-manager service stop"), "{detail}");
11894        assert!(!detail.contains("runner-manager service start"), "{detail}");
11895    }
11896
11897    /// Each platform's restart is its service manager's own command for the
11898    /// registration's start mode, never a `runner-manager service` subcommand
11899    /// that does not exist.
11900    #[test]
11901    fn the_restart_command_is_the_service_managers_own() {
11902        let identity = ServiceIdentity::product();
11903        let boot = identity.restart_command(StartMode::Boot);
11904        let login = identity.restart_command(StartMode::Login);
11905        for command in [&boot, &login] {
11906            assert!(!command.contains("runner-manager service"), "{command}");
11907            assert!(command.contains(identity.name()), "{command}");
11908        }
11909        assert_ne!(boot, login);
11910        if cfg!(windows) {
11911            assert!(
11912                boot.starts_with("Restart-Service -Name runner-manager"),
11913                "{boot}"
11914            );
11915            assert!(
11916                login.contains("schtasks /Run /TN runner-manager"),
11917                "{login}"
11918            );
11919        } else if cfg!(target_os = "macos") {
11920            assert!(
11921                boot.starts_with("sudo launchctl kickstart -k system/"),
11922                "{boot}"
11923            );
11924            assert!(login.starts_with("launchctl kickstart -k gui/"), "{login}");
11925        } else {
11926            assert_eq!(boot, "sudo systemctl restart runner-manager.service");
11927            assert_eq!(login, "systemctl --user restart runner-manager.service");
11928        }
11929    }
11930
11931    /// A daemon refusing every runner is not a healthy service.
11932    #[test]
11933    fn a_daemon_that_finds_the_host_unfit_makes_the_service_unhealthy() {
11934        let host = Host::new();
11935        let operations = host.operations();
11936        operations
11937            .install(&host.request(StartMode::Login))
11938            .expect("an install at login");
11939        let paths = operations.paths.clone();
11940        crate::host_fitness::record_host_unfit(
11941            &paths,
11942            &[crate::host_fitness::UnfitFinding {
11943                id: "host.runner_root_responsive".into(),
11944                detail: "macOS has not let the service list /Volumes/NVME/runners".into(),
11945                remedy: Some("click Allow".into()),
11946            }],
11947            Utc::now(),
11948        )
11949        .unwrap();
11950        let status = operations.status().expect("a status");
11951        assert!(!status.is_healthy(), "{status}");
11952        let problem = status
11953            .problems()
11954            .iter()
11955            .find(|problem| problem.subject == HOST_UNFIT_SUBJECT)
11956            .expect("the refusal is a problem");
11957        assert!(
11958            problem.detail.contains("/Volumes/NVME/runners"),
11959            "{problem:?}"
11960        );
11961        assert!(problem.detail.contains("Fix: click Allow"), "{problem:?}");
11962
11963        // A record nobody has re-stamped for a while is a stopped daemon's,
11964        // not a refusal in force.
11965        crate::host_fitness::clear_host_unfit(&paths).unwrap();
11966        crate::host_fitness::record_host_unfit(
11967            &paths,
11968            &[],
11969            Utc::now() - chrono::Duration::hours(2),
11970        )
11971        .unwrap();
11972        let status = operations.status().expect("a status");
11973        assert!(
11974            !status
11975                .problems()
11976                .iter()
11977                .any(|problem| problem.subject == HOST_UNFIT_SUBJECT),
11978            "{status}"
11979        );
11980    }
11981
11982    // -----------------------------------------------------------------------
11983    // Start and stop
11984    // -----------------------------------------------------------------------
11985
11986    #[test]
11987    fn start_and_stop_reach_the_domain_that_holds_the_registration() {
11988        let host = Host::new();
11989        let operations = host.operations();
11990        operations
11991            .install(&host.request(StartMode::Login))
11992            .expect("an install at login");
11993        operations.start().expect("a start");
11994        assert!(operations.status().expect("a status").is_running());
11995        assert!(operations.stop().expect("a stop"));
11996        assert!(!operations.status().expect("a status").is_running());
11997    }
11998
11999    #[test]
12000    fn starting_a_host_with_no_registration_is_refused() {
12001        let host = Host::new();
12002        let error = host.operations().start().expect_err("nothing to start");
12003        assert!(
12004            matches!(error, ServiceError::NotInstalled { .. }),
12005            "{error}"
12006        );
12007    }
12008
12009    // -----------------------------------------------------------------------
12010    // The seam with `d2`
12011    // -----------------------------------------------------------------------
12012
12013    /// Requirement 2 is *"an account that can **read the secret store**"*, and
12014    /// on Windows what an account may read is decided by a DACL in a file this
12015    /// task does not own. Asserting the account name alone would be asserting
12016    /// this module against itself; this reads `d2`'s protection back off a real
12017    /// store and checks that it admits the account this installer registers.
12018    ///
12019    /// No privileges are needed. `d2` documents that a rooted machine-scoped
12020    /// store *"is protected and encrypted exactly as the standard one is"* —
12021    /// the Windows backend picks its DACL from the scope, not from the site —
12022    /// so a store in a temporary directory carries the access control the real
12023    /// one does.
12024    #[cfg(windows)]
12025    #[test]
12026    fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
12027        use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
12028
12029        let root = tempfile::tempdir().expect("a temporary directory");
12030        let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
12031            .expect("a rooted machine-scoped store");
12032        store
12033            .store(&secrecy::SecretString::from("a stand-in for the token"))
12034            .expect("the store accepts a value");
12035        let protection = store.protection().expect("the DACL can be read back");
12036
12037        assert!(
12038            protection.description().contains(";;;SY)"),
12039            "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
12040             read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
12041            protection.description()
12042        );
12043        assert_eq!(
12044            ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
12045            ServiceAccount::LocalSystem,
12046            "and that is the account this installer registers, which is why SY is what matters"
12047        );
12048        assert!(
12049            !protection.readable_by_other_local_users(),
12050            "the same DACL must still exclude ordinary local users: {}",
12051            protection.description()
12052        );
12053
12054        // The other half of the analysis in `docs/service-account.md`, and the
12055        // reason LocalService and NetworkService are not used: the DACL names
12056        // three trustees and neither of them is among them.
12057        for rejected in [";;;LS)", ";;;NS)"] {
12058            assert!(
12059                !protection.description().contains(rejected),
12060                "if the store ever admitted {rejected}, the least-privilege analysis in \
12061                 docs/service-account.md would need redoing: {}",
12062                protection.description()
12063            );
12064        }
12065    }
12066
12067    // -- another installation's service ---------------------------------------
12068
12069    /// A request that records `host`'s own four directories on the command
12070    /// line, the way `service install` registers one.
12071    fn request_with_directories(host: &Host, mode: StartMode) -> InstallRequest {
12072        let mut arguments: Vec<OsString> = DAEMON_ARGUMENTS.iter().map(OsString::from).collect();
12073        arguments.extend(ServiceDirectories::of(&host.paths).arguments());
12074        host.request(mode).with_arguments(arguments)
12075    }
12076
12077    /// The operations a second account on the same machine gets: its own
12078    /// directories, and the machine's one service manager.
12079    fn second_account(account: &Host, machine: &Host) -> ServiceOperations {
12080        ServiceOperations::with_controls(
12081            account.paths.clone(),
12082            ServiceIdentity::product(),
12083            std::sync::Arc::new(machine.controls.clone()),
12084        )
12085        .with_runner_root(account.runner_root.clone())
12086    }
12087
12088    /// The owner's boot service, and a second account holding the record of
12089    /// an earlier login install of its own -- the layout an owner reported on
12090    /// a Windows host with two local accounts.
12091    fn a_machine_with_a_stale_second_account() -> (Host, Host) {
12092        let owner = Host::new();
12093        owner
12094            .operations()
12095            .install(&request_with_directories(&owner, StartMode::Boot))
12096            .expect("the owner's boot install");
12097        let earlier = Host::new();
12098        earlier
12099            .operations()
12100            .install(&request_with_directories(&earlier, StartMode::Login))
12101            .expect("the second account's earlier login install");
12102        assert!(
12103            InstallRecord::read(&earlier.paths).unwrap().is_some(),
12104            "the earlier install left its record behind"
12105        );
12106        (owner, earlier)
12107    }
12108
12109    /// The advice that, followed from the second account, breaks or replaces
12110    /// the owner's healthy service.
12111    const DESTRUCTIVE_ADVICE: [&str; 4] = [
12112        "service uninstall",
12113        "service install`",
12114        "service install --",
12115        "auth login",
12116    ];
12117
12118    #[test]
12119    fn a_service_installed_from_another_data_root_is_reported_as_that_installations() {
12120        let (owner, earlier) = a_machine_with_a_stale_second_account();
12121
12122        let status = second_account(&earlier, &owner).status().expect("a status");
12123
12124        let foreign = status.foreign().expect("the service is the owner's");
12125        assert_eq!(foreign.directories, ServiceDirectories::of(&owner.paths));
12126        assert_eq!(foreign.root, ServiceDirectories::of(&owner.paths).root());
12127        assert!(foreign.unreadable.is_none(), "{status}");
12128        assert!(
12129            status.is_healthy(),
12130            "the owner's service is healthy, and a stale record elsewhere is not its fault: \
12131             {status}"
12132        );
12133        for subject in [
12134            "binary",
12135            "start mode",
12136            "secret store",
12137            "record",
12138            "registration",
12139        ] {
12140            assert!(
12141                !status
12142                    .problems()
12143                    .iter()
12144                    .any(|problem| problem.subject == subject),
12145                "{subject} compared the second account's record against the owner's \
12146                 registration: {status}"
12147            );
12148        }
12149        let rendered = status.to_string();
12150        for advice in DESTRUCTIVE_ADVICE {
12151            assert!(!rendered.contains(advice), "{advice}: {rendered}");
12152        }
12153        assert!(rendered.contains("another installation's"), "{rendered}");
12154        assert!(
12155            status
12156                .notes()
12157                .iter()
12158                .any(|note| note.contains("unused local configuration")
12159                    && note.contains("host forget-local")),
12160            "{status}"
12161        );
12162        // What the owner recorded, not what the second account did.
12163        assert_eq!(status.start_mode(), Some(StartMode::Boot));
12164    }
12165
12166    #[test]
12167    fn a_foreign_service_whose_records_cannot_be_read_reports_only_what_the_manager_says() {
12168        let (owner, earlier) = a_machine_with_a_stale_second_account();
12169        // Stands in for a profile this account may not open: there is nothing
12170        // to read where the registration points.
12171        InstallRecord::remove(&owner.paths).expect("the owner's record removed");
12172
12173        let operations = second_account(&earlier, &owner);
12174        let status = operations.status().expect("a status");
12175        let foreign = status.foreign().expect("the service is the owner's");
12176        assert!(foreign.unreadable.is_some(), "{status}");
12177        assert!(
12178            status.is_healthy(),
12179            "running, and nothing it could judge: {status}"
12180        );
12181        let rendered = status.to_string();
12182        for advice in DESTRUCTIVE_ADVICE {
12183            assert!(!rendered.contains(advice), "{advice}: {rendered}");
12184        }
12185        assert!(rendered.contains("running; installed from"), "{rendered}");
12186        assert!(!rendered.contains("last GitHub contact"), "{rendered}");
12187
12188        // Stopped, it is a problem -- with a place to look, not a reinstall.
12189        owner.operations().stop().expect("stopped");
12190        let stopped = operations.status().expect("a status");
12191        assert!(!stopped.is_healthy(), "{stopped}");
12192        let rendered = stopped.to_string();
12193        for advice in DESTRUCTIVE_ADVICE {
12194            assert!(!rendered.contains(advice), "{advice}: {rendered}");
12195        }
12196    }
12197
12198    #[test]
12199    fn this_accounts_own_registration_still_reports_every_mismatch() {
12200        let host = Host::new();
12201        let operations = host.operations();
12202        operations
12203            .install(&request_with_directories(&host, StartMode::Boot))
12204            .expect("an install");
12205        let other = host.binary.with_file_name("someone-elses");
12206        std::fs::write(&other, b"x").expect("writable");
12207        // Something edited the registration, keeping its directories.
12208        host.controls.edit("runner-manager", |registration| {
12209            let mut arguments = split_command_line(&registration.command_line);
12210            arguments[0] = other.to_string_lossy().into_owned();
12211            registration.command_line = arguments
12212                .iter()
12213                .map(|argument| quote_argument(argument))
12214                .collect::<Vec<_>>()
12215                .join(" ");
12216            registration.start_mode = StartMode::Login;
12217        });
12218
12219        let status = operations.status().expect("a status");
12220        assert!(status.foreign().is_none(), "{status}");
12221        assert!(
12222            matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
12223            "{status}"
12224        );
12225        assert!(
12226            status
12227                .problems()
12228                .iter()
12229                .any(|problem| problem.subject == "start mode"),
12230            "{status}"
12231        );
12232        assert!(status.to_string().contains("service uninstall"), "{status}");
12233    }
12234
12235    #[test]
12236    fn directories_read_back_from_a_command_line_with_spaces() {
12237        let root = Path::new("/Users/someone/Library/Application Support/runner manager");
12238        let directories = ServiceDirectories {
12239            config: root.join("config"),
12240            state: root.join("data").join("state"),
12241            runtime: root.join("data").join("runtime"),
12242            logs: root.join("data").join("logs"),
12243        };
12244        let mut command_line = quote_argument("/usr/local/bin/runner-manager");
12245        for argument in ["daemon", "run"]
12246            .map(OsString::from)
12247            .into_iter()
12248            .chain(directories.arguments())
12249        {
12250            command_line.push(' ');
12251            command_line.push_str(&quote_argument(&argument.to_string_lossy()));
12252        }
12253        assert_eq!(
12254            ServiceDirectories::from_command_line(&command_line),
12255            Some(directories.clone())
12256        );
12257        assert_eq!(directories.root(), root);
12258        assert_eq!(
12259            ServiceDirectories::from_command_line("runner-manager daemon run"),
12260            None,
12261            "a registration that names no directories cannot be judged foreign"
12262        );
12263    }
12264
12265    #[test]
12266    fn the_owner_of_a_data_root_is_read_from_its_path() {
12267        let users = Path::new(if cfg!(windows) { r"C:\Users" } else { "/home" });
12268        let home = users.join("ivand");
12269        assert_eq!(
12270            data_root_owner(&users.join("yuriv").join("data"), Some(&home)).as_deref(),
12271            Some("yuriv")
12272        );
12273        assert_eq!(
12274            data_root_owner(
12275                Path::new(if cfg!(windows) { r"D:\rm" } else { "/opt/rm" }),
12276                Some(&home)
12277            ),
12278            None
12279        );
12280        if cfg!(windows) {
12281            assert_eq!(
12282                data_root_owner(
12283                    Path::new(r"C:\Windows\System32\config\systemprofile\AppData"),
12284                    Some(&home)
12285                )
12286                .as_deref(),
12287                Some("LocalSystem")
12288            );
12289        } else {
12290            assert_eq!(
12291                data_root_owner(Path::new("/root/.config/runner-manager"), Some(&home)).as_deref(),
12292                Some("root")
12293            );
12294        }
12295    }
12296
12297    #[test]
12298    fn forget_local_archives_the_unused_record_and_never_touches_the_service_root() {
12299        let (owner, earlier) = a_machine_with_a_stale_second_account();
12300        let owner_before = snapshot(&[owner._root.path()]);
12301        let config = earlier.paths.config_dir().to_path_buf();
12302        let record = std::fs::read(InstallRecord::path(&earlier.paths)).unwrap();
12303
12304        let forgotten = second_account(&earlier, &owner)
12305            .forget_local(Utc::now())
12306            .expect("archived");
12307
12308        assert_eq!(
12309            snapshot(&[owner._root.path()]),
12310            owner_before,
12311            "the owner's directories must not change by one byte"
12312        );
12313        let archive = forgotten.archive.expect("something was archived");
12314        assert!(archive.starts_with(&config), "{}", archive.display());
12315        assert_eq!(
12316            std::fs::read(archive.join(RECORD_FILE)).unwrap(),
12317            record,
12318            "moved, not rewritten or deleted"
12319        );
12320        assert!(InstallRecord::read(&earlier.paths).unwrap().is_none());
12321        assert_eq!(
12322            forgotten.service_root,
12323            Some(ServiceDirectories::of(&owner.paths).root())
12324        );
12325        // Afterwards the second account's status has no unused record to note.
12326        let status = second_account(&earlier, &owner).status().expect("status");
12327        assert!(
12328            !status
12329                .notes()
12330                .iter()
12331                .any(|note| note.contains("unused local configuration")),
12332            "{status}"
12333        );
12334    }
12335
12336    #[test]
12337    fn forget_local_refuses_a_configuration_a_service_runs_against() {
12338        let (owner, earlier) = a_machine_with_a_stale_second_account();
12339        let before = snapshot(&[owner._root.path()]);
12340        let refused = owner.operations().forget_local(Utc::now());
12341        assert!(
12342            matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
12343            "{refused:?}"
12344        );
12345        assert_eq!(snapshot(&[owner._root.path()]), before);
12346
12347        // Nor while an agent holds the second account's own lock.
12348        let _agent = HostLock::try_acquire(&earlier.paths, LockKind::SingleInstance)
12349            .expect("the agent's lock");
12350        let refused = second_account(&earlier, &owner).forget_local(Utc::now());
12351        assert!(
12352            matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
12353            "{refused:?}"
12354        );
12355        assert!(InstallRecord::read(&earlier.paths).unwrap().is_some());
12356    }
12357
12358    #[test]
12359    fn a_readable_foreign_service_keeps_its_diagnoses_and_loses_its_remedies() {
12360        let (owner, earlier) = a_machine_with_a_stale_second_account();
12361        // The owner's service has every problem whose remedy acts on the
12362        // directories of whoever runs it.
12363        owner.operations().stop().expect("stopped");
12364        record_github_credential_rejected(&owner.paths, Utc::now()).expect("rejected");
12365        record_credential_unreadable(&owner.paths, Utc::now()).expect("unreadable");
12366
12367        let status = second_account(&earlier, &owner).status().expect("a status");
12368        assert!(status.foreign().is_some_and(|foreign| !foreign.is_unread()));
12369        let subjects: Vec<_> = status.problems().iter().map(|p| p.subject).collect();
12370        for subject in [
12371            "runtime",
12372            "github credential",
12373            CREDENTIAL_UNREADABLE_SUBJECT,
12374        ] {
12375            assert!(subjects.contains(&subject), "{subject}: {status}");
12376        }
12377        let rendered = status.to_string();
12378        for advice in DESTRUCTIVE_ADVICE {
12379            assert!(!rendered.contains(advice), "{advice}: {rendered}");
12380        }
12381        for problem in status.problems() {
12382            assert!(problem.detail.contains("Act on it"), "{problem}");
12383        }
12384    }
12385
12386    #[test]
12387    fn no_command_of_this_account_changes_another_installations_service() {
12388        let (owner, earlier) = a_machine_with_a_stale_second_account();
12389        let operations = second_account(&earlier, &owner);
12390        let foreign = |result: Result<(), ServiceError>| {
12391            assert!(
12392                matches!(result, Err(ServiceError::ForeignInstallation { .. })),
12393                "{result:?}"
12394            );
12395        };
12396        foreign(
12397            operations
12398                .install(&request_with_directories(&earlier, StartMode::Boot))
12399                .map(drop),
12400        );
12401        foreign(operations.uninstall().map(drop));
12402        foreign(operations.set_start_mode(StartMode::Boot).map(drop));
12403        foreign(operations.start());
12404        foreign(operations.stop().map(drop));
12405
12406        let registration = owner
12407            .controls
12408            .control(StartMode::Boot)
12409            .unwrap()
12410            .query(&ServiceIdentity::product())
12411            .unwrap()
12412            .expect("the owner's registration is still there");
12413        assert_eq!(
12414            ServiceDirectories::from_command_line(&registration.command_line),
12415            Some(ServiceDirectories::of(&owner.paths)),
12416            "and still runs against the owner's directories"
12417        );
12418        assert!(registration.running, "and was never stopped");
12419        assert!(
12420            InstallRecord::read(&earlier.paths).unwrap().is_some(),
12421            "the refused uninstall kept this account's record"
12422        );
12423    }
12424
12425    #[test]
12426    fn forget_local_leaves_agent_directories_nested_in_the_configuration_alone() {
12427        // macOS puts state, runtime and logs inside the configuration
12428        // directory: `config_local_dir` and `data_local_dir` are one folder.
12429        let root = tempfile::tempdir().unwrap();
12430        let config = root.path().join("Application Support");
12431        let paths = AppPaths::from_directories(
12432            &config,
12433            config.join("state"),
12434            config.join("runtime"),
12435            config.join("logs"),
12436        );
12437        paths.create_all().unwrap();
12438        std::fs::write(config.join(RECORD_FILE), b"an earlier install").unwrap();
12439        std::fs::write(paths.logs_dir().join("runner-manager.log"), b"log").unwrap();
12440        let operations = ServiceOperations::with_controls(
12441            paths.clone(),
12442            ServiceIdentity::product(),
12443            std::sync::Arc::new(RecordingControls::new()),
12444        );
12445
12446        let forgotten = operations.forget_local(Utc::now()).expect("archived");
12447
12448        assert_eq!(forgotten.moved, vec![config.join(RECORD_FILE)]);
12449        for kept in [paths.state_dir(), paths.runtime_dir(), paths.logs_dir()] {
12450            assert!(kept.is_dir(), "{} was moved", kept.display());
12451        }
12452        assert!(paths.logs_dir().join("runner-manager.log").is_file());
12453    }
12454
12455    #[test]
12456    fn a_remedy_is_cut_from_every_detail_a_builder_writes() {
12457        for (detail, diagnosis) in [
12458            (
12459                "x holds an automatic registration, but the daemon is stopped. Run `runner-manager \
12460                 service install --start-at boot`; if the service manager denies replacement",
12461                "x holds an automatic registration, but the daemon is stopped",
12462            ),
12463            (
12464                "store -- MISMATCH. Run `auth login` again so the token is stored",
12465                "store -- MISMATCH",
12466            ),
12467            (
12468                "unit differs, so the daemon runs under outdated settings. Re-register it with \
12469                 `sudo runner-manager service install --start-at boot`",
12470                "unit differs, so the daemon runs under outdated settings",
12471            ),
12472            (
12473                "it starts no runner; the log has it. Sign in again: `runner-manager auth login`",
12474                "it starts no runner; the log has it",
12475            ),
12476            (
12477                "C:\\a\\runner-manager.exe -- STALE: moved. Run `service uninstall` and `service \
12478                 install`",
12479                "C:\\a\\runner-manager.exe -- STALE: moved",
12480            ),
12481            (
12482                "a new builder: run `runner-manager service install` here, or not",
12483                "the installation's own records report this",
12484            ),
12485        ] {
12486            assert_eq!(diagnosis_only(detail), diagnosis, "{detail}");
12487        }
12488    }
12489}