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    protected: Vec<PathBuf>,
984}
985
986impl InstallRequest {
987    /// Register the binary that is running this call.
988    #[must_use]
989    pub fn new(start_mode: StartMode) -> Self {
990        Self {
991            start_mode,
992            binary: None,
993            source_binary: None,
994            arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
995            restart: RestartPolicy::default(),
996            on_demand: false,
997            protected: Vec::new(),
998        }
999    }
1000
1001    /// Host paths the service, its runners and their jobs may not reach at
1002    /// all. See [`ProtectedPaths`].
1003    #[must_use]
1004    pub fn protecting(mut self, paths: Vec<PathBuf>) -> Self {
1005        self.protected = paths;
1006        self
1007    }
1008
1009    /// Registers the service but does not ask the manager to start it by
1010    /// itself.
1011    ///
1012    /// Production never uses this: a boot-mode registration that does not start
1013    /// at boot is the failure `service status` reports. **The privileged
1014    /// installer tests use it for every fixture they create**, so that a
1015    /// registration which somehow escaped its cleanup guard cannot start with
1016    /// the owner's machine on the next reboot. A leaked service is bad; a
1017    /// leaked service that runs is worse, and the difference costs one flag.
1018    #[must_use]
1019    pub const fn started_on_demand(mut self) -> Self {
1020        self.on_demand = true;
1021        self
1022    }
1023
1024    /// Register a named binary instead of the running one.
1025    ///
1026    /// The privileged installer tests use this to register a fixture service
1027    /// host. Production does not: `05-infrastructure.md` item 6 is specifically
1028    /// about the path of *the running binary*, and letting an operator name a
1029    /// different one would defeat the stale-path detection it asks for.
1030    #[must_use]
1031    pub fn for_binary(mut self, binary: impl Into<PathBuf>) -> Self {
1032        self.binary = Some(binary.into());
1033        self
1034    }
1035
1036    /// Where the registered binary was copied from.
1037    ///
1038    /// # Why a registration has a source at all
1039    ///
1040    /// A service registered directly against a package manager's own file can
1041    /// never be upgraded on Windows, because the running service holds that
1042    /// file open and `npm i -g` cannot replace it. What it does instead is
1043    /// worse than failing: it rewrites the package metadata, reports success,
1044    /// and leaves the old executable in place — so the operator is told the new
1045    /// version is installed while the old one keeps running. That was observed,
1046    /// twice in a row, before this existed.
1047    ///
1048    /// So the service runs a copy the product owns, and this records where that
1049    /// copy came from. The source is what a package manager updates, what
1050    /// [`inspect_binary`] watches for item 6's stale-path case, and what the
1051    /// daemon compares its own version against to know an upgrade is waiting.
1052    #[must_use]
1053    pub fn copied_from(mut self, source: impl Into<PathBuf>) -> Self {
1054        self.source_binary = Some(source.into());
1055        self
1056    }
1057
1058    /// Replace the registered arguments. Defaults to [`DAEMON_ARGUMENTS`].
1059    #[must_use]
1060    pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1061    where
1062        I: IntoIterator<Item = S>,
1063        S: Into<OsString>,
1064    {
1065        self.arguments = arguments.into_iter().map(Into::into).collect();
1066        self
1067    }
1068
1069    /// Replace the restart-on-failure policy. Defaults to
1070    /// [`RestartPolicy::default`].
1071    #[must_use]
1072    pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1073        self.restart = restart;
1074        self
1075    }
1076
1077    /// The start mode asked for.
1078    #[must_use]
1079    pub const fn start_mode(&self) -> StartMode {
1080        self.start_mode
1081    }
1082}
1083
1084/// Everything a backend needs, resolved and validated.
1085///
1086/// Constructing one is where `05-infrastructure.md` item 6 is actually
1087/// satisfied: the binary path is made absolute and is confirmed to be a file
1088/// *before* anything is registered, so a registration that would fail at boot
1089/// is refused at install time instead.
1090#[derive(Debug, Clone, PartialEq, Eq)]
1091pub struct InstallPlan {
1092    identity: ServiceIdentity,
1093    start_mode: StartMode,
1094    binary: PathBuf,
1095    source_binary: Option<PathBuf>,
1096    arguments: Vec<OsString>,
1097    account: ServiceAccount,
1098    restart: RestartPolicy,
1099    directories: ServiceDirectories,
1100    secret_guard: Option<PathBuf>,
1101    on_demand: bool,
1102    protected: Vec<PathBuf>,
1103}
1104
1105impl InstallPlan {
1106    /// Resolves a request against this host.
1107    ///
1108    /// # Errors
1109    ///
1110    /// [`ServiceError::BinaryPath`] when the running executable cannot be
1111    /// located, and [`ServiceError::BinaryMissing`] when the path that would be
1112    /// registered is not a file.
1113    pub fn resolve(
1114        identity: ServiceIdentity,
1115        request: &InstallRequest,
1116        directories: ServiceDirectories,
1117    ) -> Result<Self, ServiceError> {
1118        let binary = match &request.binary {
1119            Some(named) => absolute(named)?,
1120            None => running_executable()?,
1121        };
1122        if !binary.is_file() {
1123            return Err(ServiceError::BinaryMissing { path: binary });
1124        }
1125        // `d2` publishes the guard file the store lives in so the systemd
1126        // sandbox can admit only its parent for atomic token rotation. A store
1127        // that cannot be resolved is not fatal here; it merely cannot receive
1128        // that narrow writable-path exception.
1129        let secret_guard = crate::secrets::PlatformSecretStore::for_start_mode(request.start_mode)
1130            .ok()
1131            .map(|store| store.guard());
1132        Ok(Self {
1133            identity,
1134            start_mode: request.start_mode,
1135            binary,
1136            source_binary: request.source_binary.clone(),
1137            arguments: request.arguments.clone(),
1138            account: ServiceAccount::for_start_mode(request.start_mode),
1139            restart: request.restart,
1140            directories,
1141            secret_guard,
1142            on_demand: request.on_demand,
1143            protected: request.protected.clone(),
1144        })
1145    }
1146
1147    /// Builds a plan from values a caller already holds, without touching the
1148    /// filesystem.
1149    ///
1150    /// This is what makes the renderers testable on a host that has none of the
1151    /// paths involved: a Windows developer can render the systemd unit for
1152    /// `/opt/runner-manager/bin/runner-manager` and assert every line of it.
1153    #[must_use]
1154    pub fn unchecked(
1155        identity: ServiceIdentity,
1156        start_mode: StartMode,
1157        binary: impl Into<PathBuf>,
1158        directories: ServiceDirectories,
1159    ) -> Self {
1160        Self {
1161            identity,
1162            start_mode,
1163            binary: binary.into(),
1164            source_binary: None,
1165            arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
1166            account: ServiceAccount::for_start_mode(start_mode),
1167            restart: RestartPolicy::default(),
1168            directories,
1169            secret_guard: None,
1170            on_demand: false,
1171            protected: Vec::new(),
1172        }
1173    }
1174
1175    /// The same plan with `paths` made inaccessible. See [`ProtectedPaths`].
1176    #[must_use]
1177    pub fn with_protected_paths(mut self, paths: Vec<PathBuf>) -> Self {
1178        self.protected = paths;
1179        self
1180    }
1181
1182    /// The host paths the service and its jobs may not reach.
1183    #[must_use]
1184    pub fn protected_paths(&self) -> &[PathBuf] {
1185        &self.protected
1186    }
1187
1188    /// Registers without arming the manager's automatic start. See
1189    /// [`InstallRequest::started_on_demand`].
1190    #[must_use]
1191    pub const fn started_on_demand(mut self) -> Self {
1192        self.on_demand = true;
1193        self
1194    }
1195
1196    /// Whether the manager was asked not to start this by itself.
1197    #[must_use]
1198    pub const fn is_on_demand(&self) -> bool {
1199        self.on_demand
1200    }
1201
1202    /// Names the file the secret store lives in, so systemd can permit atomic
1203    /// replacement of it under `ProtectSystem=strict`.
1204    ///
1205    /// [`InstallPlan::resolve`] fills this from `d2`; a test names it directly
1206    /// so that a Windows or macOS CI leg can assert the Linux unit's writable
1207    /// path for a store that platform does not have.
1208    #[must_use]
1209    pub fn with_secret_guard(mut self, guard: impl Into<PathBuf>) -> Self {
1210        self.secret_guard = Some(guard.into());
1211        self
1212    }
1213
1214    /// The file the machine-scoped secret store lives in, when one resolved.
1215    #[must_use]
1216    pub fn secret_guard(&self) -> Option<&Path> {
1217        self.secret_guard.as_deref()
1218    }
1219
1220    /// Overrides the restart policy on an already-built plan.
1221    #[must_use]
1222    pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1223        self.restart = restart;
1224        self
1225    }
1226
1227    /// Overrides the registered arguments on an already-built plan.
1228    #[must_use]
1229    pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1230    where
1231        I: IntoIterator<Item = S>,
1232        S: Into<OsString>,
1233    {
1234        self.arguments = arguments.into_iter().map(Into::into).collect();
1235        self
1236    }
1237
1238    /// What the operating system calls this registration.
1239    #[must_use]
1240    pub const fn identity(&self) -> &ServiceIdentity {
1241        &self.identity
1242    }
1243
1244    /// Boot or login.
1245    #[must_use]
1246    pub const fn start_mode(&self) -> StartMode {
1247        self.start_mode
1248    }
1249
1250    /// The resolved absolute path of the binary to register.
1251    #[must_use]
1252    pub fn binary(&self) -> &Path {
1253        &self.binary
1254    }
1255
1256    /// Where [`Self::binary`] was copied from, when it is a copy.
1257    ///
1258    /// `None` is the legacy layout: the registration names the file a package
1259    /// manager owns, and cannot be upgraded while it runs. See
1260    /// [`InstallRequest::copied_from`].
1261    #[must_use]
1262    pub fn source_binary(&self) -> Option<&Path> {
1263        self.source_binary.as_deref()
1264    }
1265
1266    /// The arguments it is registered with.
1267    #[must_use]
1268    pub fn arguments(&self) -> &[OsString] {
1269        &self.arguments
1270    }
1271
1272    /// The account it runs under.
1273    #[must_use]
1274    pub const fn account(&self) -> &ServiceAccount {
1275        &self.account
1276    }
1277
1278    /// The restart-on-failure policy.
1279    #[must_use]
1280    pub const fn restart(&self) -> RestartPolicy {
1281        self.restart
1282    }
1283
1284    /// The four directories it was installed against.
1285    #[must_use]
1286    pub const fn directories(&self) -> &ServiceDirectories {
1287        &self.directories
1288    }
1289
1290    /// The command line as one string, quoted the way each platform expects.
1291    #[must_use]
1292    pub fn command_line(&self) -> String {
1293        let mut out = quote_argument(&self.binary.to_string_lossy());
1294        for argument in &self.arguments {
1295            out.push(' ');
1296            out.push_str(&quote_argument(&argument.to_string_lossy()));
1297        }
1298        out
1299    }
1300}
1301
1302/// Undoes a runner-root change, and says what could not be undone.
1303///
1304/// `None` when the rollback was complete — either because there was nothing to
1305/// undo, or because the directory this operation created was removed again and
1306/// the descriptor it replaced was written back. `Some` is the "report any
1307/// non-reversible existing directory state explicitly" half of the requirement,
1308/// and the caller folds it into the failure it was already reporting.
1309fn retained_runner_root(change: &RootAccessChange) -> Option<String> {
1310    let reversal = change.revert();
1311    matches!(reversal, Reversal::Retained { .. }).then(|| reversal.to_string())
1312}
1313
1314/// The error a failed step returns once both of its rollbacks have been
1315/// attempted.
1316///
1317/// `retained` is [`retained_runner_root`]'s answer and `rollback` is what
1318/// undoing the *registration* reported — whatever it reports on success, which
1319/// a failure has no use for. Both arrive already evaluated, because each may be
1320/// attempted exactly once and the order is the caller's to choose.
1321///
1322/// A complete rollback leaves `cause` exactly as it was: the operator's problem
1323/// is what failed, not the tidying up afterwards. An incomplete one is promoted
1324/// to [`ServiceError::Rollback`], which is the variant that exists to say "this
1325/// failed *and* something is left behind" — and it names everything that did.
1326fn rolled_back<T>(
1327    retained: Option<String>,
1328    rollback: Result<T, ServiceError>,
1329    operation: &'static str,
1330    identity: &ServiceIdentity,
1331    cause: ServiceError,
1332) -> ServiceError {
1333    let left_behind = match (rollback.err(), retained) {
1334        (Some(rollback), Some(retained)) => Some(format!("{rollback}; {retained}")),
1335        (Some(rollback), None) => Some(rollback.to_string()),
1336        (None, retained) => retained,
1337    };
1338    match left_behind {
1339        Some(rollback) => ServiceError::Rollback {
1340            operation,
1341            name: identity.name().to_string(),
1342            cause: cause.to_string(),
1343            rollback,
1344        },
1345        None => cause,
1346    }
1347}
1348
1349/// [`rolled_back`] for a failure with nothing registered yet, where the runner
1350/// root is the only thing there is to undo.
1351fn undo_runner_root(
1352    change: &RootAccessChange,
1353    operation: &'static str,
1354    identity: &ServiceIdentity,
1355    cause: ServiceError,
1356) -> ServiceError {
1357    rolled_back(
1358        retained_runner_root(change),
1359        Ok(()),
1360        operation,
1361        identity,
1362        cause,
1363    )
1364}
1365
1366/// Makes a path absolute without resolving symlinks.
1367///
1368/// Symbolic links are deliberately left alone. A Homebrew installation puts a
1369/// stable link at `/opt/homebrew/bin/runner-manager` and moves the file it
1370/// points at on every upgrade; recording the link's target would make an
1371/// ordinary `brew upgrade` look exactly like the npm failure this module exists
1372/// to detect, and recording the link records the thing the operator installed.
1373///
1374/// Linux is the one platform where this is not fully in the caller's hands:
1375/// `std::env::current_exe` reads `/proc/self/exe`, which the kernel has already
1376/// resolved. There the recorded path is the real file rather than the shim —
1377/// which is still correct for the npm case, because a real file under a Node
1378/// prefix disappears with the prefix.
1379fn absolute(path: &Path) -> Result<PathBuf, ServiceError> {
1380    std::path::absolute(path).map_err(|error| ServiceError::BinaryPath {
1381        detail: format!("{} could not be made absolute: {error}", path.display()),
1382    })
1383}
1384
1385/// The absolute path of the executable running this call.
1386fn running_executable() -> Result<PathBuf, ServiceError> {
1387    let raw = std::env::current_exe().map_err(|error| ServiceError::BinaryPath {
1388        detail: error.to_string(),
1389    })?;
1390    absolute(&raw)
1391}
1392
1393/// Quotes one command-line argument when it needs it.
1394///
1395/// The rule is Windows', because Windows is the platform where a command line is
1396/// a single string the callee re-splits, and because `windows-service` applies
1397/// exactly this rule when it builds `lpBinaryPathName`. The Unix backends embed
1398/// arguments in a plist array and in a systemd `ExecStart=`, both of which
1399/// accept the same quoting, so one rule serves all three rather than three
1400/// nearly-identical ones.
1401pub(crate) fn quote_argument(argument: &str) -> String {
1402    if !argument.is_empty() && !argument.contains([' ', '"', '\t', '\n']) {
1403        return argument.to_string();
1404    }
1405    let mut out = String::with_capacity(argument.len() + 2);
1406    out.push('"');
1407    let mut backslashes = 0usize;
1408    for c in argument.chars() {
1409        match c {
1410            '\\' => {
1411                backslashes += 1;
1412                out.push('\\');
1413            }
1414            '"' => {
1415                // Every backslash immediately before a quote must be doubled,
1416                // and the quote itself escaped.
1417                for _ in 0..=backslashes {
1418                    out.push('\\');
1419                }
1420                out.push('"');
1421                backslashes = 0;
1422            }
1423            other => {
1424                backslashes = 0;
1425                out.push(other);
1426            }
1427        }
1428    }
1429    // Trailing backslashes would otherwise escape the closing quote.
1430    for _ in 0..backslashes {
1431        out.push('\\');
1432    }
1433    out.push('"');
1434    out
1435}
1436
1437/// Reads the executable back out of a command line quoted by
1438/// [`quote_argument`].
1439///
1440/// `service status` needs this because the Windows Service Control Manager
1441/// stores the binary and its arguments as one string and hands the whole thing
1442/// back from `QueryServiceConfigW`. Comparing a record against a registration
1443/// means splitting that string the same way Windows itself does.
1444///
1445/// Returns `None` for an empty or whitespace-only command line, which is the
1446/// only input with no first argument to find.
1447#[must_use]
1448pub fn executable_from_command_line(command_line: &str) -> Option<PathBuf> {
1449    split_command_line(command_line)
1450        .into_iter()
1451        .next()
1452        .filter(|executable| !executable.is_empty())
1453        .map(PathBuf::from)
1454}
1455
1456/// Splits a command line quoted by [`quote_argument`] back into its
1457/// arguments, by the rule Windows itself applies: whitespace separates
1458/// arguments outside quotes, `2n` backslashes before a quote are `n`
1459/// backslashes and a quote that opens or closes, `2n+1` are `n` backslashes
1460/// and a literal quote, and every other backslash is literal.
1461#[must_use]
1462pub fn split_command_line(command_line: &str) -> Vec<String> {
1463    let mut arguments = Vec::new();
1464    let mut current = String::new();
1465    let mut started = false;
1466    let mut quoted = false;
1467    let mut backslashes = 0usize;
1468    for c in command_line.chars() {
1469        match c {
1470            '\\' => {
1471                backslashes += 1;
1472                started = true;
1473            }
1474            '"' => {
1475                current.extend(std::iter::repeat_n('\\', backslashes / 2));
1476                if backslashes.is_multiple_of(2) {
1477                    quoted = !quoted;
1478                } else {
1479                    current.push('"');
1480                }
1481                backslashes = 0;
1482                started = true;
1483            }
1484            ' ' | '\t' if !quoted => {
1485                current.extend(std::iter::repeat_n('\\', backslashes));
1486                backslashes = 0;
1487                if started {
1488                    arguments.push(std::mem::take(&mut current));
1489                    started = false;
1490                }
1491            }
1492            other => {
1493                current.extend(std::iter::repeat_n('\\', backslashes));
1494                backslashes = 0;
1495                current.push(other);
1496                started = true;
1497            }
1498        }
1499    }
1500    current.extend(std::iter::repeat_n('\\', backslashes));
1501    if started {
1502        arguments.push(current);
1503    }
1504    arguments
1505}
1506
1507// ---------------------------------------------------------------------------
1508// Rendered definitions
1509// ---------------------------------------------------------------------------
1510
1511/// Which of the four things a platform reads.
1512#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1513pub enum DefinitionKind {
1514    /// The parameters `CreateServiceW` is called with, rendered as a canonical
1515    /// descriptor so that they can be reviewed and asserted like the other
1516    /// three. The Service Control Manager has no file.
1517    WindowsService,
1518    /// A Task Scheduler XML document, for `--start-at login` on Windows.
1519    WindowsScheduledTask,
1520    /// A launchd property list — a LaunchDaemon at boot, a LaunchAgent at login.
1521    LaunchdPlist,
1522    /// A systemd unit — a system unit at boot, a user unit at login.
1523    SystemdUnit,
1524}
1525
1526impl DefinitionKind {
1527    /// What to call the thing that reads it, in an operator-facing message.
1528    #[must_use]
1529    pub const fn manager(self) -> &'static str {
1530        match self {
1531            Self::WindowsService => "the Windows Service Control Manager",
1532            Self::WindowsScheduledTask => "Windows Task Scheduler",
1533            Self::LaunchdPlist => "launchd",
1534            Self::SystemdUnit => "systemd",
1535        }
1536    }
1537}
1538
1539impl fmt::Display for DefinitionKind {
1540    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1541        f.write_str(self.manager())
1542    }
1543}
1544
1545/// One platform's definition of the registration, as text.
1546///
1547/// Producing this is pure: no `cfg`, no privileges, no filesystem. That is what
1548/// lets every leg of the CI matrix assert every platform's definition, instead
1549/// of each leg asserting only its own and the other two being reviewed by
1550/// reading.
1551#[derive(Debug, Clone, PartialEq, Eq)]
1552pub struct ServiceDefinition {
1553    kind: DefinitionKind,
1554    text: String,
1555    install_path: Option<PathBuf>,
1556}
1557
1558impl ServiceDefinition {
1559    /// Which platform this is for.
1560    #[must_use]
1561    pub const fn kind(&self) -> DefinitionKind {
1562        self.kind
1563    }
1564
1565    /// The definition itself.
1566    #[must_use]
1567    pub fn text(&self) -> &str {
1568        &self.text
1569    }
1570
1571    /// Where the platform expects the file, when the platform reads a file.
1572    ///
1573    /// `None` for [`DefinitionKind::WindowsService`], whose "definition" is a
1574    /// set of arguments to `CreateServiceW`, and for a Task Scheduler document,
1575    /// which is handed to `schtasks` from a temporary file and is not stored
1576    /// where it was written.
1577    #[must_use]
1578    pub fn install_path(&self) -> Option<&Path> {
1579        self.install_path.as_deref()
1580    }
1581
1582    /// The definition this host's own service manager would be given.
1583    ///
1584    /// The value-level twin of [`host_definition_kind`]. Rendering needs no
1585    /// privileges and no service manager, so anything that wants to *see* what
1586    /// an install would register — `f3`'s `service install --dry-run`, a
1587    /// support bundle, [`RecordingControls`] — can have it without registering
1588    /// anything.
1589    ///
1590    /// # Errors
1591    ///
1592    /// [`ServiceError::Control`] when a Windows logon-triggered registration is
1593    /// asked for and the session reports no account to register it for.
1594    pub fn for_host(plan: &InstallPlan) -> Result<Self, ServiceError> {
1595        Ok(match host_definition_kind(plan.start_mode()) {
1596            DefinitionKind::WindowsService => Self::windows_service(plan),
1597            DefinitionKind::WindowsScheduledTask => {
1598                Self::windows_scheduled_task(plan, &TaskPrincipal::current()?)
1599            }
1600            DefinitionKind::LaunchdPlist => Self::launchd(plan, host_home().as_deref()),
1601            DefinitionKind::SystemdUnit => Self::systemd(plan, host_home().as_deref()),
1602        })
1603    }
1604
1605    /// The Windows service parameters, as a canonical descriptor.
1606    #[must_use]
1607    pub fn windows_service(plan: &InstallPlan) -> Self {
1608        Self {
1609            kind: DefinitionKind::WindowsService,
1610            text: windows_service_descriptor(plan),
1611            install_path: None,
1612        }
1613    }
1614
1615    /// A Task Scheduler document for `--start-at login`.
1616    #[must_use]
1617    pub fn windows_scheduled_task(plan: &InstallPlan, principal: &TaskPrincipal) -> Self {
1618        Self {
1619            kind: DefinitionKind::WindowsScheduledTask,
1620            text: windows_scheduled_task_xml(plan, principal),
1621            install_path: None,
1622        }
1623    }
1624
1625    /// A launchd property list.
1626    ///
1627    /// `home` is only consulted for `--start-at login`, where the file belongs
1628    /// in the operator's own `~/Library/LaunchAgents`.
1629    #[must_use]
1630    pub fn launchd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1631        let file = format!("{}.plist", plan.identity().launchd_label());
1632        let install_path = match plan.start_mode() {
1633            StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
1634            StartMode::Login => home.map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file)),
1635        };
1636        Self {
1637            kind: DefinitionKind::LaunchdPlist,
1638            text: launchd_plist(plan),
1639            install_path,
1640        }
1641    }
1642
1643    /// Wraps text this module did not render.
1644    ///
1645    /// Two callers need it and both matter. A test builds a deliberately
1646    /// widened definition and watches [`review_least_privilege`] reject it —
1647    /// without which the review would be a function nobody had ever seen fail.
1648    /// And `service status` can review the definition **actually on disk**,
1649    /// which on Linux and macOS an operator is free to edit after installation.
1650    #[must_use]
1651    pub fn from_text(kind: DefinitionKind, text: impl Into<String>) -> Self {
1652        Self {
1653            kind,
1654            text: text.into(),
1655            install_path: None,
1656        }
1657    }
1658
1659    /// A systemd unit.
1660    ///
1661    /// `home` is only consulted for `--start-at login`, where the unit belongs
1662    /// in the operator's own `~/.config/systemd/user`.
1663    #[must_use]
1664    pub fn systemd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1665        let file = plan.identity().systemd_unit();
1666        let install_path = match plan.start_mode() {
1667            StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
1668            StartMode::Login => home.map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file)),
1669        };
1670        Self {
1671            kind: DefinitionKind::SystemdUnit,
1672            text: systemd_unit(plan),
1673            install_path,
1674        }
1675    }
1676}
1677
1678/// Where a LaunchDaemon lives.
1679pub const LAUNCH_DAEMONS_DIR: &str = "/Library/LaunchDaemons";
1680/// Where a LaunchAgent lives, under the operator's home directory.
1681pub const LAUNCH_AGENTS_SUBDIR: &str = "Library/LaunchAgents";
1682/// Where a systemd system unit lives.
1683pub const SYSTEMD_SYSTEM_DIR: &str = "/etc/systemd/system";
1684/// Where a systemd user unit lives, under the operator's home directory.
1685pub const SYSTEMD_USER_SUBDIR: &str = ".config/systemd/user";
1686
1687/// The product's own page, cited by every definition so an operator who finds
1688/// one on a host can find out what put it there.
1689const DOCUMENTATION: &str = "https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI";
1690
1691/// How many consecutive failures the platform tolerates before it stops
1692/// retrying.
1693///
1694/// The companion to [`RestartPolicy::reset_after`]: the delay bounds how *fast*
1695/// a restart may come, and this bounds how *many* come before the host gives
1696/// up and leaves the failure visible instead of hiding it behind an endless
1697/// retry.
1698pub const START_LIMIT_BURST: u32 = 5;
1699
1700// -- systemd -----------------------------------------------------------------
1701
1702/// Renders the systemd unit for this plan.
1703///
1704/// # Why the sandbox is this tight, and what it costs
1705///
1706/// `05-infrastructure.md` item 2 ends *"and write its configured cache and
1707/// runtime directories — and no more"*. `ProtectSystem=strict` plus an explicit
1708/// `ReadWritePaths` is what "and no more" means on Linux: everything outside the
1709/// recorded application-data directories and the credential store directory
1710/// is read-only to this unit. The latter must be writable because refresh-token
1711/// rotation atomically replaces the credential file.
1712///
1713/// **The runner inherits it.** The agent spawns the GitHub Actions runner as a
1714/// child, so a workflow running on this host also runs inside this sandbox: it
1715/// cannot write outside those directories and its private `/tmp`, and
1716/// `NoNewPrivileges=yes` means it cannot `sudo`. `07-security.md` assumes a
1717/// hostile workflow may run here, so that is the intended direction — but it is
1718/// a real behavioural limit and `docs/service-account.md` states it where an
1719/// operator will find it.
1720#[must_use]
1721pub fn systemd_unit(plan: &InstallPlan) -> String {
1722    let identity = plan.identity();
1723    let restart = plan.restart();
1724    let directories = plan.directories();
1725
1726    let mut out = String::new();
1727    out.push_str("[Unit]\n");
1728    out.push_str(&format!("Description={}\n", identity.display_name()));
1729    out.push_str(&format!("Documentation={DOCUMENTATION}\n"));
1730    out.push_str("After=network-online.target\n");
1731    out.push_str("Wants=network-online.target\n");
1732    // In `[Unit]` rather than `[Service]`: systemd moved these in v229 and only
1733    // accepts them here without a deprecation warning.
1734    out.push_str(&format!(
1735        "StartLimitIntervalSec={}\n",
1736        restart.reset_after().as_secs()
1737    ));
1738    out.push_str(&format!("StartLimitBurst={START_LIMIT_BURST}\n"));
1739
1740    out.push_str("\n[Service]\n");
1741    out.push_str("Type=simple\n");
1742    // A runner is a child of the daemon. In particular, systemd must not
1743    // signal it merely because a cooperative daemon handover replaces the
1744    // manager process; startup recovery supervises any survivor instead.
1745    out.push_str("KillMode=process\n");
1746    out.push_str(&format!("ExecStart={}\n", plan.command_line()));
1747    out.push_str(&format!(
1748        "WorkingDirectory={}\n",
1749        directories.state.display()
1750    ));
1751    out.push_str(&format!("SyslogIdentifier={identity}\n"));
1752    out.push_str("Restart=on-failure\n");
1753    out.push_str(&format!("RestartSec={}\n", restart.delay().as_secs()));
1754
1755    out.push_str("\n# Least privilege. See docs/service-account.md.\n");
1756    for directive in systemd_hardening(plan.start_mode()) {
1757        out.push_str(directive);
1758        out.push('\n');
1759    }
1760    let quoted = |paths: &[String]| {
1761        paths
1762            .iter()
1763            .map(|path| quote_argument(path))
1764            .collect::<Vec<_>>()
1765            .join(" ")
1766    };
1767    let permitted = permitted_paths(DefinitionKind::SystemdUnit, plan);
1768    out.push_str(&format!("ReadWritePaths={}\n", quoted(&permitted)));
1769    // `ProtectHome=tmpfs` hides the home trees, and only `BindPaths=` brings
1770    // a directory back (see `DefinitionDrift::writable`). New boot units keep
1771    // theirs outside them; one an older build put under `/root` (a WSL host
1772    // set up before 0.4.57) keeps working this way.
1773    if plan.start_mode() == StartMode::Boot {
1774        let under_home: Vec<String> = permitted
1775            .iter()
1776            .filter(|path| home_root_of(Path::new(path.as_str())).is_some())
1777            .cloned()
1778            .collect();
1779        if !under_home.is_empty() {
1780            out.push_str(&format!("BindPaths={}\n", quoted(&under_home)));
1781        }
1782    }
1783    if !plan.protected_paths().is_empty() {
1784        out.push_str(&format!(
1785            "InaccessiblePaths={}\n",
1786            inaccessible_entries(plan).join(" ")
1787        ));
1788    }
1789
1790    out.push_str("\n[Install]\n");
1791    out.push_str(match plan.start_mode() {
1792        StartMode::Boot => "WantedBy=multi-user.target\n",
1793        StartMode::Login => "WantedBy=default.target\n",
1794    });
1795    out
1796}
1797
1798/// The hardening directives every unit carries, in the order they are rendered.
1799///
1800/// One list, used by [`systemd_hardening`], so that [`review_least_privilege`]
1801/// is written against the same directives the renderer emits: a directive
1802/// dropped from the unit is one the review then reports as missing, rather
1803/// than one that silently stops being checked.
1804///
1805/// The capability bounding set listed here is a login unit's: empty. A boot
1806/// unit's is [`BOOT_CAPABILITIES`]; see [`systemd_hardening`].
1807///
1808/// # Why not `RestrictSUIDSGID=yes`
1809///
1810/// Its seccomp filter cannot inspect `openat2`'s flags, so it answers every
1811/// `openat2` with `ENOSYS`, expecting a fallback to `openat`. GNU tar 1.35
1812/// extracts through `openat2(..., RESOLVE_BENEATH)` and has none, so every
1813/// extraction in a job failed with "Cannot open: Function not implemented" --
1814/// and the runner unpacks every action it downloads with `tar`. Measured on
1815/// Ubuntu 26.04 with systemd 259. What the directive guarded against is
1816/// covered without it: `NoNewPrivileges=yes` makes a set-user-ID bit inert for
1817/// everything this unit starts, and the only places it can write are `root`'s
1818/// own `0700` directories and its private `/tmp`, which no other account can
1819/// reach to execute a file left there.
1820const SYSTEMD_HARDENING: [&str; 12] = [
1821    "NoNewPrivileges=yes",
1822    "CapabilityBoundingSet=",
1823    "AmbientCapabilities=",
1824    "PrivateTmp=yes",
1825    "PrivateDevices=yes",
1826    "ProtectSystem=strict",
1827    "ProtectKernelTunables=yes",
1828    "ProtectKernelModules=yes",
1829    "ProtectControlGroups=yes",
1830    "RestrictNamespaces=yes",
1831    "RestrictRealtime=yes",
1832    "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1833];
1834
1835/// The capabilities a boot unit's daemon keeps, all for one purpose: running
1836/// its runners as accounts of a [`crate::runner_account::RunnerPool`] rather than as
1837/// itself.
1838///
1839/// - `CAP_SETUID`, `CAP_SETGID`: to start a runner as that account.
1840/// - `CAP_KILL`: to signal the runner's processes, which are no longer its own.
1841/// - `CAP_CHOWN`: to hand the runner its attempt directory and caches.
1842/// - `CAP_DAC_OVERRIDE`, `CAP_FOWNER`: to clean up what the runner left, and
1843///   to prune caches, though the runner's account owns those files.
1844///
1845/// None reaches a job. Moving from `root` to another uid clears the permitted
1846/// and effective sets, the ambient set is empty, and `NoNewPrivileges=yes`
1847/// stops a set-user-ID or file-capability binary from granting any back.
1848pub const BOOT_CAPABILITIES: &str = boot_capabilities!();
1849
1850/// The one spelling of [`BOOT_CAPABILITIES`], so that the directive the unit
1851/// carries and the list the review checks it against cannot drift apart.
1852macro_rules! boot_capabilities {
1853    () => {
1854        "CAP_CHOWN CAP_DAC_OVERRIDE CAP_FOWNER CAP_KILL CAP_SETGID CAP_SETUID"
1855    };
1856}
1857use boot_capabilities;
1858
1859/// The hardening directives a unit started in `mode` carries.
1860///
1861/// A boot unit keeps [`BOOT_CAPABILITIES`] for its daemon, and hides every
1862/// home directory behind an empty `tmpfs`: its
1863/// data lives outside them, and a workflow running as `root` has no business
1864/// reading the files there that DAC leaves world-readable. `tmpfs` rather than
1865/// `yes`, because tools such as `git` treat an *unreadable* `$HOME/.gitconfig`
1866/// as fatal and an absent one as nothing.
1867fn systemd_hardening(mode: StartMode) -> Vec<&'static str> {
1868    let mut directives = SYSTEMD_HARDENING.to_vec();
1869    if mode == StartMode::Boot {
1870        if let Some(directive) = directives
1871            .iter_mut()
1872            .find(|directive| **directive == "CapabilityBoundingSet=")
1873        {
1874            *directive = concat!("CapabilityBoundingSet=", boot_capabilities!());
1875        }
1876        directives.push("ProtectHome=tmpfs");
1877    }
1878    directives
1879}
1880
1881/// `InaccessiblePaths=` entries for `plan`'s protected paths, each prefixed
1882/// with `-` so that a path that does not exist yet is not an error.
1883fn inaccessible_entries(plan: &InstallPlan) -> Vec<String> {
1884    plan.protected_paths()
1885        .iter()
1886        .map(|path| quote_argument(&format!("-{}", path.to_string_lossy())))
1887        .collect()
1888}
1889
1890/// Host paths no runner or job may reach, which the operator names with
1891/// `host protect-path`.
1892///
1893/// On Linux the systemd unit lists them in `InaccessiblePaths=`, so inside the
1894/// unit the kernel replaces each with a node of mode `000`: no runner or job,
1895/// in any pool account, reaches it, whatever its own permissions. Measured on
1896/// Ubuntu 26.04: `rm-runner-1` reads such a file on the host and gets
1897/// `Permission denied` inside the unit. The
1898/// list is read when the service is registered, so a change takes effect when
1899/// it is registered again. Windows and macOS have no equivalent the installer
1900/// can set, so there it is refused rather than written and silently ignored.
1901#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
1902#[serde(deny_unknown_fields)]
1903pub struct ProtectedPaths {
1904    #[serde(default)]
1905    pub paths: Vec<PathBuf>,
1906}
1907
1908impl ProtectedPaths {
1909    /// The file in the configuration directory.
1910    pub const FILE: &'static str = "protected-paths.json";
1911
1912    /// Whether this platform can enforce protected paths.
1913    #[must_use]
1914    pub const fn supported() -> bool {
1915        cfg!(all(unix, not(target_os = "macos")))
1916    }
1917
1918    /// The list in `config_dir`, empty when there is none.
1919    ///
1920    /// # Errors
1921    ///
1922    /// When the file cannot be read or does not parse.
1923    pub fn load(config_dir: &Path) -> std::io::Result<Self> {
1924        crate::settings_file::load(&config_dir.join(Self::FILE))
1925    }
1926
1927    /// Writes the list into `config_dir`.
1928    ///
1929    /// # Errors
1930    ///
1931    /// When the file cannot be written.
1932    pub fn save(&self, config_dir: &Path) -> std::io::Result<()> {
1933        crate::settings_file::save(&config_dir.join(Self::FILE), self)
1934    }
1935
1936    /// Why `path` cannot be protected, or `None` when it can.
1937    ///
1938    /// The paths every job needs to run at all, and the service's own
1939    /// directories, are refused: hiding them would stop every runner rather
1940    /// than keep jobs out of something.
1941    #[must_use]
1942    pub fn refusal(path: &Path, own: &[PathBuf]) -> Option<String> {
1943        const SYSTEM: [&str; 16] = [
1944            "/", "/bin", "/boot", "/dev", "/etc", "/lib", "/lib32", "/lib64", "/opt", "/proc",
1945            "/run", "/sbin", "/sys", "/tmp", "/usr", "/var",
1946        ];
1947        if !path.is_absolute()
1948            || path
1949                .components()
1950                .any(|component| matches!(component, std::path::Component::ParentDir))
1951        {
1952            return Some(format!(
1953                "{} is not an absolute path without `..`",
1954                path.display()
1955            ));
1956        }
1957        if SYSTEM.iter().any(|system| path == Path::new(system)) {
1958            return Some(format!(
1959                "{} is one every job needs to run; protect a directory inside it instead",
1960                path.display()
1961            ));
1962        }
1963        if let Some(needed) = own
1964            .iter()
1965            .find(|needed| needed.starts_with(path) || path.starts_with(needed))
1966        {
1967            return Some(format!(
1968                "{} holds or lies inside {}, which the service itself uses",
1969                path.display(),
1970                needed.display()
1971            ));
1972        }
1973        None
1974    }
1975}
1976
1977/// The trees `ProtectHome=` hides, which a boot unit's directories must
1978/// therefore lie outside, or be bound back into ([`systemd_unit`]).
1979const SYSTEMD_HOME_ROOTS: [&str; 3] = ["/home", "/root", "/run/user"];
1980
1981/// The home tree `path` lies under, which `ProtectHome=` hides.
1982fn home_root_of(path: &Path) -> Option<&'static str> {
1983    SYSTEMD_HOME_ROOTS
1984        .iter()
1985        .copied()
1986        .find(|root| path.starts_with(root))
1987}
1988
1989/// Renames a non-empty directory at `path` to `<name>.before-move-<stamp>`
1990/// beside it, leaving nothing at `path`.
1991#[cfg(any(all(test, unix), target_os = "linux"))]
1992fn set_aside(path: &Path, stamp: &str) -> std::io::Result<()> {
1993    let occupied = match std::fs::read_dir(path) {
1994        Ok(mut entries) => entries.next().is_some(),
1995        Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
1996        Err(error) => return Err(error),
1997    };
1998    if occupied {
1999        let name = path.file_name().map_or_else(
2000            || std::borrow::Cow::Borrowed("directory"),
2001            |name| name.to_string_lossy(),
2002        );
2003        std::fs::rename(
2004            path,
2005            path.with_file_name(format!("{name}.before-move-{stamp}")),
2006        )?;
2007    }
2008    Ok(())
2009}
2010
2011/// Copies the tree at `from` into `to`, except a top-level entry named `skip`.
2012/// Directories are created `0700`, files keep their mode, and symbolic links
2013/// are copied as links rather than followed.
2014#[cfg(any(all(test, unix), target_os = "linux"))]
2015fn copy_tree(from: &Path, to: &Path, skip: Option<&str>) -> std::io::Result<()> {
2016    use std::os::unix::fs::DirBuilderExt as _;
2017
2018    std::fs::DirBuilder::new()
2019        .recursive(true)
2020        .mode(0o700)
2021        .create(to)?;
2022    for entry in std::fs::read_dir(from)? {
2023        let entry = entry?;
2024        if skip.is_some_and(|skip| entry.file_name() == skip) {
2025            continue;
2026        }
2027        let source = entry.path();
2028        let target = to.join(entry.file_name());
2029        let kind = entry.file_type()?;
2030        if kind.is_symlink() {
2031            std::os::unix::fs::symlink(std::fs::read_link(&source)?, &target)?;
2032        } else if kind.is_dir() {
2033            copy_tree(&source, &target, None)?;
2034        } else {
2035            std::fs::copy(&source, &target)?;
2036        }
2037    }
2038    Ok(())
2039}
2040
2041/// Why a boot unit could not use `path`, owned by `owner`, or `None` when it
2042/// can.
2043///
2044/// A boot unit runs as `root` with no capability at all, so a directory
2045/// another account owns is one it cannot open, and one under a home tree is
2046/// one its `ProtectHome=` hides.
2047#[cfg(any(test, all(unix, not(target_os = "macos"))))]
2048fn boot_directory_refusal(path: &Path, owner: Option<u32>) -> Option<String> {
2049    if let Some(root) = home_root_of(path) {
2050        return Some(format!(
2051            "{} is under {root}, which a boot service cannot see",
2052            path.display()
2053        ));
2054    }
2055    match owner {
2056        Some(0) => None,
2057        Some(uid) => Some(format!(
2058            "{} belongs to uid {uid}, and a boot service runs as root with no capability \
2059             to open another account's directory",
2060            path.display()
2061        )),
2062        None => Some(format!("{} cannot be inspected", path.display())),
2063    }
2064}
2065
2066// -- launchd -----------------------------------------------------------------
2067
2068/// The `ProcessType` every rendered plist carries.
2069///
2070/// # Why not `Background`
2071///
2072/// launchd applies a job's `ProcessType` to the whole process tree it starts,
2073/// not only to the daemon. Under `Background` every runner the daemon launched
2074/// ran at base priority 4, on efficiency cores with throttled I/O, so each job
2075/// took 4 to 20 times as long as on a classic runner.
2076///
2077/// A child cannot leave it. This is the process type launchd chose at spawn
2078/// (`spawn type = background (5)` in `launchctl print`), not the
2079/// `PRIO_DARWIN_BG` flag `setpriority` controls: measured on macOS 27, a child
2080/// of a `Background` agent reads `getpriority(PRIO_DARWIN_PROCESS) == 0`, and
2081/// `setpriority(PRIO_DARWIN_PROCESS, 0, 0)` returns success and leaves it at
2082/// 4, before `exec` and after it alike. 0.4.31 tried exactly that.
2083///
2084/// The measured alternatives, as the base priority of a runner the agent
2085/// starts: `Background` 4, `Adaptive` 4, `Standard` (the default when the key
2086/// is absent) 20, `Interactive` 31 (the same as a classic runner, and the value
2087/// GitHub's own runner service template uses). The daemon itself mostly waits,
2088/// so it costs nothing to run it at that priority too.
2089pub const LAUNCHD_PROCESS_TYPE: &str = "Interactive";
2090
2091/// Renders the launchd property list for this plan.
2092///
2093/// `KeepAlive` is a dictionary rather than `<true/>` on purpose: the
2094/// requirement is *restart on failure*, and a bare `KeepAlive` also restarts a
2095/// job that exited cleanly, which would turn a deliberate `service stop` into a
2096/// fight with launchd.
2097#[must_use]
2098pub fn launchd_plist(plan: &InstallPlan) -> String {
2099    let identity = plan.identity();
2100    let directories = plan.directories();
2101    let mut out = String::new();
2102    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
2103    out.push_str(
2104        "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
2105         \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
2106    );
2107    out.push_str("<plist version=\"1.0\">\n<dict>\n");
2108    out.push_str(&plist_string("Label", &identity.launchd_label()));
2109
2110    out.push_str("  <key>ProgramArguments</key>\n  <array>\n");
2111    out.push_str(&format!(
2112        "    <string>{}</string>\n",
2113        xml_escape(&plan.binary().to_string_lossy())
2114    ));
2115    for argument in plan.arguments() {
2116        out.push_str(&format!(
2117            "    <string>{}</string>\n",
2118            xml_escape(&argument.to_string_lossy())
2119        ));
2120    }
2121    out.push_str("  </array>\n");
2122
2123    out.push_str("  <key>RunAtLoad</key>\n  <true/>\n");
2124    out.push_str("  <key>KeepAlive</key>\n  <dict>\n");
2125    out.push_str("    <key>SuccessfulExit</key>\n    <false/>\n");
2126    out.push_str("  </dict>\n");
2127    out.push_str(&format!(
2128        "  <key>ThrottleInterval</key>\n  <integer>{}</integer>\n",
2129        plan.restart().delay().as_secs()
2130    ));
2131    out.push_str(&plist_string("ProcessType", LAUNCHD_PROCESS_TYPE));
2132    out.push_str(&plist_string(
2133        "WorkingDirectory",
2134        &directories.state.to_string_lossy(),
2135    ));
2136    out.push_str(&plist_string(
2137        "StandardOutPath",
2138        &directories
2139            .logs
2140            .join("runner-manager.launchd.out.log")
2141            .to_string_lossy(),
2142    ));
2143    out.push_str(&plist_string(
2144        "StandardErrorPath",
2145        &directories
2146            .logs
2147            .join("runner-manager.launchd.err.log")
2148            .to_string_lossy(),
2149    ));
2150
2151    match plan.start_mode() {
2152        StartMode::Boot => {
2153            // A LaunchDaemon runs as root unless told otherwise, and here it
2154            // must: the System Keychain's master key is root-only.
2155            out.push_str(&plist_string(
2156                "UserName",
2157                ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
2158                    .as_str(),
2159            ));
2160            // A daemon has no user session and must not be given one.
2161            out.push_str("  <key>SessionCreate</key>\n  <false/>\n");
2162        }
2163        StartMode::Login => {
2164            // A LaunchAgent already runs as the operator. Naming a `UserName`
2165            // here would be asking launchd for an account switch a login-mode
2166            // registration has no reason to want.
2167        }
2168    }
2169
2170    out.push_str("</dict>\n</plist>\n");
2171    out
2172}
2173
2174/// One `<key>`/`<string>` pair, indented as the rest of the plist is.
2175fn plist_string(key: &str, value: &str) -> String {
2176    format!(
2177        "  <key>{}</key>\n  <string>{}</string>\n",
2178        xml_escape(key),
2179        xml_escape(value)
2180    )
2181}
2182
2183// -- Windows Task Scheduler --------------------------------------------------
2184
2185/// The account a Task Scheduler task runs as.
2186///
2187/// Task Scheduler, unlike the other three managers, needs the principal spelled
2188/// out in the document. Isolating the one impure step keeps
2189/// [`windows_scheduled_task_xml`] a pure function that every CI leg can assert.
2190#[derive(Debug, Clone, PartialEq, Eq)]
2191pub struct TaskPrincipal {
2192    user_id: String,
2193}
2194
2195impl TaskPrincipal {
2196    /// The account running this call, as `DOMAIN\user`.
2197    ///
2198    /// # Errors
2199    ///
2200    /// [`ServiceError::BinaryPath`] is not the right shape here, so this
2201    /// reports [`ServiceError::Control`] naming what the environment failed to
2202    /// say.
2203    pub fn current() -> Result<Self, ServiceError> {
2204        let user = std::env::var("USERNAME")
2205            .ok()
2206            .filter(|value| !value.trim().is_empty());
2207        let Some(user) = user else {
2208            return Err(ServiceError::Control {
2209                operation: "identify the account for",
2210                name: SERVICE_NAME.to_string(),
2211                manager: "Windows Task Scheduler",
2212                detail: "this session reports no %USERNAME%, so there is no principal to \
2213                         register a logon-triggered task for"
2214                    .to_string(),
2215            });
2216        };
2217        let domain = std::env::var("USERDOMAIN")
2218            .ok()
2219            .filter(|value| !value.trim().is_empty());
2220        Ok(Self {
2221            user_id: match domain {
2222                Some(domain) => format!("{domain}\\{user}"),
2223                None => user,
2224            },
2225        })
2226    }
2227
2228    /// A named principal, for tests and for a caller that already knows.
2229    #[must_use]
2230    pub fn named(user_id: impl Into<String>) -> Self {
2231        Self {
2232            user_id: user_id.into(),
2233        }
2234    }
2235
2236    /// `DOMAIN\user`.
2237    #[must_use]
2238    pub fn user_id(&self) -> &str {
2239        &self.user_id
2240    }
2241}
2242
2243/// Renders the Task Scheduler document for a `--start-at login` registration.
2244///
2245/// `RunLevel` is `LeastPrivilege`, which is the whole of Windows' answer to
2246/// item 2 in this domain: the task runs with the operator's ordinary filtered
2247/// token and never with an elevated one, whatever the operator's group
2248/// membership.
2249#[must_use]
2250pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
2251    let identity = plan.identity();
2252    let user = xml_escape(principal.user_id());
2253    let arguments = plan
2254        .arguments()
2255        .iter()
2256        .map(|argument| quote_argument(&argument.to_string_lossy()))
2257        .collect::<Vec<_>>()
2258        .join(" ");
2259    let mut out = String::new();
2260    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
2261    out.push_str(
2262        "<Task version=\"1.4\" \
2263         xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
2264    );
2265    out.push_str("  <RegistrationInfo>\n");
2266    out.push_str(&format!(
2267        "    <Description>{}</Description>\n",
2268        xml_escape(identity.description())
2269    ));
2270    out.push_str(&format!(
2271        "    <URI>\\{}</URI>\n",
2272        xml_escape(identity.name())
2273    ));
2274    out.push_str("  </RegistrationInfo>\n");
2275
2276    out.push_str("  <Triggers>\n    <LogonTrigger>\n");
2277    out.push_str("      <Enabled>true</Enabled>\n");
2278    out.push_str(&format!("      <UserId>{user}</UserId>\n"));
2279    out.push_str("    </LogonTrigger>\n  </Triggers>\n");
2280
2281    out.push_str("  <Principals>\n    <Principal id=\"Author\">\n");
2282    out.push_str(&format!("      <UserId>{user}</UserId>\n"));
2283    out.push_str("      <LogonType>InteractiveToken</LogonType>\n");
2284    out.push_str("      <RunLevel>LeastPrivilege</RunLevel>\n");
2285    out.push_str("    </Principal>\n  </Principals>\n");
2286
2287    out.push_str("  <Settings>\n");
2288    // One agent per host is `d1`'s lock; saying so here means Task Scheduler
2289    // refuses the second start rather than starting a process that then loses
2290    // the race and exits.
2291    out.push_str("    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
2292    out.push_str("    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
2293    out.push_str("    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
2294    out.push_str("    <AllowHardTerminate>true</AllowHardTerminate>\n");
2295    out.push_str("    <StartWhenAvailable>true</StartWhenAvailable>\n");
2296    out.push_str("    <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
2297    out.push_str("    <IdleSettings>\n");
2298    out.push_str("      <StopOnIdleEnd>false</StopOnIdleEnd>\n");
2299    out.push_str("      <RestartOnIdle>false</RestartOnIdle>\n");
2300    out.push_str("    </IdleSettings>\n");
2301    out.push_str("    <AllowStartOnDemand>true</AllowStartOnDemand>\n");
2302    out.push_str("    <Enabled>true</Enabled>\n");
2303    out.push_str("    <Hidden>false</Hidden>\n");
2304    out.push_str("    <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
2305    out.push_str("    <WakeToRun>false</WakeToRun>\n");
2306    // A daemon has no natural end, so any limit here would be a scheduled kill.
2307    out.push_str("    <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
2308    out.push_str("    <Priority>7</Priority>\n");
2309    out.push_str("    <RestartOnFailure>\n");
2310    out.push_str(&format!(
2311        "      <Interval>{}</Interval>\n",
2312        iso8601_minutes(
2313            plan.restart()
2314                .effective_delay(DefinitionKind::WindowsScheduledTask)
2315        )
2316    ));
2317    out.push_str(&format!("      <Count>{START_LIMIT_BURST}</Count>\n"));
2318    out.push_str("    </RestartOnFailure>\n");
2319    out.push_str("  </Settings>\n");
2320
2321    out.push_str("  <Actions Context=\"Author\">\n    <Exec>\n");
2322    out.push_str(&format!(
2323        "      <Command>{}</Command>\n",
2324        xml_escape(&plan.binary().to_string_lossy())
2325    ));
2326    if !arguments.is_empty() {
2327        out.push_str(&format!(
2328            "      <Arguments>{}</Arguments>\n",
2329            xml_escape(&arguments)
2330        ));
2331    }
2332    out.push_str(&format!(
2333        "      <WorkingDirectory>{}</WorkingDirectory>\n",
2334        xml_escape(&plan.directories().state.to_string_lossy())
2335    ));
2336    out.push_str("    </Exec>\n  </Actions>\n");
2337    out.push_str("</Task>\n");
2338    out
2339}
2340
2341/// `PT<n>M`, which is the only shape Task Scheduler accepts for a restart
2342/// interval. See [`RestartPolicy::effective_delay`].
2343fn iso8601_minutes(duration: Duration) -> String {
2344    format!("PT{}M", duration.as_secs() / 60)
2345}
2346
2347/// Escapes the five XML entities. Applied to every value that reaches a plist
2348/// or a task document, because a Windows account name may legitimately contain
2349/// `&` and a path may contain `<`.
2350///
2351/// `pub(crate)` rather than private because [`crate::wsl::task`] renders a
2352/// second kind of Task Scheduler document and must escape it the same way; two
2353/// escapers in one crate is exactly how one of them ends up subtly different.
2354pub(crate) fn xml_escape(value: &str) -> String {
2355    let mut out = String::with_capacity(value.len());
2356    for c in value.chars() {
2357        match c {
2358            '&' => out.push_str("&amp;"),
2359            '<' => out.push_str("&lt;"),
2360            '>' => out.push_str("&gt;"),
2361            '"' => out.push_str("&quot;"),
2362            '\'' => out.push_str("&apos;"),
2363            other => out.push(other),
2364        }
2365    }
2366    out
2367}
2368
2369/// The inverse of [`xml_escape`], for reading a definition back off disk.
2370///
2371/// `&amp;` is replaced last, which is the whole subtlety: replacing it first
2372/// would turn a literal `&amp;amp;` into `&` in two passes instead of `&amp;`
2373/// in one.
2374fn xml_unescape(value: &str) -> String {
2375    value
2376        .replace("&lt;", "<")
2377        .replace("&gt;", ">")
2378        .replace("&quot;", "\"")
2379        .replace("&apos;", "'")
2380        .replace("&amp;", "&")
2381}
2382
2383// -- Windows Service Control Manager -----------------------------------------
2384
2385/// How `CreateServiceW` should be called, in a form that can be reviewed,
2386/// asserted, and printed on any platform.
2387///
2388/// The Windows backend builds its `ServiceInfo` from **this** value rather than
2389/// from the plan, so the descriptor a Linux CI leg asserts and the parameters a
2390/// Windows host registers cannot drift apart.
2391#[derive(Debug, Clone, PartialEq, Eq)]
2392pub struct WindowsServiceSpec {
2393    /// The service name.
2394    pub name: String,
2395    /// What `services.msc` shows.
2396    pub display_name: String,
2397    /// The one-line description.
2398    pub description: String,
2399    /// `true` for `SERVICE_AUTO_START`, `false` for `SERVICE_DEMAND_START`.
2400    pub automatic_start: bool,
2401    /// The account, or `None` for `LocalSystem`, which is what
2402    /// `CreateServiceW` takes a null `lpServiceStartName` to mean.
2403    pub account: Option<String>,
2404    /// The full launch command, quoted.
2405    pub command_line: String,
2406    /// The restart-on-failure policy.
2407    pub restart: RestartPolicy,
2408}
2409
2410/// Derives the Windows service parameters from a plan.
2411#[must_use]
2412pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
2413    WindowsServiceSpec {
2414        name: plan.identity().name().to_string(),
2415        display_name: plan.identity().display_name().to_string(),
2416        description: plan.identity().description().to_string(),
2417        // Only a boot-mode registration is a service at all; a login-mode one
2418        // is a scheduled task. So an automatic start is the only kind here, and
2419        // the field exists because the privileged tests register an on-demand
2420        // fixture rather than one that starts with the test machine.
2421        automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
2422        account: match ServiceAccount::for_definition(
2423            DefinitionKind::WindowsService,
2424            plan.start_mode(),
2425        ) {
2426            // `None` means LocalSystem to `CreateServiceW`, and naming it
2427            // explicitly would be one more string to spell right.
2428            ServiceAccount::LocalSystem => None,
2429            other => Some(other.as_str().to_string()),
2430        },
2431        command_line: plan.command_line(),
2432        restart: plan.restart(),
2433    }
2434}
2435
2436/// Renders the descriptor [`review_least_privilege`] reads and `service status`
2437/// can print.
2438#[must_use]
2439fn windows_service_descriptor(plan: &InstallPlan) -> String {
2440    let spec = windows_service_spec(plan);
2441    let mut out = String::new();
2442    out.push_str("[windows-service]\n");
2443    out.push_str(&format!("Name={}\n", spec.name));
2444    out.push_str(&format!("DisplayName={}\n", spec.display_name));
2445    out.push_str(&format!("Description={}\n", spec.description));
2446    // OWN_PROCESS and never INTERACTIVE_PROCESS: an interactive service would
2447    // put a process this product controls on the operator's desktop, which is
2448    // both deprecated by Windows and more than the requirement asks for.
2449    out.push_str("ServiceType=OWN_PROCESS\n");
2450    out.push_str(&format!(
2451        "StartType={}\n",
2452        if spec.automatic_start {
2453            "AutoStart"
2454        } else {
2455            "OnDemand"
2456        }
2457    ));
2458    out.push_str("ErrorControl=Normal\n");
2459    out.push_str(&format!(
2460        "Account={}\n",
2461        spec.account
2462            .as_deref()
2463            .unwrap_or(ServiceAccount::LocalSystem.as_str())
2464    ));
2465    out.push_str(&format!("CommandLine={}\n", spec.command_line));
2466    out.push_str(&format!(
2467        "FailureActionRestartDelaySecs={}\n",
2468        spec.restart.delay().as_secs()
2469    ));
2470    out.push_str(&format!(
2471        "FailureActionsResetPeriodSecs={}\n",
2472        spec.restart.reset_after().as_secs()
2473    ));
2474    // Without this flag the Service Control Manager applies failure actions
2475    // only to a crash, and a daemon that exits non-zero after failing to reach
2476    // GitHub is not a crash.
2477    out.push_str("FailureActionsOnNonCrashFailures=true\n");
2478    out.push_str(&format!(
2479        "ReadWritePaths={}\n",
2480        plan.directories()
2481            .all()
2482            .iter()
2483            .map(|path| quote_argument(&path.to_string_lossy()))
2484            .collect::<Vec<_>>()
2485            .join(" ")
2486    ));
2487    out
2488}
2489
2490// ---------------------------------------------------------------------------
2491// The least-privilege review
2492// ---------------------------------------------------------------------------
2493
2494/// Whether a finding is about too much authority or too little.
2495#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2496pub enum FindingKind {
2497    /// The definition grants more than `05-infrastructure.md` item 2 asks for.
2498    /// This is what makes a review fail.
2499    Excess,
2500    /// The definition does not grant something the daemon needs, or does not
2501    /// state something the review cannot verify without.
2502    Shortfall,
2503}
2504
2505impl fmt::Display for FindingKind {
2506    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2507        f.write_str(match self {
2508            Self::Excess => "excess",
2509            Self::Shortfall => "shortfall",
2510        })
2511    }
2512}
2513
2514/// One thing the review has to say about a definition.
2515#[derive(Debug, Clone, PartialEq, Eq)]
2516pub struct PrivilegeFinding {
2517    /// Excess or shortfall.
2518    pub kind: FindingKind,
2519    /// The directive, key, or element it is about.
2520    pub subject: String,
2521    /// What is wrong with it, in terms an operator can act on.
2522    pub detail: String,
2523}
2524
2525impl fmt::Display for PrivilegeFinding {
2526    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2527        write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2528    }
2529}
2530
2531/// What a definition actually grants, measured against the requirement.
2532///
2533/// `05-infrastructure.md` item 2 and `07-security.md`'s release gate
2534/// (*"Service account permissions are documented and verified least
2535/// privilege"*) are the same requirement stated twice, and this is the
2536/// verification half. The documentation half is `docs/service-account.md`.
2537///
2538/// The review reads the **rendered text**, not the plan it came from. That is
2539/// deliberate and it is the only version of this check worth having: a review
2540/// that re-derived its expectations from the same values the renderer used
2541/// would agree with the renderer by construction and could never fail. Reading
2542/// the text means a hand-edited unit on a real host is reviewed as it is, and
2543/// means a test can widen a definition and watch this say so.
2544#[derive(Debug, Clone, PartialEq, Eq)]
2545pub struct PrivilegeReview {
2546    kind: DefinitionKind,
2547    account: ServiceAccount,
2548    controls: Vec<String>,
2549    findings: Vec<PrivilegeFinding>,
2550}
2551
2552impl PrivilegeReview {
2553    /// Whether the definition grants nothing beyond the requirement.
2554    #[must_use]
2555    pub fn is_least_privilege(&self) -> bool {
2556        !self
2557            .findings
2558            .iter()
2559            .any(|finding| finding.kind == FindingKind::Excess)
2560    }
2561
2562    /// Everything the review found, excesses and shortfalls together.
2563    #[must_use]
2564    pub fn findings(&self) -> &[PrivilegeFinding] {
2565        &self.findings
2566    }
2567
2568    /// Only the excesses — the findings that make [`Self::is_least_privilege`]
2569    /// false.
2570    #[must_use]
2571    pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2572        self.findings
2573            .iter()
2574            .filter(|finding| finding.kind == FindingKind::Excess)
2575            .collect()
2576    }
2577
2578    /// The controls the definition was confirmed to carry.
2579    ///
2580    /// Present so that a passing review says *what it checked* rather than only
2581    /// that it passed. A check that reports nothing when it succeeds is
2582    /// indistinguishable from a check that did not run.
2583    #[must_use]
2584    pub fn controls(&self) -> &[String] {
2585        &self.controls
2586    }
2587
2588    /// The account the registration runs under, and why it is the minimum.
2589    #[must_use]
2590    pub const fn account(&self) -> &ServiceAccount {
2591        &self.account
2592    }
2593
2594    /// Which platform's definition was reviewed.
2595    #[must_use]
2596    pub const fn kind(&self) -> DefinitionKind {
2597        self.kind
2598    }
2599}
2600
2601impl fmt::Display for PrivilegeReview {
2602    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2603        writeln!(
2604            f,
2605            "{} runs as {} ({})",
2606            self.kind,
2607            self.account,
2608            self.account.justification()
2609        )?;
2610        for control in &self.controls {
2611            writeln!(f, "  confirmed  {control}")?;
2612        }
2613        for finding in &self.findings {
2614            writeln!(f, "  {finding}")?;
2615        }
2616        if self.is_least_privilege() {
2617            write!(f, "  verdict    least privilege")
2618        } else {
2619            write!(
2620                f,
2621                "  verdict    NOT least privilege: {} excess(es)",
2622                self.excesses().len()
2623            )
2624        }
2625    }
2626}
2627
2628/// Reads a definition back and reports what it grants.
2629///
2630/// `plan` supplies only the two things the text cannot: the four directories
2631/// the registration is *allowed* to write, and the start mode, which decides
2632/// which account is the minimum.
2633#[must_use]
2634pub fn review_least_privilege(
2635    definition: &ServiceDefinition,
2636    plan: &InstallPlan,
2637) -> PrivilegeReview {
2638    let mut controls = Vec::new();
2639    let mut findings = Vec::new();
2640    match definition.kind() {
2641        DefinitionKind::SystemdUnit => {
2642            review_systemd(definition.text(), plan, &mut controls, &mut findings);
2643        }
2644        DefinitionKind::LaunchdPlist => {
2645            review_launchd(definition.text(), plan, &mut controls, &mut findings);
2646        }
2647        DefinitionKind::WindowsScheduledTask => {
2648            review_scheduled_task(definition.text(), &mut controls, &mut findings);
2649        }
2650        DefinitionKind::WindowsService => {
2651            review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2652        }
2653    }
2654    PrivilegeReview {
2655        kind: definition.kind(),
2656        // The definition's account, not the plan's: a Linux CI leg reviewing the
2657        // Windows descriptor must report `LocalSystem`, which is the account
2658        // that descriptor registers, and not `root`, which is the account this
2659        // leg's own host would use.
2660        account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2661        controls,
2662        findings,
2663    }
2664}
2665
2666/// Every writable path a definition may name, as the strings it names them by.
2667fn permitted_paths(kind: DefinitionKind, plan: &InstallPlan) -> Vec<String> {
2668    let mut permitted = plan
2669        .directories()
2670        .all()
2671        .iter()
2672        .map(|path| path.to_string_lossy().into_owned())
2673        .collect::<Vec<_>>();
2674    if kind == DefinitionKind::SystemdUnit
2675        && let Some(secret_directory) = plan.secret_guard().and_then(Path::parent)
2676    {
2677        permitted.push(secret_directory.to_string_lossy().into_owned());
2678    }
2679    permitted
2680}
2681
2682/// Whether two path spellings name the same place on **this host's**
2683/// filesystem. Case-insensitive on Windows, exact elsewhere.
2684///
2685/// For a rendered definition use [`same_path_for`] instead: which comparison is
2686/// right there follows the platform the definition is *for*, and every leg of
2687/// the CI matrix reviews all four.
2688fn same_path_text(left: &str, right: &str) -> bool {
2689    if cfg!(windows) {
2690        left.eq_ignore_ascii_case(right)
2691    } else {
2692        left == right
2693    }
2694}
2695
2696/// Whether two path spellings name the same place on the platform a given
2697/// definition targets.
2698fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
2699    match kind {
2700        DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
2701            left.eq_ignore_ascii_case(right)
2702        }
2703        DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
2704    }
2705}
2706
2707/// Checks a `ReadWritePaths`-style list against the permitted directories.
2708fn review_writable_paths(
2709    kind: DefinitionKind,
2710    subject: &str,
2711    listed: &[String],
2712    plan: &InstallPlan,
2713    controls: &mut Vec<String>,
2714    findings: &mut Vec<PrivilegeFinding>,
2715) {
2716    let permitted = permitted_paths(kind, plan);
2717    for entry in listed {
2718        if !permitted
2719            .iter()
2720            .any(|allowed| same_path_for(kind, allowed, entry))
2721        {
2722            findings.push(PrivilegeFinding {
2723                kind: FindingKind::Excess,
2724                subject: subject.to_string(),
2725                detail: format!(
2726                    "{entry} is writable but is not one of this registration's required paths"
2727                ),
2728            });
2729        }
2730    }
2731    for allowed in &permitted {
2732        if !listed
2733            .iter()
2734            .any(|entry| same_path_for(kind, allowed, entry))
2735        {
2736            findings.push(PrivilegeFinding {
2737                kind: FindingKind::Shortfall,
2738                subject: subject.to_string(),
2739                detail: format!(
2740                    "{allowed} is one of this registration's directories but is not writable, \
2741                     so the daemon cannot use it"
2742                ),
2743            });
2744        }
2745    }
2746    if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
2747        controls.push(format!(
2748            "{subject} names exactly the required application-data and credential paths"
2749        ));
2750    }
2751}
2752
2753/// Compares a unit's capability bounding set with the one it should keep, as
2754/// sets: a capability it lacks is a shortfall, one it adds is an excess.
2755fn review_capabilities(
2756    expected: &str,
2757    wanted: &str,
2758    actual: &str,
2759    controls: &mut Vec<String>,
2760    findings: &mut Vec<PrivilegeFinding>,
2761) {
2762    let subject = "CapabilityBoundingSet";
2763    // `~` inverts the list, keeping every capability *except* those named.
2764    if actual.trim_start().starts_with('~') {
2765        findings.push(PrivilegeFinding {
2766            kind: FindingKind::Excess,
2767            subject: subject.to_string(),
2768            detail: format!("is `{actual}`, which keeps every capability it does not name"),
2769        });
2770        return;
2771    }
2772    let wanted: BTreeSet<&str> = wanted.split_whitespace().collect();
2773    let actual: BTreeSet<&str> = actual.split_whitespace().collect();
2774    let extra = actual.difference(&wanted).copied().collect::<Vec<_>>();
2775    let missing = wanted.difference(&actual).copied().collect::<Vec<_>>();
2776    if !extra.is_empty() {
2777        findings.push(PrivilegeFinding {
2778            kind: FindingKind::Excess,
2779            subject: subject.to_string(),
2780            detail: format!(
2781                "keeps {}, which the requirement does not ask for",
2782                extra.join(" ")
2783            ),
2784        });
2785    }
2786    if !missing.is_empty() {
2787        findings.push(PrivilegeFinding {
2788            kind: FindingKind::Shortfall,
2789            subject: subject.to_string(),
2790            detail: format!("drops {}, which the daemon needs", missing.join(" ")),
2791        });
2792    }
2793    if extra.is_empty() && missing.is_empty() {
2794        controls.push(expected.to_string());
2795    }
2796}
2797
2798/// The one inbound-surface rule, applied to whichever platform names it.
2799///
2800/// `07-security.md` handling rule 2: *"The product exposes no inbound HTTP,
2801/// socket, or RPC surface anywhere, in any command."* A definition that asks the
2802/// service manager to open a socket or publish a Mach service on the daemon's
2803/// behalf would create exactly that surface without a line of product code
2804/// changing, which is why it is checked here and not only in review.
2805fn review_inbound_surface(
2806    text: &str,
2807    markers: &[(&str, &str)],
2808    controls: &mut Vec<String>,
2809    findings: &mut Vec<PrivilegeFinding>,
2810) {
2811    let mut clean = true;
2812    for (marker, detail) in markers {
2813        if text.contains(marker) {
2814            clean = false;
2815            findings.push(PrivilegeFinding {
2816                kind: FindingKind::Excess,
2817                subject: (*marker).to_string(),
2818                detail: (*detail).to_string(),
2819            });
2820        }
2821    }
2822    if clean {
2823        controls.push(
2824            "no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
2825        );
2826    }
2827}
2828
2829fn review_systemd(
2830    text: &str,
2831    plan: &InstallPlan,
2832    controls: &mut Vec<String>,
2833    findings: &mut Vec<PrivilegeFinding>,
2834) {
2835    let directives = ini_directives(text, "Service");
2836    for expected in systemd_hardening(plan.start_mode()) {
2837        let (key, value) = expected
2838            .split_once('=')
2839            .expect("every hardening directive is written as key=value");
2840        match directives.get(key) {
2841            Some(actual) if key == "CapabilityBoundingSet" => {
2842                review_capabilities(expected, value, actual, controls, findings);
2843            }
2844            Some(actual) if actual == value => controls.push((*expected).to_string()),
2845            Some(actual) => findings.push(PrivilegeFinding {
2846                kind: FindingKind::Excess,
2847                subject: key.to_string(),
2848                detail: format!(
2849                    "is `{actual}`, not `{value}`, so the unit keeps authority the \
2850                                 requirement does not ask for"
2851                ),
2852            }),
2853            None => findings.push(PrivilegeFinding {
2854                kind: FindingKind::Excess,
2855                subject: key.to_string(),
2856                detail: format!(
2857                    "is absent, so the unit inherits systemd's default rather than `{value}`"
2858                ),
2859            }),
2860        }
2861    }
2862
2863    match directives.get("ReadWritePaths") {
2864        Some(value) => {
2865            let listed = split_quoted(value);
2866            review_writable_paths(
2867                DefinitionKind::SystemdUnit,
2868                "ReadWritePaths",
2869                &listed,
2870                plan,
2871                controls,
2872                findings,
2873            );
2874        }
2875        None => findings.push(PrivilegeFinding {
2876            kind: FindingKind::Shortfall,
2877            subject: "ReadWritePaths".to_string(),
2878            detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
2879                .to_string(),
2880        }),
2881    }
2882
2883    // `BindPaths=` reopens what `ProtectHome=tmpfs` hides, read-write, and can
2884    // mount any host path anywhere (`source:destination`): it may name only the
2885    // unit's own directories, each bound onto itself.
2886    if let Some(value) = directives.get("BindPaths") {
2887        let permitted = permitted_paths(DefinitionKind::SystemdUnit, plan);
2888        for entry in split_quoted(value) {
2889            if !permitted
2890                .iter()
2891                .any(|allowed| same_path_for(DefinitionKind::SystemdUnit, allowed, &entry))
2892            {
2893                findings.push(PrivilegeFinding {
2894                    kind: FindingKind::Excess,
2895                    subject: "BindPaths".to_string(),
2896                    detail: format!(
2897                        "{entry} is bound into the unit but is not one of this registration's \
2898                         required paths"
2899                    ),
2900                });
2901            }
2902        }
2903    }
2904
2905    // Each path the operator protected must stay inaccessible: a unit that no
2906    // longer lists one has let every job back into it.
2907    if !plan.protected_paths().is_empty() {
2908        let listed = directives
2909            .get("InaccessiblePaths")
2910            .map(|value| split_quoted(value))
2911            .unwrap_or_default();
2912        let missing = plan
2913            .protected_paths()
2914            .iter()
2915            .filter(|path| {
2916                let entry = format!("-{}", path.to_string_lossy());
2917                !listed.contains(&entry)
2918            })
2919            .map(|path| path.display().to_string())
2920            .collect::<Vec<_>>();
2921        if missing.is_empty() {
2922            controls.push("InaccessiblePaths keeps every protected path from every job".into());
2923        } else {
2924            findings.push(PrivilegeFinding {
2925                kind: FindingKind::Excess,
2926                subject: "InaccessiblePaths".to_string(),
2927                detail: format!("no longer hides {}", missing.join(", ")),
2928            });
2929        }
2930    }
2931
2932    // `User=` on a system unit is an escalation only in the other direction, so
2933    // what is checked is the pair systemd actually treats as privileged.
2934    if directives.contains_key("PrivateUsers")
2935        && directives.get("PrivateUsers") == Some(&"no".to_string())
2936    {
2937        findings.push(PrivilegeFinding {
2938            kind: FindingKind::Excess,
2939            subject: "PrivateUsers".to_string(),
2940            detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
2941                .to_string(),
2942        });
2943    }
2944
2945    review_inbound_surface(
2946        text,
2947        &[
2948            (
2949                "ListenStream=",
2950                "asks systemd to open a listening socket for this service, which \
2951                 07-security.md rule 2 forbids the product to have",
2952            ),
2953            (
2954                "ListenDatagram=",
2955                "asks systemd to open a listening socket for this service, which \
2956                 07-security.md rule 2 forbids the product to have",
2957            ),
2958        ],
2959        controls,
2960        findings,
2961    );
2962}
2963
2964fn review_launchd(
2965    text: &str,
2966    plan: &InstallPlan,
2967    controls: &mut Vec<String>,
2968    findings: &mut Vec<PrivilegeFinding>,
2969) {
2970    // A scheduling class is not an authority, so no value here is an excess.
2971    // Anything but `Interactive` is a shortfall: launchd applies it to every
2972    // runner the daemon starts. See `LAUNCHD_PROCESS_TYPE`.
2973    match plist_string_value(text, "ProcessType") {
2974        Some(value) if value == LAUNCHD_PROCESS_TYPE => {
2975            controls.push(format!("ProcessType={LAUNCHD_PROCESS_TYPE}"));
2976        }
2977        found => findings.push(PrivilegeFinding {
2978            kind: FindingKind::Shortfall,
2979            subject: "ProcessType".to_string(),
2980            detail: format!(
2981                "is {}, and launchd applies it to every runner the daemon starts, so their jobs \
2982                 run below normal priority. The daemon rewrites it to `{LAUNCHD_PROCESS_TYPE}` \
2983                 when it next starts with no runner; `service status` says how to restart it",
2984                found.map_or_else(
2985                    || "absent (launchd's `Standard`)".to_string(),
2986                    |value| format!("`{value}`")
2987                )
2988            ),
2989        }),
2990    }
2991
2992    match plan.start_mode() {
2993        StartMode::Boot => {
2994            if plist_bool_value(text, "SessionCreate") == Some(true) {
2995                findings.push(PrivilegeFinding {
2996                    kind: FindingKind::Excess,
2997                    subject: "SessionCreate".to_string(),
2998                    detail: "asks launchd to create a security session for a job that runs \
2999                             outside every login session and has no use for one"
3000                        .to_string(),
3001                });
3002            } else {
3003                controls.push("SessionCreate is not requested".to_string());
3004            }
3005            match plist_string_value(text, "UserName").as_deref() {
3006                Some("root") => {
3007                    controls.push("UserName=root, stated rather than inherited".to_string())
3008                }
3009                Some(other) => findings.push(PrivilegeFinding {
3010                    kind: FindingKind::Shortfall,
3011                    subject: "UserName".to_string(),
3012                    detail: format!(
3013                        "is `{other}`, which cannot unlock the System Keychain: \
3014                         /var/db/SystemKey is root-only, so the daemon would start and then \
3015                         find no credential"
3016                    ),
3017                }),
3018                None => findings.push(PrivilegeFinding {
3019                    kind: FindingKind::Shortfall,
3020                    subject: "UserName".to_string(),
3021                    detail: "is absent, so the account is launchd's implicit default and this \
3022                             review cannot confirm it"
3023                        .to_string(),
3024                }),
3025            }
3026        }
3027        StartMode::Login => {
3028            if let Some(named) = plist_string_value(text, "UserName") {
3029                findings.push(PrivilegeFinding {
3030                    kind: FindingKind::Excess,
3031                    subject: "UserName".to_string(),
3032                    detail: format!(
3033                        "names `{named}` in a LaunchAgent, which already runs as the operator; \
3034                         naming an account here asks launchd for a switch a login-mode \
3035                         registration has no reason to want"
3036                    ),
3037                });
3038            } else {
3039                controls
3040                    .push("no UserName: the agent runs as the operator and no other".to_string());
3041            }
3042        }
3043    }
3044
3045    review_inbound_surface(
3046        text,
3047        &[
3048            (
3049                "<key>Sockets</key>",
3050                "asks launchd to open a socket for this job, which 07-security.md rule 2 \
3051                 forbids the product to have",
3052            ),
3053            (
3054                "<key>MachServices</key>",
3055                "publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
3056                 forbids the product to have",
3057            ),
3058        ],
3059        controls,
3060        findings,
3061    );
3062}
3063
3064fn review_scheduled_task(
3065    text: &str,
3066    controls: &mut Vec<String>,
3067    findings: &mut Vec<PrivilegeFinding>,
3068) {
3069    match xml_value(text, "RunLevel").as_deref() {
3070        Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
3071        Some(other) => findings.push(PrivilegeFinding {
3072            kind: FindingKind::Excess,
3073            subject: "RunLevel".to_string(),
3074            detail: format!(
3075                "is `{other}`, so the task runs with an elevated token whenever the operator is \
3076                 an administrator"
3077            ),
3078        }),
3079        None => findings.push(PrivilegeFinding {
3080            kind: FindingKind::Excess,
3081            subject: "RunLevel".to_string(),
3082            detail: "is absent, so Task Scheduler decides the token rather than the definition"
3083                .to_string(),
3084        }),
3085    }
3086
3087    match xml_value(text, "LogonType").as_deref() {
3088        Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
3089        Some(other) => findings.push(PrivilegeFinding {
3090            kind: FindingKind::Excess,
3091            subject: "LogonType".to_string(),
3092            detail: format!(
3093                "is `{other}`, which asks Windows to store or synthesise a credential for this \
3094                 task; an interactive token needs neither"
3095            ),
3096        }),
3097        None => findings.push(PrivilegeFinding {
3098            kind: FindingKind::Shortfall,
3099            subject: "LogonType".to_string(),
3100            detail: "is absent, so this review cannot confirm that no credential is stored"
3101                .to_string(),
3102        }),
3103    }
3104}
3105
3106fn review_windows_service(
3107    text: &str,
3108    plan: &InstallPlan,
3109    controls: &mut Vec<String>,
3110    findings: &mut Vec<PrivilegeFinding>,
3111) {
3112    let directives = ini_directives(text, "windows-service");
3113
3114    match directives.get("ServiceType").map(String::as_str) {
3115        Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
3116        Some(other) => findings.push(PrivilegeFinding {
3117            kind: FindingKind::Excess,
3118            subject: "ServiceType".to_string(),
3119            detail: format!(
3120                "is `{other}`; an interactive or shared-process service reaches further than a \
3121                 daemon that only talks to GitHub over HTTPS"
3122            ),
3123        }),
3124        None => findings.push(PrivilegeFinding {
3125            kind: FindingKind::Shortfall,
3126            subject: "ServiceType".to_string(),
3127            detail: "is absent, so this review cannot confirm the service is not interactive"
3128                .to_string(),
3129        }),
3130    }
3131
3132    // The account is not a free choice on Windows: `d2`'s DACL decides it. What
3133    // the review can check is that the definition names the one account that
3134    // DACL admits, and no broader one.
3135    match directives.get("Account").map(String::as_str) {
3136        Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
3137            controls.push(format!(
3138                "Account={account}: the only stock account the machine-scoped store's DACL \
3139                 (SY, BA, OW) admits"
3140            ));
3141        }
3142        Some(other) => findings.push(PrivilegeFinding {
3143            kind: FindingKind::Shortfall,
3144            subject: "Account".to_string(),
3145            detail: format!(
3146                "is `{other}`, which the machine-scoped store's DACL does not name, so the \
3147                 daemon would start and then find no credential. Widening that DACL is not this \
3148                 registration's to do: an ACE reaching `{other}` would also reach every other \
3149                 service running under it"
3150            ),
3151        }),
3152        None => findings.push(PrivilegeFinding {
3153            kind: FindingKind::Shortfall,
3154            subject: "Account".to_string(),
3155            detail: "is absent, so this review cannot confirm which account was registered"
3156                .to_string(),
3157        }),
3158    }
3159
3160    match directives.get("ReadWritePaths") {
3161        Some(value) => {
3162            let listed = split_quoted(value);
3163            review_writable_paths(
3164                DefinitionKind::WindowsService,
3165                "ReadWritePaths",
3166                &listed,
3167                plan,
3168                controls,
3169                findings,
3170            );
3171        }
3172        None => findings.push(PrivilegeFinding {
3173            kind: FindingKind::Shortfall,
3174            subject: "ReadWritePaths".to_string(),
3175            detail: "is absent, so the directories the service was installed against are not \
3176                     recorded"
3177                .to_string(),
3178        }),
3179    }
3180}
3181
3182// -- parsing helpers ---------------------------------------------------------
3183
3184/// Reads `key=value` lines out of one `[section]` of an INI-shaped document.
3185///
3186/// systemd's unit format and this module's Windows descriptor are both this
3187/// shape. A repeated key takes its last value, which is systemd's own rule for
3188/// every directive here.
3189fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
3190    let mut out = BTreeMap::new();
3191    let mut inside = false;
3192    for line in text.lines() {
3193        let line = line.trim();
3194        if line.starts_with('[') && line.ends_with(']') {
3195            inside = &line[1..line.len() - 1] == section;
3196            continue;
3197        }
3198        if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
3199            continue;
3200        }
3201        if let Some((key, value)) = line.split_once('=') {
3202            out.insert(key.trim().to_string(), value.trim().to_string());
3203        }
3204    }
3205    out
3206}
3207
3208/// The command line in a systemd `ExecStart=`, without the prefixes systemd
3209/// reads off its front (`-`, `@`, `:`, `+`, `!`, `!!`).
3210///
3211/// A drop-in may carry any of them, and left in place they become part of the
3212/// executable's path. With `@` the second word is the `argv[0]` handed to the
3213/// process rather than an argument, so it goes as well.
3214#[cfg(any(test, all(unix, not(target_os = "macos"))))]
3215fn systemd_exec_command_line(value: &str) -> String {
3216    let value = value.trim();
3217    let command = value.trim_start_matches(['-', '@', ':', '+', '!']);
3218    if !value[..value.len() - command.len()].contains('@') {
3219        return command.to_string();
3220    }
3221    let mut words = split_command_line(command);
3222    if words.len() > 1 {
3223        words.remove(1);
3224    }
3225    words
3226        .iter()
3227        .map(|word| quote_argument(word))
3228        .collect::<Vec<_>>()
3229        .join(" ")
3230}
3231
3232/// Parses a systemd time span such as `15`, `15s`, `500ms`, `1min 30s` or
3233/// `2h`. A bare number is seconds. `None` for `infinity`, an empty value, or
3234/// anything systemd would not accept either.
3235#[cfg(any(test, all(unix, not(target_os = "macos"))))]
3236fn systemd_timespan(value: &str) -> Option<Duration> {
3237    let mut rest = value.trim();
3238    if rest.is_empty() || rest == "infinity" {
3239        return None;
3240    }
3241    let mut seconds = 0f64;
3242    while !rest.is_empty() {
3243        let digits = rest
3244            .find(|c: char| !(c.is_ascii_digit() || c == '.'))
3245            .unwrap_or(rest.len());
3246        if digits == 0 {
3247            return None;
3248        }
3249        let number: f64 = rest[..digits].parse().ok()?;
3250        rest = rest[digits..].trim_start();
3251        let letters = rest
3252            .find(|c: char| !c.is_alphabetic())
3253            .unwrap_or(rest.len());
3254        let scale = match &rest[..letters] {
3255            "" | "s" | "sec" | "second" | "seconds" => 1.0,
3256            "us" | "usec" | "\u{3bc}s" | "\u{b5}s" => 1e-6,
3257            "ms" | "msec" => 1e-3,
3258            "m" | "min" | "minute" | "minutes" => 60.0,
3259            "h" | "hr" | "hour" | "hours" => 3_600.0,
3260            "d" | "day" | "days" => 86_400.0,
3261            "w" | "week" | "weeks" => 604_800.0,
3262            "M" | "month" | "months" => 2_629_800.0,
3263            "y" | "year" | "years" => 31_557_600.0,
3264            _ => return None,
3265        };
3266        seconds += number * scale;
3267        rest = rest[letters..].trim_start();
3268    }
3269    Duration::try_from_secs_f64(seconds).ok()
3270}
3271
3272/// Splits a whitespace-separated list that may contain arguments quoted by
3273/// [`quote_argument`].
3274fn split_quoted(value: &str) -> Vec<String> {
3275    let mut out = Vec::new();
3276    let mut rest = value.trim();
3277    while !rest.is_empty() {
3278        if rest.starts_with('"') {
3279            // `executable_from_command_line` already implements Windows' quoting
3280            // rules; reuse it rather than write a second, subtly different one.
3281            if let Some(parsed) = executable_from_command_line(rest) {
3282                out.push(parsed.to_string_lossy().into_owned());
3283            }
3284            // Advance past the closing quote.
3285            let mut depth = 0usize;
3286            let mut end = rest.len();
3287            for (index, c) in rest.char_indices() {
3288                match c {
3289                    '\\' => depth += 1,
3290                    '"' => {
3291                        if depth.is_multiple_of(2) && index > 0 {
3292                            end = index + 1;
3293                            break;
3294                        }
3295                        depth = 0;
3296                    }
3297                    _ => depth = 0,
3298                }
3299            }
3300            rest = rest[end..].trim_start();
3301        } else {
3302            let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
3303            out.push(rest[..end].to_string());
3304            rest = rest[end..].trim_start();
3305        }
3306    }
3307    out
3308}
3309
3310/// The text between the **first** `<tag>` and its `</tag>`, unescaped, for the
3311/// small, known documents this module renders. Not a general XML parser and
3312/// does not pretend to be one.
3313///
3314/// "First" matters for a document that repeats a tag: a Task Scheduler task has
3315/// an `<Enabled>` in its trigger *and* one in its settings, so a caller after
3316/// the second one passes the slice that starts at `<Settings>`.
3317///
3318/// `pub(crate)` rather than private because [`crate::wsl::task`] reads back a
3319/// second kind of Task Scheduler document; one reader for both is the same
3320/// argument [`xml_escape`] makes for one escaper.
3321pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
3322    let open = format!("<{tag}>");
3323    let close = format!("</{tag}>");
3324    let start = text.find(&open)? + open.len();
3325    let end = text[start..].find(&close)? + start;
3326    Some(xml_unescape(text[start..end].trim()))
3327}
3328
3329/// Whether Task Scheduler will launch a login task automatically.
3330///
3331/// Its `/Query /XML` output normalises away values that equal the schema
3332/// default. In particular, an enabled task commonly has no `<Enabled>` node at
3333/// all, so only an explicit `false` means disabled.
3334#[cfg(any(windows, test))]
3335fn windows_login_task_starts_automatically(document: &str) -> bool {
3336    document.contains("<LogonTrigger>")
3337        && xml_value(document, "Enabled").as_deref() != Some("false")
3338}
3339
3340/// The `<string>` that follows `<key>key</key>` in a property list.
3341fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
3342    let marker = format!("<key>{key}</key>");
3343    let start = text.find(&marker)? + marker.len();
3344    Some(text[start..].trim_start())
3345}
3346
3347pub fn plist_string_value(text: &str, key: &str) -> Option<String> {
3348    let rest = plist_value_after_key(text, key)?;
3349    if !rest.starts_with("<string>") {
3350        return None;
3351    }
3352    xml_value(rest, "string")
3353}
3354
3355fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
3356    let rest = plist_value_after_key(text, key)?;
3357    if rest.starts_with("<true/>") {
3358        Some(true)
3359    } else if rest.starts_with("<false/>") {
3360        Some(false)
3361    } else {
3362        None
3363    }
3364}
3365
3366// ---------------------------------------------------------------------------
3367// The install record
3368// ---------------------------------------------------------------------------
3369
3370/// The version of [`InstallRecord`] on disk.
3371///
3372/// Bumped when a record written by an older version can no longer be read.
3373/// `service status` reports an unreadable record as a problem with a remedy
3374/// rather than failing, because the remedy — uninstall and install again —
3375/// touches no configuration, no secret and no cache.
3376pub const RECORD_SCHEMA_VERSION: u32 = 1;
3377
3378/// What `service install` wrote down, and what `service status` reads back.
3379///
3380/// This is the *record* half of `05-infrastructure.md` item 6. The service
3381/// manager also knows the binary path, and the two are compared: a registration
3382/// whose command line no longer matches the record is a registration something
3383/// else has edited, and saying so is more useful than picking one of them to
3384/// believe.
3385///
3386/// It holds no credential. The nearest thing is the *location* of the secret
3387/// store, which `d2` publishes for `host show` to print, and even that is
3388/// derived rather than stored.
3389#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3390pub struct InstallRecord {
3391    /// [`RECORD_SCHEMA_VERSION`] at the time of writing.
3392    pub schema_version: u32,
3393    /// What the operating system calls the registration.
3394    pub service_name: String,
3395    /// Which service manager holds it.
3396    pub manager: String,
3397    /// Boot or login.
3398    pub start_mode: StartMode,
3399    /// The account it runs under.
3400    pub account: ServiceAccount,
3401    /// **The resolved absolute path of the binary at install time.** Item 6.
3402    pub binary: PathBuf,
3403    /// Where [`Self::binary`] was copied from, when it is a copy.
3404    ///
3405    /// `#[serde(default)]` rather than a schema bump: a record written before
3406    /// this existed is still readable, and reads as `None`. That is not a
3407    /// silent downgrade — `None` means the registration names a package
3408    /// manager's own file, which is the layout that cannot be upgraded while
3409    /// the service runs, and `service status` says so by name.
3410    #[serde(default)]
3411    pub source_binary: Option<PathBuf>,
3412    /// The arguments it was registered with.
3413    pub arguments: Vec<String>,
3414    /// The restart-on-failure delay.
3415    pub restart_delay_secs: u64,
3416    /// How long it must run before the failure count resets.
3417    pub restart_reset_secs: u64,
3418    /// The rotating diagnostic log's stem. Item 4.
3419    pub log_file: PathBuf,
3420    /// Whether the manager was asked not to start this by itself. Always
3421    /// `false` for a registration an operator made; see
3422    /// [`InstallRequest::started_on_demand`].
3423    ///
3424    /// `#[serde(default)]` so that a record missing the field is rejected by
3425    /// the schema check below, with its remedy, rather than by a parse error
3426    /// that names a field an operator has never heard of.
3427    #[serde(default)]
3428    pub starts_on_demand: bool,
3429    /// Where the platform's definition file was written, when there is one.
3430    pub definition_path: Option<PathBuf>,
3431    /// The host paths the registration made inaccessible. See
3432    /// [`ProtectedPaths`]; empty in a record written before they existed.
3433    #[serde(default)]
3434    pub protected_paths: Vec<PathBuf>,
3435    /// When the registration was made.
3436    pub installed_at: DateTime<Utc>,
3437    /// Which build of the product made it.
3438    pub installed_by_version: String,
3439    // A TOML table has to follow every scalar at its level, so this field is
3440    // last by necessity rather than by taste.
3441    /// The four directories the registration was installed against. Item 2's
3442    /// *"configured cache and runtime directories"*.
3443    pub directories: ServiceDirectories,
3444}
3445
3446impl InstallRecord {
3447    /// Where the record lives, given this host's directories.
3448    #[must_use]
3449    pub fn path(paths: &AppPaths) -> PathBuf {
3450        paths.config_dir().join(RECORD_FILE)
3451    }
3452
3453    /// Builds the record for a plan that has just been applied.
3454    #[must_use]
3455    pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
3456        Self {
3457            schema_version: RECORD_SCHEMA_VERSION,
3458            service_name: plan.identity().name().to_string(),
3459            manager: definition.kind().manager().to_string(),
3460            start_mode: plan.start_mode(),
3461            account: plan.account().clone(),
3462            binary: plan.binary().to_path_buf(),
3463            arguments: plan
3464                .arguments()
3465                .iter()
3466                .map(|argument| argument.to_string_lossy().into_owned())
3467                .collect(),
3468            restart_delay_secs: plan.restart().delay().as_secs(),
3469            restart_reset_secs: plan.restart().reset_after().as_secs(),
3470            starts_on_demand: plan.is_on_demand(),
3471            source_binary: plan.source_binary().map(Path::to_path_buf),
3472            log_file: plan.directories().log_file(),
3473            definition_path: definition.install_path().map(Path::to_path_buf),
3474            protected_paths: plan.protected_paths().to_vec(),
3475            installed_at: at,
3476            installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
3477            directories: plan.directories().clone(),
3478        }
3479    }
3480
3481    /// The plan this record describes, as this build would register it.
3482    ///
3483    /// The directories, binary, arguments and restart policy are the recorded
3484    /// ones; everything else -- hardening, the secret store's guard -- is what
3485    /// this build renders. That difference is the point: it is how a
3486    /// registration written by an older build is compared against, and
3487    /// repaired to, the current definition.
3488    #[must_use]
3489    pub fn plan(&self, identity: ServiceIdentity) -> InstallPlan {
3490        let plan = InstallPlan::unchecked(
3491            identity,
3492            self.start_mode,
3493            self.binary.clone(),
3494            self.directories.clone(),
3495        )
3496        .with_arguments(self.arguments.clone())
3497        .with_restart(self.restart())
3498        .with_protected_paths(self.protected_paths.clone());
3499        let plan = if self.starts_on_demand {
3500            plan.started_on_demand()
3501        } else {
3502            plan
3503        };
3504        match crate::secrets::PlatformSecretStore::for_start_mode(self.start_mode) {
3505            Ok(store) => plan.with_secret_guard(store.guard()),
3506            Err(_) => plan,
3507        }
3508    }
3509
3510    /// The restart policy this record describes, or [`RestartPolicy::default`]
3511    /// when the recorded numbers are outside the supported range — which can
3512    /// only happen to a record something else has edited.
3513    #[must_use]
3514    pub fn restart(&self) -> RestartPolicy {
3515        RestartPolicy::new(
3516            Duration::from_secs(self.restart_delay_secs),
3517            Duration::from_secs(self.restart_reset_secs),
3518        )
3519        .unwrap_or_default()
3520    }
3521
3522    /// Reads the record, or reports that there is none.
3523    ///
3524    /// `Ok(None)` means no registration was made by this product on this host.
3525    ///
3526    /// # Errors
3527    ///
3528    /// [`ServiceError::Record`] when the file exists and cannot be read,
3529    /// [`ServiceError::RecordNotPermitted`] when it exists and this account may
3530    /// not read it, and [`ServiceError::RecordUnreadable`] when it is not a
3531    /// record this version understands.
3532    pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
3533        let path = Self::path(paths);
3534        let text = match std::fs::read_to_string(&path) {
3535            Ok(text) => text,
3536            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3537            // Told apart from every other read failure because `status` carries
3538            // on through this one alone. See `ServiceError::RecordNotPermitted`.
3539            Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
3540                return Err(ServiceError::RecordNotPermitted {
3541                    path,
3542                    detail: error.to_string(),
3543                });
3544            }
3545            Err(error) => {
3546                return Err(ServiceError::Record {
3547                    operation: "read",
3548                    path,
3549                    detail: error.to_string(),
3550                });
3551            }
3552        };
3553        let record: Self =
3554            toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
3555                path: path.clone(),
3556                detail: error.to_string(),
3557            })?;
3558        if record.schema_version != RECORD_SCHEMA_VERSION {
3559            return Err(ServiceError::RecordUnreadable {
3560                path,
3561                detail: format!(
3562                    "it declares schema version {} and this build reads version {}",
3563                    record.schema_version, RECORD_SCHEMA_VERSION
3564                ),
3565            });
3566        }
3567        Ok(Some(record))
3568    }
3569
3570    /// Writes the record, replacing whatever was there.
3571    ///
3572    /// # Errors
3573    ///
3574    /// [`ServiceError::Record`].
3575    pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
3576        use std::io::Write as _;
3577
3578        let path = Self::path(paths);
3579        let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
3580            operation: "encode",
3581            path: path.clone(),
3582            detail: error.to_string(),
3583        })?;
3584        if let Some(parent) = path.parent() {
3585            std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
3586                operation: "write",
3587                path: path.clone(),
3588                detail: error.to_string(),
3589            })?;
3590        }
3591        let parent = path.parent().ok_or_else(|| ServiceError::Record {
3592            operation: "write",
3593            path: path.clone(),
3594            detail: "the record path has no parent directory".to_string(),
3595        })?;
3596        let mut temporary =
3597            tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
3598                operation: "write",
3599                path: path.clone(),
3600                detail: error.to_string(),
3601            })?;
3602        temporary
3603            .write_all(text.as_bytes())
3604            .and_then(|()| temporary.as_file().sync_all())
3605            .map_err(|error| ServiceError::Record {
3606                operation: "write",
3607                path: path.clone(),
3608                detail: error.to_string(),
3609            })?;
3610        // ------------------------------------------------------------------
3611        // THE RECORD IS READABLE BY THE ACCOUNT WHOSE DIRECTORY IT IS IN.
3612        // ------------------------------------------------------------------
3613        // `NamedTempFile` creates at `0600`, which is right for a temporary
3614        // file and wrong for this one. A boot-mode `service install` runs under
3615        // `sudo`, so `0600` made the record `root`-owned and unreadable by the
3616        // operator — and `service status`, which asks no privilege of anybody,
3617        // failed on their own host with `Permission denied` and a remedy that
3618        // named the command that had just failed.
3619        //
3620        // `0644` does not widen anything: `RECORD_FILE`'s own documentation is
3621        // that this is *"non-secret TOML"* holding no credential, and
3622        // `AppPaths::create_all` puts it in a `0700` directory, so no other
3623        // local account can reach it whatever its own mode says. It is the same
3624        // arrangement the rotating diagnostics already have.
3625        #[cfg(unix)]
3626        {
3627            use std::os::unix::fs::PermissionsExt as _;
3628
3629            temporary
3630                .as_file()
3631                .set_permissions(std::fs::Permissions::from_mode(0o644))
3632                .map_err(|error| ServiceError::Record {
3633                    operation: "write",
3634                    path: path.clone(),
3635                    detail: error.to_string(),
3636                })?;
3637        }
3638        temporary
3639            .persist(&path)
3640            .map(|_| ())
3641            .map_err(|error| ServiceError::Record {
3642                operation: "write",
3643                path,
3644                detail: error.error.to_string(),
3645            })
3646    }
3647
3648    /// Removes the record. Returns whether there was one.
3649    ///
3650    /// # Errors
3651    ///
3652    /// [`ServiceError::Record`].
3653    pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
3654        let path = Self::path(paths);
3655        match std::fs::remove_file(&path) {
3656            Ok(()) => Ok(true),
3657            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
3658            Err(error) => Err(ServiceError::Record {
3659                operation: "remove",
3660                path,
3661                detail: error.to_string(),
3662            }),
3663        }
3664    }
3665}
3666
3667// ---------------------------------------------------------------------------
3668// The last successful GitHub contact
3669// ---------------------------------------------------------------------------
3670
3671/// The heartbeat file's own schema version.
3672const CONTACT_SCHEMA_VERSION: u32 = 1;
3673
3674#[derive(Debug, Clone, Serialize, Deserialize)]
3675struct ContactRecord {
3676    schema_version: u32,
3677    last_success: DateTime<Utc>,
3678}
3679
3680/// Records that GitHub was reached successfully, for `service status` to
3681/// report.
3682///
3683/// # The contract, for `f3` and for the agent
3684///
3685/// Journey 5 step 4 requires `service status` to report *"the last successful
3686/// GitHub contact"*, and `service status` runs in the operator's terminal while
3687/// the daemon runs in a service. They share no memory, so the fact has to be on
3688/// disk, and this is the file. **The daemon calls this after each successful
3689/// GitHub call; nothing else writes it.**
3690///
3691/// It is a single timestamp and deliberately not a log: an operator asking this
3692/// question wants to know whether the agent is alive and reaching GitHub *now*,
3693/// and a value that is minutes old answers it. Writing it is a whole-file
3694/// replace through a temporary in the same directory, so a status command can
3695/// never read a half-written timestamp.
3696///
3697/// # Errors
3698///
3699/// [`ServiceError::Record`] when `state/` cannot be written.
3700pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3701    write_state_record(
3702        &contact_path(paths),
3703        &ContactRecord {
3704            schema_version: CONTACT_SCHEMA_VERSION,
3705            last_success: at,
3706        },
3707    )
3708}
3709
3710/// Reads the last successful GitHub contact, or reports that none was recorded.
3711///
3712/// `Ok(None)` is the honest answer on a host whose agent has never run, and it
3713/// is what `service status` prints rather than a zero timestamp.
3714///
3715/// # Errors
3716///
3717/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3718/// A malformed heartbeat is reported rather than treated as absence: absence
3719/// means *"the agent has never reached GitHub"*, and reporting a parse failure
3720/// as that would be a wrong answer to the question Journey 5 asks.
3721pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3722    Ok(read_state_record::<ContactRecord>(&contact_path(paths))?.map(|record| record.last_success))
3723}
3724
3725/// Where the heartbeat lives.
3726#[must_use]
3727pub fn contact_path(paths: &AppPaths) -> PathBuf {
3728    paths.state_dir().join(CONTACT_FILE)
3729}
3730
3731/// Writes a small TOML record under `state/`, through a temporary in the same
3732/// directory and a rename, so a status command never reads half of one.
3733pub(crate) fn write_state_record(path: &Path, record: &impl Serialize) -> Result<(), ServiceError> {
3734    let failed = |detail: String| ServiceError::Record {
3735        operation: "write",
3736        path: path.to_path_buf(),
3737        detail,
3738    };
3739    let text = toml::to_string_pretty(record).map_err(|error| failed(error.to_string()))?;
3740    let directory = path.parent().unwrap_or_else(|| Path::new("."));
3741    std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3742    let temporary = path.with_extension("toml.new");
3743    std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3744    std::fs::rename(&temporary, path).map_err(|error| failed(error.to_string()))
3745}
3746
3747/// Reads a record [`write_state_record`] wrote. `Ok(None)` when there is none;
3748/// one that exists and does not parse is an error, never absence.
3749pub(crate) fn read_state_record<T: serde::de::DeserializeOwned>(
3750    path: &Path,
3751) -> Result<Option<T>, ServiceError> {
3752    let failed = |detail: String| ServiceError::Record {
3753        operation: "read",
3754        path: path.to_path_buf(),
3755        detail,
3756    };
3757    let text = match std::fs::read_to_string(path) {
3758        Ok(text) => text,
3759        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3760        Err(error) => return Err(failed(error.to_string())),
3761    };
3762    toml::from_str(&text)
3763        .map(Some)
3764        .map_err(|error| failed(error.to_string()))
3765}
3766
3767/// Removes a record; one that is not there is already removed.
3768pub(crate) fn remove_state_record(path: &Path) -> Result<(), ServiceError> {
3769    match std::fs::remove_file(path) {
3770        Ok(()) => Ok(()),
3771        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3772        Err(error) => Err(ServiceError::Record {
3773            operation: "remove",
3774            path: path.to_path_buf(),
3775            detail: error.to_string(),
3776        }),
3777    }
3778}
3779
3780// ---------------------------------------------------------------------------
3781// The credential GitHub rejected
3782// ---------------------------------------------------------------------------
3783
3784#[derive(Debug, Serialize, Deserialize)]
3785#[serde(deny_unknown_fields)]
3786struct CredentialRejectionRecord {
3787    schema_version: u32,
3788    rejected_since: DateTime<Utc>,
3789}
3790
3791/// Records that GitHub rejected the daemon's credential, keeping the first
3792/// moment of an ongoing rejection.
3793///
3794/// # Why a file of its own
3795///
3796/// A rejected credential used to be visible only in the daemon's log: the
3797/// contact heartbeat simply stopped moving, `service status` still printed
3798/// `healthy`, and the TUI -- which authenticates with its own, perhaps freshly
3799/// renewed, copy -- showed nothing at all while the service started no runner
3800/// for hours. Separate from [`CONTACT_FILE`] so that an older build reading
3801/// that file is not handed a field it refuses.
3802///
3803/// # Errors
3804///
3805/// [`ServiceError::Record`] when `state/` cannot be written.
3806pub fn record_github_credential_rejected(
3807    paths: &AppPaths,
3808    at: DateTime<Utc>,
3809) -> Result<(), ServiceError> {
3810    if github_credential_rejected_since(paths)
3811        .ok()
3812        .flatten()
3813        .is_some()
3814    {
3815        return Ok(());
3816    }
3817    write_state_record(
3818        &credential_rejection_path(paths),
3819        &CredentialRejectionRecord {
3820            schema_version: CONTACT_SCHEMA_VERSION,
3821            rejected_since: at,
3822        },
3823    )
3824}
3825
3826/// Clears a recorded rejection once GitHub accepts the credential again.
3827///
3828/// # Errors
3829///
3830/// [`ServiceError::Record`] when the record exists and cannot be removed.
3831pub fn clear_github_credential_rejection(paths: &AppPaths) -> Result<(), ServiceError> {
3832    remove_state_record(&credential_rejection_path(paths))
3833}
3834
3835/// Since when GitHub has been rejecting the daemon's credential, if it is.
3836///
3837/// # Errors
3838///
3839/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3840pub fn github_credential_rejected_since(
3841    paths: &AppPaths,
3842) -> Result<Option<DateTime<Utc>>, ServiceError> {
3843    Ok(
3844        read_state_record::<CredentialRejectionRecord>(&credential_rejection_path(paths))?
3845            .map(|record| record.rejected_since),
3846    )
3847}
3848
3849/// Where the rejection record lives.
3850#[must_use]
3851pub fn credential_rejection_path(paths: &AppPaths) -> PathBuf {
3852    paths.state_dir().join(CREDENTIAL_REJECTION_FILE)
3853}
3854
3855#[derive(Debug, Serialize, Deserialize)]
3856#[serde(deny_unknown_fields)]
3857struct CredentialUnreadableRecord {
3858    schema_version: u32,
3859    unreadable_since: DateTime<Utc>,
3860}
3861
3862/// Records that the daemon could not read its stored credential, keeping the
3863/// first moment of an ongoing failure.
3864///
3865/// # Why a file of its own
3866///
3867/// A daemon that cannot read its credential exits, and the service manager
3868/// starts it again a few seconds later, for ever. `service status` reads the
3869/// registration, which is fine, and the heartbeat, which merely stops moving --
3870/// so it said `healthy` while a macOS host that had just been upgraded was
3871/// locked out of its own keychain item and started no runner. The reason was
3872/// only in the daemon's log. The daemon is the one process whose view of the
3873/// store matters, and it shares no memory with `service status`, so it says
3874/// so on disk; [`clear_credential_unreadable`] removes it as soon as a read
3875/// succeeds.
3876///
3877/// # Errors
3878///
3879/// [`ServiceError::Record`] when `state/` cannot be written.
3880pub fn record_credential_unreadable(
3881    paths: &AppPaths,
3882    at: DateTime<Utc>,
3883) -> Result<(), ServiceError> {
3884    if credential_unreadable_since(paths).ok().flatten().is_some() {
3885        return Ok(());
3886    }
3887    write_state_record(
3888        &credential_unreadable_path(paths),
3889        &CredentialUnreadableRecord {
3890            schema_version: CONTACT_SCHEMA_VERSION,
3891            unreadable_since: at,
3892        },
3893    )
3894}
3895
3896/// Clears a recorded failure once the daemon reads its credential again.
3897///
3898/// # Errors
3899///
3900/// [`ServiceError::Record`] when the record exists and cannot be removed.
3901pub fn clear_credential_unreadable(paths: &AppPaths) -> Result<(), ServiceError> {
3902    remove_state_record(&credential_unreadable_path(paths))
3903}
3904
3905/// Since when the daemon has been unable to read its stored credential, if it
3906/// has.
3907///
3908/// # Errors
3909///
3910/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3911pub fn credential_unreadable_since(
3912    paths: &AppPaths,
3913) -> Result<Option<DateTime<Utc>>, ServiceError> {
3914    Ok(
3915        read_state_record::<CredentialUnreadableRecord>(&credential_unreadable_path(paths))?
3916            .map(|record| record.unreadable_since),
3917    )
3918}
3919
3920/// Where the unreadable-credential record lives.
3921#[must_use]
3922pub fn credential_unreadable_path(paths: &AppPaths) -> PathBuf {
3923    paths.state_dir().join(CREDENTIAL_UNREADABLE_FILE)
3924}
3925
3926/// How to sign this host in again for `mode`: the exact command, in
3927/// backticks, and where to run it when that matters.
3928///
3929/// A machine-scoped store needs privilege: `sudo` on Unix, an elevated
3930/// terminal on Windows. A login-mode store on macOS is the login keychain,
3931/// which is unlocked for the desktop session only, so an SSH session cannot
3932/// write it.
3933#[must_use]
3934pub fn sign_in_instruction(mode: StartMode) -> String {
3935    let (command, place) = match mode {
3936        StartMode::Boot if cfg!(unix) => (sudo_command("auth login --start-at boot"), ""),
3937        StartMode::Boot => (
3938            "runner-manager auth login --start-at boot".to_owned(),
3939            " from an elevated terminal",
3940        ),
3941        StartMode::Login => (
3942            "runner-manager auth login --start-at login".to_owned(),
3943            MACOS_LOGIN_PLACE,
3944        ),
3945    };
3946    format!("`{command}`{place}")
3947}
3948
3949/// `sudo`, then this program, then `arguments`: a command an operator can
3950/// paste.
3951///
3952/// `sudo` searches only its own `secure_path`, which an npm or nvm
3953/// installation is not on, and the npm command is a Node shim that root's
3954/// `PATH` could not run either. So the command names this executable -- the
3955/// native binary, even behind that shim -- unless it already lives on
3956/// `secure_path`, where the bare name reads better and works.
3957#[must_use]
3958pub fn sudo_command(arguments: &str) -> String {
3959    // A test binary is not the product; its own path would only make every
3960    // remedy a test compares depend on where the build directory is.
3961    let executable = if cfg!(test) {
3962        None
3963    } else {
3964        std::env::current_exe().ok()
3965    };
3966    format!("sudo {} {arguments}", sudo_program(executable.as_deref()))
3967}
3968
3969/// How a `sudo` command names `executable`. See [`sudo_command`].
3970fn sudo_program(executable: Option<&Path>) -> String {
3971    /// Ubuntu's and Debian's `secure_path`, which other distributions'
3972    /// defaults are a subset of.
3973    const SECURE_PATH: [&str; 7] = [
3974        "/usr/local/sbin",
3975        "/usr/local/bin",
3976        "/usr/sbin",
3977        "/usr/bin",
3978        "/sbin",
3979        "/bin",
3980        "/snap/bin",
3981    ];
3982    let Some(executable) = executable else {
3983        return "runner-manager".to_owned();
3984    };
3985    if executable
3986        .parent()
3987        .is_some_and(|directory| SECURE_PATH.iter().any(|path| directory == Path::new(path)))
3988    {
3989        return "runner-manager".to_owned();
3990    }
3991    let text = executable.to_string_lossy();
3992    if text
3993        .chars()
3994        .all(|c| c.is_ascii_alphanumeric() || "/._-+@".contains(c))
3995    {
3996        text.into_owned()
3997    } else {
3998        format!("'{}'", text.replace('\'', "'\\''"))
3999    }
4000}
4001
4002/// Where a login-mode sign-in has to happen on macOS, as a phrase to follow
4003/// the command; nothing elsewhere.
4004pub const MACOS_LOGIN_PLACE: &str = if cfg!(target_os = "macos") {
4005    " in a Terminal in this Mac's desktop session (an SSH session cannot unlock the login \
4006     keychain)"
4007} else {
4008    ""
4009};
4010
4011// ---------------------------------------------------------------------------
4012// A definition written by an older build
4013// ---------------------------------------------------------------------------
4014
4015/// An installed service definition that differs from what this build renders
4016/// for the same recorded installation.
4017///
4018/// # Why this exists
4019///
4020/// An upgrade replaces the binary a registration runs and nothing else. A
4021/// systemd unit written by 0.4.7 still carried `LoadCredential=`, which hands
4022/// the daemon a copy of the credential frozen at service start, and lacked
4023/// write access to the credential store. Every release after the fix kept
4024/// running under that unit: the daemon could neither renew the token nor see
4025/// one renewed by anybody else, and stopped starting runners the moment the
4026/// frozen access token expired.
4027#[derive(Debug, Clone, PartialEq, Eq)]
4028pub struct DefinitionDrift {
4029    /// The definition file on disk.
4030    pub path: PathBuf,
4031    /// The build that wrote it, as its install record says.
4032    pub installed_by_version: String,
4033    /// What this build renders in its place.
4034    pub rendered: String,
4035    /// The start mode it was installed for.
4036    pub start_mode: StartMode,
4037    /// Which manager reads the definition.
4038    pub manager: DefinitionKind,
4039    /// The directories the rendered definition lets the service write. A
4040    /// systemd unit fails before its first instruction (`226/NAMESPACE`) when
4041    /// one it lists is missing, or is under a home tree its
4042    /// `ProtectHome=tmpfs` hides without a `BindPaths=` for it. So whoever
4043    /// writes `rendered` creates them first: a host an older build installed
4044    /// may never have made one.
4045    pub writable: Vec<PathBuf>,
4046}
4047
4048impl DefinitionDrift {
4049    /// Creates every missing directory in [`Self::writable`]: the directory
4050    /// itself `0700`, its parents (`~/.local` and its like, not this
4051    /// program's to tighten) as the umask says.
4052    ///
4053    /// # Errors
4054    ///
4055    /// The first directory that could not be created.
4056    pub fn create_writable(&self) -> std::io::Result<()> {
4057        create_writable_directories(&self.writable)
4058    }
4059}
4060
4061/// Creates every missing directory in `directories`: the directory itself
4062/// `0700`, whatever the umask, and its parents as the umask says.
4063///
4064/// # Errors
4065///
4066/// The first that could not be created, or a path that holds something
4067/// other than a directory: a file or a dangling link there fails a unit as
4068/// surely as nothing at all.
4069pub fn create_writable_directories(directories: &[PathBuf]) -> std::io::Result<()> {
4070    for directory in directories {
4071        if let Some(parent) = directory.parent() {
4072            std::fs::create_dir_all(parent)?;
4073        }
4074        match std::fs::create_dir(directory) {
4075            Ok(()) => {
4076                #[cfg(unix)]
4077                {
4078                    use std::os::unix::fs::PermissionsExt as _;
4079                    std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700))?;
4080                }
4081            }
4082            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
4083                if !directory.is_dir() {
4084                    return Err(std::io::Error::new(
4085                        std::io::ErrorKind::AlreadyExists,
4086                        format!("{} exists and is not a directory", directory.display()),
4087                    ));
4088                }
4089            }
4090            Err(error) => return Err(error),
4091        }
4092    }
4093    Ok(())
4094}
4095
4096/// Compares the installed definition against this build's rendering.
4097///
4098/// A systemd unit is compared byte for byte. A launchd plist is compared on
4099/// `ProcessType` alone (see [`launchd_definition_drift`]). `None` for every
4100/// other manager, for a definition that cannot be read, and for one that
4101/// already matches. The file is read through `controls`, so a double never
4102/// compares against a real host's definition.
4103fn definition_drift(
4104    controls: &dyn ControlFactory,
4105    identity: &ServiceIdentity,
4106    record: &InstallRecord,
4107) -> Option<DefinitionDrift> {
4108    let compare = match host_definition_kind(record.start_mode) {
4109        DefinitionKind::SystemdUnit => systemd_definition_drift,
4110        DefinitionKind::LaunchdPlist => launchd_definition_drift,
4111        DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => return None,
4112    };
4113    let path = record.definition_path.clone()?;
4114    let installed = controls.installed_definition(&path)?;
4115    compare(identity, record, path, &installed)
4116}
4117
4118/// A plist whose `ProcessType` is not [`LAUNCHD_PROCESS_TYPE`], with the plist
4119/// this build renders in its place.
4120///
4121/// Only that one key, unlike the systemd comparison. The daemon repairs this
4122/// drift by itself (`daemon.rs`, `converge_launchd_definition`), so a
4123/// whole-file comparison would make it rewrite and reload over every edit an
4124/// operator made to the plist. A plist that does carry the wrong value is
4125/// replaced whole, as `service install` would replace it.
4126fn launchd_definition_drift(
4127    identity: &ServiceIdentity,
4128    record: &InstallRecord,
4129    path: PathBuf,
4130    installed: &str,
4131) -> Option<DefinitionDrift> {
4132    (plist_string_value(installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE)).then(
4133        || DefinitionDrift {
4134            path,
4135            installed_by_version: record.installed_by_version.clone(),
4136            rendered: launchd_plist(&record.plan(identity.clone())),
4137            start_mode: record.start_mode,
4138            manager: DefinitionKind::LaunchdPlist,
4139            writable: Vec::new(),
4140        },
4141    )
4142}
4143
4144fn systemd_definition_drift(
4145    identity: &ServiceIdentity,
4146    record: &InstallRecord,
4147    path: PathBuf,
4148    installed: &str,
4149) -> Option<DefinitionDrift> {
4150    let plan = record.plan(identity.clone());
4151    let rendered = systemd_unit(&plan);
4152    (installed != rendered).then(|| DefinitionDrift {
4153        path,
4154        installed_by_version: record.installed_by_version.clone(),
4155        rendered,
4156        start_mode: record.start_mode,
4157        manager: DefinitionKind::SystemdUnit,
4158        writable: permitted_paths(DefinitionKind::SystemdUnit, &plan)
4159            .into_iter()
4160            .map(PathBuf::from)
4161            .collect(),
4162    })
4163}
4164
4165// ---------------------------------------------------------------------------
4166// The last runner-root refusal
4167// ---------------------------------------------------------------------------
4168
4169const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
4170
4171#[derive(Debug, Clone, Serialize, Deserialize)]
4172struct RootRefusalFile {
4173    schema_version: u32,
4174    /// Keyed by policy, because the fact is per-policy and not per-host.
4175    #[serde(default)]
4176    refusals: BTreeMap<String, RootRefusalEntry>,
4177}
4178
4179#[derive(Debug, Clone, Serialize, Deserialize)]
4180struct RootRefusalEntry {
4181    at: DateTime<Utc>,
4182    kind: String,
4183    root: String,
4184    detail: String,
4185}
4186
4187/// One policy's last runner-root refusal, as `service status` reports it.
4188#[derive(Debug, Clone, PartialEq, Eq)]
4189pub struct RunnerRootRefusal {
4190    /// The policy that placed no runner.
4191    pub policy: String,
4192    /// When it was last refused.
4193    pub at: DateTime<Utc>,
4194    /// The closed-vocabulary cause, as `RunnerRootError::kind` names it.
4195    pub kind: String,
4196    /// The root it was refused.
4197    pub root: String,
4198    /// The refusal in full, including the remediation. Not redacted: this file
4199    /// is read by a command in the operator's terminal, not by the log sink.
4200    pub detail: String,
4201}
4202
4203/// Records that one policy could not use its runner root.
4204///
4205/// # The contract, and why a file
4206///
4207/// A launch the runner root refuses fails before any attempt row exists, so the
4208/// journal cannot carry it and no screen can show it. The daemon's own log
4209/// cannot either: it redacts every field it does not allow-list and scrubs
4210/// anything path-shaped out of the ones it does, which is most of a
4211/// `RunnerRootError`. So the sentence an operator needs — the directory and the
4212/// remediation, verbatim — has nowhere to go, and the failure reads
4213/// `runner_start_failed reason=other` once per poll for as long as it lasts.
4214/// That is not hypothetical: it cost three hours.
4215///
4216/// **Keyed by policy, and that is load-bearing.** A host runs several policies
4217/// and they do not share a fate: `allocate_persistent_slot` tolerates an
4218/// unresolvable host default, so a repository with its own persistent root
4219/// places runners happily while an ephemeral policy on a withheld volume places
4220/// none. A single host-wide slot would have the working policy clear the broken
4221/// one's record on the same reconcile pass, and `service status` would report a
4222/// healthy host that had started zero runners for an entire target.
4223///
4224/// **The daemon writes this when a root refuses a launch and clears the policy's
4225/// entry when one succeeds; nothing else writes it.** It keeps the last refusal
4226/// per policy rather than a history, for the reason [`record_github_contact`] is
4227/// a single timestamp: the operator is asking whether runners can be placed
4228/// *now*.
4229///
4230/// Written whole through a temporary in the same directory, so a status command
4231/// cannot read half a record.
4232///
4233/// # Errors
4234///
4235/// [`ServiceError::Record`] when `state/` cannot be read or written.
4236pub fn record_runner_root_refusal(
4237    paths: &AppPaths,
4238    policy: &str,
4239    at: DateTime<Utc>,
4240    kind: &str,
4241    root: &str,
4242    detail: &str,
4243) -> Result<(), ServiceError> {
4244    let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
4245        schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
4246        refusals: BTreeMap::new(),
4247    });
4248    file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
4249    file.refusals.insert(
4250        policy.to_owned(),
4251        RootRefusalEntry {
4252            at,
4253            kind: kind.to_owned(),
4254            root: root.to_owned(),
4255            detail: detail.to_owned(),
4256        },
4257    );
4258    write_refusal_file(paths, &file)
4259}
4260
4261/// Clears one policy's entry, because that policy placed a runner.
4262///
4263/// Removes the file once the last entry goes, so a host that is working leaves
4264/// nothing behind for a later `service status` to report. A policy with no entry
4265/// is not an error: this is called on every successful placement, and almost
4266/// every one of those follows another success.
4267///
4268/// # Errors
4269///
4270/// [`ServiceError::Record`] when `state/` cannot be read or written.
4271pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
4272    let Some(mut file) = read_refusal_file(paths)? else {
4273        return Ok(());
4274    };
4275    if file.refusals.remove(policy).is_none() {
4276        return Ok(());
4277    }
4278    if file.refusals.is_empty() {
4279        let path = root_refusal_path(paths);
4280        return match std::fs::remove_file(&path) {
4281            Ok(()) => Ok(()),
4282            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
4283            Err(error) => Err(ServiceError::Record {
4284                operation: "remove",
4285                path,
4286                detail: error.to_string(),
4287            }),
4288        };
4289    }
4290    write_refusal_file(paths, &file)
4291}
4292
4293/// Every policy whose runner root refused it and has not since succeeded.
4294///
4295/// Ordered by policy, so two runs of `service status` on an unchanged host
4296/// print the same thing in the same order.
4297///
4298/// # Errors
4299///
4300/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
4301/// Reported rather than treated as absence, for the reason
4302/// [`last_github_contact`] gives: absence means "every policy is placing
4303/// runners", and answering a parse failure with that would be a wrong answer.
4304pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
4305    Ok(read_refusal_file(paths)?
4306        .map(|file| {
4307            file.refusals
4308                .into_iter()
4309                .map(|(policy, entry)| RunnerRootRefusal {
4310                    policy,
4311                    at: entry.at,
4312                    kind: entry.kind,
4313                    root: entry.root,
4314                    detail: entry.detail,
4315                })
4316                .collect()
4317        })
4318        .unwrap_or_default())
4319}
4320
4321fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
4322    read_state_record(&root_refusal_path(paths))
4323}
4324
4325fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
4326    write_state_record(&root_refusal_path(paths), file)
4327}
4328
4329/// Where the refusals live.
4330#[must_use]
4331pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
4332    paths.state_dir().join(ROOT_REFUSAL_FILE)
4333}
4334
4335// ---------------------------------------------------------------------------
4336// The recorded binary path
4337// ---------------------------------------------------------------------------
4338
4339/// What became of the absolute path `install` recorded.
4340///
4341/// `05-infrastructure.md` item 6 in one type. Three of the four variants are
4342/// errors, and `service status` reports them as errors rather than as health —
4343/// which is the whole point, because the npm case produces a service that looks
4344/// installed, is registered, and cannot start.
4345#[derive(Debug, Clone, PartialEq, Eq)]
4346pub enum BinaryPath {
4347    /// The recorded path is a file, and the service manager still starts it.
4348    Current {
4349        /// The path.
4350        path: PathBuf,
4351    },
4352    /// **Nothing is at the recorded path.**
4353    ///
4354    /// This is the npm upgrade: `npm i -g @ivan-murzak/runner-manager` puts the binary under
4355    /// the active Node installation's global prefix, and switching Node versions
4356    /// with `nvm`, `fnm`, or `volta` moves that prefix. The service is still
4357    /// registered, still set to start at boot, and starts nothing.
4358    Missing {
4359        /// The path the record names.
4360        recorded: PathBuf,
4361    },
4362    /// Something is at the recorded path, but it is not a file the service
4363    /// manager could start.
4364    NotExecutable {
4365        /// The path the record names.
4366        recorded: PathBuf,
4367        /// What is there instead.
4368        detail: String,
4369    },
4370    /// The record and the service manager name different binaries.
4371    Diverged {
4372        /// What the record says.
4373        recorded: PathBuf,
4374        /// What the service manager is registered to start.
4375        registered: PathBuf,
4376    },
4377}
4378
4379impl BinaryPath {
4380    /// Whether this is a state `service status` must report as an error.
4381    #[must_use]
4382    pub const fn is_error(&self) -> bool {
4383        !matches!(self, Self::Current { .. })
4384    }
4385
4386    /// The path the record names, whatever state it is in.
4387    #[must_use]
4388    pub fn recorded(&self) -> &Path {
4389        match self {
4390            Self::Current { path } => path,
4391            Self::Missing { recorded }
4392            | Self::NotExecutable { recorded, .. }
4393            | Self::Diverged { recorded, .. } => recorded,
4394        }
4395    }
4396}
4397
4398impl fmt::Display for BinaryPath {
4399    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4400        match self {
4401            Self::Current { path } => write!(f, "{}", path.display()),
4402            Self::Missing { recorded } => write!(
4403                f,
4404                "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
4405                 package manager that moved the binary is the usual cause; an `npm i -g` \
4406                 installation moves with the active Node version. Run `service install` again \
4407                 from the binary that is now installed.",
4408                recorded.display()
4409            ),
4410            Self::NotExecutable { recorded, detail } => write!(
4411                f,
4412                "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
4413                 from the installed binary.",
4414                recorded.display()
4415            ),
4416            Self::Diverged {
4417                recorded,
4418                registered,
4419            } => write!(
4420                f,
4421                "{} -- STALE: the service manager is registered to start {} instead. Something \
4422                 has edited the registration since it was installed. Run `service uninstall` and \
4423                 `service install`; neither touches configuration, secrets, or the cache.",
4424                recorded.display(),
4425                registered.display()
4426            ),
4427        }
4428    }
4429}
4430
4431/// Decides what became of a recorded path.
4432///
4433/// `registered` is what the service manager says it will start, when the
4434/// platform can be asked. Order matters and is deliberate: **absence is checked
4435/// first**, because a missing binary is the failure item 6 exists for and an
4436/// operator hearing "the registration disagrees with the record" about a file
4437/// that is not there would be sent to the wrong problem.
4438#[must_use]
4439pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
4440    match std::fs::metadata(recorded) {
4441        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
4442            return BinaryPath::Missing {
4443                recorded: recorded.to_path_buf(),
4444            };
4445        }
4446        Err(error) => {
4447            return BinaryPath::NotExecutable {
4448                recorded: recorded.to_path_buf(),
4449                detail: format!("it cannot be inspected ({error})"),
4450            };
4451        }
4452        Ok(metadata) if !metadata.is_file() => {
4453            return BinaryPath::NotExecutable {
4454                recorded: recorded.to_path_buf(),
4455                detail: "what is there is not a file".to_string(),
4456            };
4457        }
4458        Ok(_) => {}
4459    }
4460    if let Some(registered) = registered
4461        && !same_path_text(&recorded.to_string_lossy(), &registered.to_string_lossy())
4462    {
4463        return BinaryPath::Diverged {
4464            recorded: recorded.to_path_buf(),
4465            registered: registered.to_path_buf(),
4466        };
4467    }
4468    BinaryPath::Current {
4469        path: recorded.to_path_buf(),
4470    }
4471}
4472
4473// ---------------------------------------------------------------------------
4474// The control seam
4475// ---------------------------------------------------------------------------
4476
4477/// What a service manager says about a registration it holds.
4478#[derive(Debug, Clone, PartialEq, Eq)]
4479pub struct Registration {
4480    /// Which manager holds it, which is also what decides the start mode:
4481    /// a Windows service is a boot registration and a Task Scheduler task is a
4482    /// login one; a LaunchDaemon and a system unit are boot, a LaunchAgent and
4483    /// a user unit are login.
4484    pub manager: DefinitionKind,
4485    /// The start mode the *domain* it was found in implies.
4486    pub start_mode: StartMode,
4487    /// The full command line, as the manager stores it.
4488    pub command_line: String,
4489    /// The account it runs under, spelled as [`ServiceAccount`] spells it, when
4490    /// the manager names a real account.
4491    ///
4492    /// `None` where the manager reports only a placeholder: a login
4493    /// registration that runs as whoever signs in, or a Task Scheduler `UserId`
4494    /// that is a raw SID. Status then reports the install record's account.
4495    pub account: Option<String>,
4496    /// Whether it is running now.
4497    pub running: bool,
4498    /// Whether it will start by itself.
4499    ///
4500    /// Distinct from [`Registration::start_mode`]: a Windows service can be
4501    /// registered in the boot domain and still be set to `demand` start, which
4502    /// is a service that exists, looks installed, and does not come back after
4503    /// a reboot.
4504    pub starts_automatically: bool,
4505    /// The restart-on-failure delay the manager reports, when it reports one.
4506    ///
4507    /// The *delay* rather than the whole [`RestartPolicy`], because that is the
4508    /// half every one of the four managers can be asked for. The failure-count
4509    /// reset window has no representation at all in Task Scheduler or launchd,
4510    /// so a `RestartPolicy` read back from them would carry one number the
4511    /// manager reported and one this module invented — and a comparison against
4512    /// an invented number is a comparison that cannot fail.
4513    pub restart_delay: Option<Duration>,
4514}
4515
4516impl Registration {
4517    /// The executable the manager will start, parsed out of the command line.
4518    #[must_use]
4519    pub fn binary(&self) -> Option<PathBuf> {
4520        executable_from_command_line(&self.command_line)
4521    }
4522}
4523
4524/// One platform's service manager, for one start-mode domain.
4525///
4526/// Every method takes the identity rather than storing it, so one control can
4527/// answer about the product's registration and about a test fixture's without
4528/// either being able to reach the other by accident.
4529pub trait ServiceControl: fmt::Debug {
4530    /// Which manager this is.
4531    fn manager(&self) -> DefinitionKind;
4532
4533    /// Registers the plan, returning the definition that was applied.
4534    ///
4535    /// # Errors
4536    ///
4537    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`].
4538    fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
4539
4540    /// Deregisters. Returns whether there was a registration to remove.
4541    ///
4542    /// **Removes the registration and nothing else.** No implementation of this
4543    /// method may delete a directory, a database, a secret, or a cache;
4544    /// `05-infrastructure.md` item 5 is a property of every backend, not a
4545    /// check somewhere above them.
4546    ///
4547    /// # Errors
4548    ///
4549    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`].
4550    fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
4551
4552    /// What the manager knows about this registration, if anything.
4553    ///
4554    /// # Errors
4555    ///
4556    /// [`ServiceError::Control`].
4557    fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
4558
4559    /// Starts it now, without waiting for the next boot or logon.
4560    ///
4561    /// # Errors
4562    ///
4563    /// [`ServiceError::Control`], [`ServiceError::NeedsElevation`], or
4564    /// [`ServiceError::NotInstalled`].
4565    fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
4566
4567    /// Stops it. Returns whether it was running.
4568    ///
4569    /// # Errors
4570    ///
4571    /// As [`ServiceControl::start`].
4572    fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
4573}
4574
4575/// Chooses the control for a start mode.
4576///
4577/// The indirection is what makes [`ServiceOperations`] testable: production
4578/// hands it [`HostControls`], every test in this file hands it
4579/// [`RecordingControls`], and neither knows the difference.
4580pub trait ControlFactory: fmt::Debug + Send + Sync {
4581    /// The control for one start-mode domain.
4582    ///
4583    /// # Errors
4584    ///
4585    /// [`ServiceError::Control`] when this host has no manager for that domain.
4586    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
4587
4588    /// The text of an installed definition file, for drift detection. `None`
4589    /// when there is none to read. The default reads nothing, so a double
4590    /// never reports the real host's unit as its own.
4591    fn installed_definition(&self, _path: &Path) -> Option<String> {
4592        None
4593    }
4594
4595    /// Whether this Mac signs somebody in after a restart, and whether
4596    /// FileVault holds it at the unlock screen. `None` where the platform has
4597    /// no such probe; the default probes nothing, for the same reason as
4598    /// [`Self::installed_definition`].
4599    fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
4600        None
4601    }
4602}
4603
4604/// The real service managers of the host this binary was built for.
4605#[derive(Debug, Clone, Copy, Default)]
4606pub struct HostControls;
4607
4608// ---------------------------------------------------------------------------
4609// The operations
4610// ---------------------------------------------------------------------------
4611
4612/// What `install` did.
4613#[derive(Debug, Clone)]
4614pub struct Installed {
4615    /// The plan that was applied, including the resolved absolute binary path.
4616    pub plan: InstallPlan,
4617    /// The definition the platform was given.
4618    pub definition: ServiceDefinition,
4619    /// The record that was written.
4620    pub record: InstallRecord,
4621    /// What the definition grants, measured against the requirement.
4622    pub review: PrivilegeReview,
4623    /// What was done to the runner root the registration will run jobs under.
4624    ///
4625    /// Named trustees rather than SIDs, so printing it adds no identity to the
4626    /// output. See [`crate::runner_root_access`].
4627    pub runner_root: RootAccessSummary,
4628    /// Whether this replaced a registration that was already there, rather than
4629    /// making a new one.
4630    ///
4631    /// Reported rather than glossed over: "Service installed" on a host that
4632    /// had one already reads as though nothing had been taken away and put
4633    /// back, and what was taken away and put back is the thing an operator
4634    /// watching a running agent wants to know about.
4635    pub replaced_existing: bool,
4636}
4637
4638/// What `uninstall` did — and, as importantly, what it did not.
4639#[derive(Debug, Clone, PartialEq, Eq)]
4640pub struct Uninstalled {
4641    /// Whether a registration was removed.
4642    pub removed_registration: bool,
4643    /// Whether the install record was removed.
4644    pub removed_record: bool,
4645    /// Whether a definition file was removed, and which.
4646    pub removed_definition: Option<PathBuf>,
4647    /// The directories that were **left exactly as they were**.
4648    ///
4649    /// `05-infrastructure.md` item 5 stated as a value rather than as a
4650    /// promise: `service uninstall` prints this list, so an operator can see
4651    /// that the configuration, the SQLite database, the stored token and the
4652    /// runner package cache are all still there.
4653    pub preserved: Vec<PathBuf>,
4654}
4655
4656impl fmt::Display for Uninstalled {
4657    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4658        if self.removed_registration {
4659            writeln!(f, "The service registration was removed.")?;
4660        } else {
4661            writeln!(f, "There was no service registration to remove.")?;
4662        }
4663        writeln!(f, "Nothing else was deleted. These are untouched:")?;
4664        for path in &self.preserved {
4665            writeln!(f, "  {}", path.display())?;
4666        }
4667        write!(
4668            f,
4669            "The stored GitHub token is untouched too; `auth logout` is what purges it."
4670        )
4671    }
4672}
4673
4674/// What `set_start_mode` did.
4675#[derive(Debug, Clone, PartialEq, Eq)]
4676pub struct StartModeChange {
4677    /// What it was.
4678    pub from: StartMode,
4679    /// What it is now.
4680    pub to: StartMode,
4681    /// `false` when the registration was already in the requested mode.
4682    pub changed: bool,
4683    /// The secret store the new mode obliges, so a caller can tell an operator
4684    /// whether the token has to move too.
4685    pub store_scope: crate::secrets::SecretScope,
4686    /// What the mode change did to the runner root's access control.
4687    ///
4688    /// A mode change moves the account the daemon runs as, and the runner root
4689    /// admits that account by name — so the two move together or the new
4690    /// registration cannot write its own workspaces.
4691    pub runner_root: RootAccessSummary,
4692}
4693
4694impl fmt::Display for StartModeChange {
4695    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4696        if !self.changed {
4697            return write!(f, "The service already starts at {}.", self.to);
4698        }
4699        write!(
4700            f,
4701            "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
4702             if the token was stored under the other scope, run `auth login` again. {}",
4703            self.to, self.from, self.store_scope, self.runner_root
4704        )
4705    }
4706}
4707
4708/// Install, uninstall, inspect, and switch start mode.
4709///
4710/// This is the whole of the library contract `f3` builds `service install`,
4711/// `service uninstall` and `service status` on. Nothing here prints; every
4712/// operation returns a value with a [`fmt::Display`] a command can write, so the
4713/// same facts are available to the TUI and to a JSON document without being
4714/// re-derived from text.
4715#[derive(Debug, Clone)]
4716pub struct ServiceOperations {
4717    paths: AppPaths,
4718    identity: ServiceIdentity,
4719    controls: std::sync::Arc<dyn ControlFactory>,
4720    runner_root: Option<LocalAbsolutePath>,
4721}
4722
4723impl ServiceOperations {
4724    /// Operates on this host's real service managers.
4725    #[must_use]
4726    pub fn on_this_host(paths: AppPaths) -> Self {
4727        Self::with_controls(
4728            paths,
4729            ServiceIdentity::product(),
4730            std::sync::Arc::new(HostControls),
4731        )
4732    }
4733
4734    /// Operates on the controls a caller supplies.
4735    ///
4736    /// Both other arguments are explicit for the same reason: the privileged
4737    /// installer tests register a real service under a
4738    /// [`ServiceIdentity::fixture`] name, against a disposable
4739    /// [`AppPaths::rooted_at`] tree, and must not be able to touch an operator's
4740    /// installation even by mistake.
4741    #[must_use]
4742    pub fn with_controls(
4743        paths: AppPaths,
4744        identity: ServiceIdentity,
4745        controls: std::sync::Arc<dyn ControlFactory>,
4746    ) -> Self {
4747        Self {
4748            paths,
4749            identity,
4750            controls,
4751            runner_root: None,
4752        }
4753    }
4754
4755    /// Points runner-root preparation at a directory the caller owns, for the
4756    /// **current account**.
4757    ///
4758    /// **Only a [`ServiceIdentity::fixture`] registration is allowed to move
4759    /// it, and the guard is here rather than at the call site.** A product
4760    /// registration ignores the override entirely and resolves
4761    /// [`crate::runner_root::default_runner_root`] itself, which is what keeps
4762    /// "custom roots are never re-ACLed" true no matter what a caller passes.
4763    ///
4764    /// It exists because the directory the product uses is
4765    /// `%SystemDrive%\rman`, and a smoke test that created and re-permissioned
4766    /// *that* would be editing the machine it runs on from outside its own
4767    /// fixture. So a fixture aims this at a temporary directory it created and
4768    /// will delete — and "and will delete" is why the override is always
4769    /// reconciled for the calling account rather than for the account the start
4770    /// mode obliges. A boot-mode root admits `SY` and `BA` only, which an
4771    /// ordinary filtered token is neither, so a test that supplied one could
4772    /// not then inspect or remove its own temporary directory.
4773    #[must_use]
4774    pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
4775        self.runner_root = Some(root);
4776        self
4777    }
4778
4779    /// The directories this operates against.
4780    #[must_use]
4781    pub const fn paths(&self) -> &AppPaths {
4782        &self.paths
4783    }
4784
4785    /// The registration this operates on.
4786    #[must_use]
4787    pub const fn identity(&self) -> &ServiceIdentity {
4788        &self.identity
4789    }
4790
4791    /// Registers `daemon run` with the operating system.
4792    ///
4793    /// The order is the contract, and each step is a requirement:
4794    ///
4795    /// 1. the four directories are created, because item 2 is about writing them;
4796    /// 2. **the single-instance lock is taken** — item 1. It is held for the
4797    ///    whole of the install rather than probed and released, so a daemon that
4798    ///    starts halfway through cannot end up racing a registration;
4799    /// 3. neither start-mode domain already holds a registration — item 6's
4800    ///    record would otherwise describe one of two;
4801    /// 4. the binary path is resolved and confirmed to be a file — item 6;
4802    /// 5. the platform registers it;
4803    /// 6. the record is written.
4804    ///
4805    /// # Errors
4806    ///
4807    /// [`ServiceError::LockHeld`] when an agent is already running,
4808    /// [`ServiceError::AlreadyInstalled`], [`ServiceError::BinaryMissing`], and
4809    /// whatever the platform reports.
4810    pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
4811        self.paths
4812            .create_all()
4813            .map_err(|source| ServiceError::Paths {
4814                source: Box::new(source),
4815            })?;
4816
4817        // Item 1. The guard lives until the end of this function; dropping it is
4818        // the release, so an early return releases it too.
4819        let _guard = self.refuse_while_an_agent_runs()?;
4820        let adopted = self.adopt_boot_installation(request.start_mode())?;
4821        // Replacing applies to this account's own registration only: over
4822        // another installation's it would re-register that service against
4823        // this account's directories.
4824        if !adopted {
4825            self.refuse_foreign("replace")?;
4826        }
4827
4828        // --------------------------------------------------------------------
4829        // AN INSTALL OVER THE SAME START MODE REPLACES; IT DOES NOT REFUSE.
4830        // --------------------------------------------------------------------
4831        // This used to refuse any existing registration, and that was wrong in
4832        // a way that took a real host down.
4833        //
4834        // `service install` is *also* how an operator moves to a new version by
4835        // hand — `install_owned_copy` says so, and the `.old` rename it does
4836        // exists precisely so that reinstalling over a **running** service
4837        // works. So the caller replaces the copy the service runs and *then*
4838        // asks to register it. Refusing at that point does not undo the swap:
4839        // the daemon is left running a binary nobody registered, the command
4840        // reports failure, and on macOS the new binary is a program the stored
4841        // credential's keychain grant does not name. Watched on 0.1.17: the
4842        // copy under `state/bin` was replaced at 00:16, `service install` said
4843        // "already registered", and the daemon crash-looped on `-25293` from
4844        // then on.
4845        //
4846        // Registering the same service twice is not what an operator is asking
4847        // for here and is not what they get: the platform is asked to drop the
4848        // registration and take it again, which is a deregister followed by a
4849        // register because that is the only thing launchd, systemd and the SCM
4850        // all support — `launchctl bootstrap` refuses a label already loaded.
4851        //
4852        // The *other* mode is still refused. Moving between boot and login
4853        // moves the registration between two service managers and changes the
4854        // account and the secret store with it; `set_start_mode` does that,
4855        // carefully, and an install is not the place for it.
4856        let (replacing, previous_was_running) = match self.find_registration()? {
4857            Some((existing, registration)) if existing == request.start_mode() => {
4858                (true, registration.running)
4859            }
4860            Some((existing, _)) => {
4861                return Err(ServiceError::AlreadyInstalled {
4862                    name: self.identity.name().to_string(),
4863                    existing,
4864                    requested: request.start_mode(),
4865                });
4866            }
4867            None => (false, false),
4868        };
4869
4870        let plan = InstallPlan::resolve(
4871            self.identity.clone(),
4872            request,
4873            ServiceDirectories::of(&self.paths),
4874        )?;
4875
4876        // Resolved before the runner root, and used after it: every step from
4877        // here on has to be able to undo the directory, and a `?` between the
4878        // preparation and the first fallible use would leave one behind with
4879        // nothing to say so.
4880        let control = self.controls.control(plan.start_mode())?;
4881
4882        // Item 8, and the one step that can refuse an otherwise valid install.
4883        // Before the platform is asked to register anything, because a
4884        // registration whose workspaces would land in a directory ordinary local
4885        // users can write is one that should never have existed — and because
4886        // undoing a directory is cheaper than undoing a service.
4887        let root = self.prepare_runner_root(plan.start_mode())?;
4888
4889        // Read before the registration is dropped, so that a replace which then
4890        // fails to take can put back what was there. A record this account
4891        // cannot read leaves nothing to reinstate, which is reported rather
4892        // than pretended: see `reinstate`.
4893        let previous = if replacing {
4894            InstallRecord::read(&self.paths).ok().flatten()
4895        } else {
4896            None
4897        };
4898        if replacing && let Err(cause) = control.uninstall(&self.identity) {
4899            return Err(undo_runner_root(&root, "install", &self.identity, cause));
4900        }
4901
4902        let definition = match control.install(&plan) {
4903            Ok(definition) => definition,
4904            Err(cause) => {
4905                // Nothing was deregistered on a first install, so there is
4906                // nothing to put back and `reinstate` says so with `Ok(())`.
4907                let restored =
4908                    self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running);
4909                return Err(rolled_back(
4910                    retained_runner_root(&root),
4911                    restored,
4912                    "install",
4913                    &self.identity,
4914                    cause,
4915                ));
4916            }
4917        };
4918        let review = review_least_privilege(&definition, &plan);
4919        let record = InstallRecord::of(&plan, &definition, Utc::now());
4920        if let Err(cause) = record.write(&self.paths) {
4921            let rollback = control
4922                .uninstall(&self.identity)
4923                .map(|_| ())
4924                .and_then(|()| {
4925                    self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4926                });
4927            return Err(rolled_back(
4928                retained_runner_root(&root),
4929                rollback,
4930                "install",
4931                &self.identity,
4932                cause,
4933            ));
4934        }
4935
4936        // Registration is not the postcondition of an install: a service the
4937        // current session still has to start by hand is only half-installed.
4938        // This matters most for a logon-triggered Windows task because its
4939        // trigger has already passed by the time an operator reinstalls it.
4940        // Start through the same control that registered the definition and
4941        // roll the whole replacement back if the manager refuses it.
4942        if let Err(cause) = control.start(&self.identity) {
4943            let remove_new = control.uninstall(&self.identity).map(|_| ());
4944            let restore_registration = remove_new.and_then(|()| {
4945                self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4946            });
4947            let restore_record = restore_registration.and_then(|()| match &previous {
4948                Some(previous) => previous.write(&self.paths),
4949                None => InstallRecord::remove(&self.paths).map(|_| ()),
4950            });
4951            return Err(rolled_back(
4952                retained_runner_root(&root),
4953                restore_record,
4954                "install and start",
4955                &self.identity,
4956                cause,
4957            ));
4958        }
4959        Ok(Installed {
4960            plan,
4961            definition,
4962            record,
4963            review,
4964            runner_root: root.summary().clone(),
4965            replaced_existing: replacing,
4966        })
4967    }
4968
4969    /// Puts back a registration this call deregistered in order to replace it.
4970    ///
4971    /// `Ok(())` when there was nothing to put back, which is the ordinary case:
4972    /// a first install deregisters nothing. When there *was* a record, the plan
4973    /// is rebuilt from it rather than from the request, because the request is
4974    /// the one that just failed.
4975    ///
4976    /// Best effort by construction — the caller passes the result to
4977    /// [`rolled_back`], which reports a failed rollback alongside the original
4978    /// cause rather than replacing it.
4979    fn reinstate(
4980        &self,
4981        control: &dyn ServiceControl,
4982        previous: Option<&InstallRecord>,
4983        was_running: bool,
4984    ) -> Result<(), ServiceError> {
4985        let Some(record) = previous else {
4986            return Ok(());
4987        };
4988        control.install(&record.plan(self.identity.clone()))?;
4989        if was_running {
4990            control.start(&self.identity)?;
4991        }
4992        Ok(())
4993    }
4994
4995    /// Deregisters, and deletes nothing else.
4996    ///
4997    /// # Errors
4998    ///
4999    /// Whatever the platform reports. A missing registration is **not** an
5000    /// error: `service uninstall` on a host that has none should say so and
5001    /// exit cleanly, because an operator running it twice has not made a
5002    /// mistake.
5003    pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
5004        let record = InstallRecord::read(&self.paths).ok().flatten();
5005        let removed_definition = record
5006            .as_ref()
5007            .and_then(|record| record.definition_path.clone());
5008
5009        let mut removed_registration = false;
5010        // Both domains, not only the recorded one. A record can be lost while a
5011        // registration survives, and leaving a registration behind because a
5012        // TOML file was deleted is exactly the state `uninstall` exists to end.
5013        //
5014        // Except another installation's: it is skipped, and when it is all
5015        // there is, the uninstall is refused and this account's record kept.
5016        let mut foreign = None;
5017        for mode in [StartMode::Boot, StartMode::Login] {
5018            let control = self.controls.control(mode)?;
5019            if let Some(directories) = control
5020                .query(&self.identity)
5021                .ok()
5022                .flatten()
5023                .and_then(|registration| self.foreign_directories(&registration))
5024            {
5025                foreign = Some(directories);
5026                continue;
5027            }
5028            if control.uninstall(&self.identity)? {
5029                removed_registration = true;
5030            }
5031        }
5032        if !removed_registration && let Some(directories) = foreign {
5033            return Err(self.foreign_refusal("uninstall", directories));
5034        }
5035        let removed_record = InstallRecord::remove(&self.paths)?;
5036        Ok(Uninstalled {
5037            removed_registration,
5038            removed_record,
5039            removed_definition: removed_definition.filter(|_| removed_registration),
5040            preserved: self
5041                .paths
5042                .all()
5043                .iter()
5044                .map(|(_, path)| (*path).to_path_buf())
5045                .collect(),
5046        })
5047    }
5048
5049    /// Switches between `boot` and `login` **without re-resolving anything**.
5050    ///
5051    /// `05-infrastructure.md` item 7. Everything the new registration carries —
5052    /// the absolute binary path, the arguments, the restart policy, the four
5053    /// directories — comes from the existing record, so the operator is not
5054    /// asked to reinstall the product and a binary that has since moved is not
5055    /// silently swapped for whichever one happens to be running this command.
5056    ///
5057    /// On Windows the registration necessarily moves between two different
5058    /// Windows facilities, because Windows has no service that starts at logon;
5059    /// see this module's documentation. That is still not a reinstall: no file
5060    /// is downloaded, replaced, or re-resolved.
5061    ///
5062    /// The caller must persist the new mode onto this host's `Host` record so
5063    /// that `host show` reports it — [`StartModeChange::to`] is the value, and
5064    /// `f1` already emits `host.service_start_mode` from that field.
5065    ///
5066    /// # Errors
5067    ///
5068    /// [`ServiceError::NotInstalled`] when there is no record to switch, and
5069    /// whatever the platform reports.
5070    pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
5071        let Some(record) = InstallRecord::read(&self.paths)? else {
5072            return Err(ServiceError::NotInstalled {
5073                name: self.identity.name().to_string(),
5074                operation: "switch the start mode of",
5075            });
5076        };
5077        let from = record.start_mode;
5078        if from == to {
5079            // Nothing moves, so nothing about the root's access control has to.
5080            // Reconciling it here would turn a no-op command into one that can
5081            // fail on a permission it does not need.
5082            return Ok(StartModeChange {
5083                from,
5084                to,
5085                changed: false,
5086                store_scope: crate::secrets::SecretScope::for_start_mode(to),
5087                runner_root: RootAccessSummary::NotApplicable,
5088            });
5089        }
5090
5091        // A real switch only: a no-op touches no service manager at all. Over
5092        // another installation's registration it would move that service.
5093        self.refuse_foreign("switch the start mode of")?;
5094
5095        #[cfg(windows)]
5096        let arguments = {
5097            let mut arguments = record.arguments.clone();
5098            arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
5099            if to == StartMode::Boot {
5100                arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
5101            }
5102            arguments
5103        };
5104        #[cfg(not(windows))]
5105        let arguments = record.arguments.clone();
5106        let plan = InstallPlan::unchecked(
5107            self.identity.clone(),
5108            to,
5109            record.binary.clone(),
5110            record.directories.clone(),
5111        )
5112        .with_arguments(arguments)
5113        .with_restart(record.restart());
5114        let plan = if record.starts_on_demand {
5115            plan.started_on_demand()
5116        } else {
5117            plan
5118        };
5119        let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
5120            Ok(store) => plan.with_secret_guard(store.guard()),
5121            Err(_) => plan,
5122        };
5123
5124        // Install the target domain before touching the live one. This makes a
5125        // failed target install a no-op from the operator's point of view and,
5126        // unlike uninstall-first ordering, never trades a working service for
5127        // an error message. Resolved before the runner root and used after it,
5128        // so that no `?` sits between preparing the directory and the first
5129        // step that knows how to undo it.
5130        let target = self.controls.control(to)?;
5131        // The domain being left, resolved here rather than where it is used for
5132        // the same reason: the only step that removes it runs after the runner
5133        // root has been prepared, and a `?` there would abandon the target
5134        // registration, the record and the directory without a word.
5135        let previous = self.controls.control(from)?;
5136
5137        // The account changes with the mode, and so must the account the runner
5138        // root admits: `04-security-recovery.md` requires the selected identity
5139        // to be *reconciled* when service mode changes, which means adding the
5140        // operator's on the way to login and dropping it again on the way back.
5141        // Before the target install, for the same reason `install` does it
5142        // first — a root that cannot be made safe must not produce a working
5143        // registration.
5144        let root = self.prepare_runner_root(to)?;
5145
5146        let definition = match target.install(&plan) {
5147            Ok(definition) => definition,
5148            Err(cause) => {
5149                return Err(undo_runner_root(
5150                    &root,
5151                    "switch start mode",
5152                    &self.identity,
5153                    cause,
5154                ));
5155            }
5156        };
5157        let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
5158        if let Err(cause) = next_record.write(&self.paths) {
5159            return Err(rolled_back(
5160                retained_runner_root(&root),
5161                target.uninstall(&self.identity),
5162                "switch start mode",
5163                &self.identity,
5164                cause,
5165            ));
5166        }
5167
5168        // Only after the target registration and its durable record exist is
5169        // it safe to remove the old domain. If that last step fails, remove the
5170        // target and put the old record back so status and reality agree.
5171        if let Err(cause) = previous.uninstall(&self.identity) {
5172            let target_rollback = target.uninstall(&self.identity);
5173            let record_rollback = record.write(&self.paths);
5174            return Err(rolled_back(
5175                retained_runner_root(&root),
5176                target_rollback.and(record_rollback),
5177                "switch start mode",
5178                &self.identity,
5179                cause,
5180            ));
5181        }
5182        Ok(StartModeChange {
5183            from,
5184            to,
5185            changed: true,
5186            store_scope: crate::secrets::SecretScope::for_start_mode(to),
5187            runner_root: root.summary().clone(),
5188        })
5189    }
5190
5191    /// Starts the registration now.
5192    ///
5193    /// # Errors
5194    ///
5195    /// [`ServiceError::NotInstalled`], or whatever the platform reports.
5196    pub fn start(&self) -> Result<(), ServiceError> {
5197        let mode = self.own_registration_mode("start")?;
5198        self.controls.control(mode)?.start(&self.identity)
5199    }
5200
5201    /// The domain holding this account's own registration, for `operation`.
5202    ///
5203    /// # Errors
5204    ///
5205    /// [`ServiceError::NotInstalled`] when there is none, and
5206    /// [`ServiceError::ForeignInstallation`] when the only one is another
5207    /// installation's.
5208    fn own_registration_mode(&self, operation: &'static str) -> Result<StartMode, ServiceError> {
5209        match self.registration_for_status()? {
5210            None => Err(ServiceError::NotInstalled {
5211                name: self.identity.name().to_string(),
5212                operation,
5213            }),
5214            Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
5215            Some((registration, None)) => Ok(registration.start_mode),
5216        }
5217    }
5218
5219    /// Stops the registration. Returns whether it was running.
5220    ///
5221    /// # Errors
5222    ///
5223    /// As [`ServiceOperations::start`].
5224    pub fn stop(&self) -> Result<bool, ServiceError> {
5225        let mode = self.own_registration_mode("stop")?;
5226        self.controls.control(mode)?.stop(&self.identity)
5227    }
5228
5229    /// Everything Journey 5 step 4 asks `service status` to report.
5230    ///
5231    /// # Errors
5232    ///
5233    /// [`ServiceError::Record`] when local state cannot be read, and whatever
5234    /// the platform reports. A *stale binary path* is deliberately **not** an
5235    /// error here: it is a reported state, because a status command that
5236    /// refused to print anything else would hide the very facts an operator
5237    /// needs in order to fix it.
5238    ///
5239    /// A record **this account may not read** is reported for the same reason
5240    /// and is the second exception. On a boot-mode host the record was written
5241    /// by `sudo service install`, and a version before this one wrote it `0600`
5242    /// — so the operator's own `service status` ended with `Permission denied`
5243    /// and printed nothing at all, on a host whose registration the service
5244    /// manager would have described perfectly well. What launchd, systemd or
5245    /// the SCM says is a separate fact from the record, and it is still worth
5246    /// having.
5247    ///
5248    /// # A registration made from another data root
5249    ///
5250    /// The service manager holds one registration per machine under one name,
5251    /// and every account on the machine sees it. When its command line names
5252    /// application-data directories that are not this account's, it is
5253    /// **another installation's service**, and comparing this account's
5254    /// install record against it would report a stale binary, a mismatched
5255    /// start mode and a secret store in the wrong scope, each with a remedy
5256    /// (`service uninstall`, `service install`, `auth login`) that, followed
5257    /// from this account, breaks a healthy service. So it is reported as what
5258    /// it is: see [`ForeignService`].
5259    pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
5260        match self.registration_for_status()? {
5261            Some((registration, Some(directories))) => {
5262                Ok(self.foreign_status(registration, directories))
5263            }
5264            found => {
5265                let (record, record_refused) = match InstallRecord::read(&self.paths) {
5266                    Ok(record) => (record, None),
5267                    Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
5268                    Err(error) => return Err(error),
5269                };
5270                self.status_at(
5271                    &self.paths,
5272                    record,
5273                    record_refused.as_ref(),
5274                    found.map(|(registration, _)| registration),
5275                )
5276            }
5277        }
5278    }
5279
5280    /// [`Self::status`] for the installation whose directories are `paths`,
5281    /// from the record already read there.
5282    fn status_at(
5283        &self,
5284        paths: &AppPaths,
5285        record: Option<InstallRecord>,
5286        record_refused: Option<&ServiceError>,
5287        found: Option<Registration>,
5288    ) -> Result<ServiceStatus, ServiceError> {
5289        let last_github_contact = last_github_contact(paths)?;
5290        let drift = record
5291            .as_ref()
5292            .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, record));
5293        let credential_rejected_since = github_credential_rejected_since(paths).ok().flatten();
5294        let credential_unreadable_since = credential_unreadable_since(paths).ok().flatten();
5295        let probed = self.controls.unattended_login();
5296        let unattended = record.as_ref().and_then(|record| {
5297            // The account is only read where something was probed to judge
5298            // it against.
5299            let account = probed.as_ref().map_or_else(String::new, |_| {
5300                found
5301                    .as_ref()
5302                    .and_then(|registration| registration.account.clone())
5303                    .unwrap_or_else(|| service_account_name(&record.account))
5304            });
5305            unattended_gap(record.start_mode, &account, probed.as_ref())
5306        });
5307        let now = Utc::now();
5308        let installed = record.is_some() || found.is_some();
5309        let status = ServiceStatus::compose(
5310            self.identity.clone(),
5311            record,
5312            record_refused,
5313            found,
5314            last_github_contact,
5315            paths,
5316        )
5317        .with_definition_drift(drift)
5318        .with_credential_rejection(credential_rejected_since)
5319        .with_credential_unreadable(credential_unreadable_since)
5320        .with_launches_blocked(crate::launch_health::launches_blocked(paths, now))
5321        .with_unattended_gap(unattended)
5322        .with_host_unfit(
5323            crate::host_fitness::host_unfit_in_force(paths, now).filter(|_| installed),
5324        );
5325        let restart = status.restart_command();
5326        Ok(status.with_stall(
5327            crate::daemon_heartbeat::liveness(paths, now).stall(last_github_contact, &restart),
5328        ))
5329    }
5330
5331    /// The installed definition, when it is not what this build renders for
5332    /// the recorded installation. See [`definition_drift`].
5333    ///
5334    /// # Errors
5335    ///
5336    /// Whatever reading the install record reports.
5337    pub fn definition_drift(&self) -> Result<Option<DefinitionDrift>, ServiceError> {
5338        Ok(InstallRecord::read(&self.paths)?
5339            .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, &record)))
5340    }
5341
5342    /// The directories the recorded systemd boot unit lets the service write,
5343    /// whether or not the unit has drifted; empty for any other registration.
5344    ///
5345    /// # Errors
5346    ///
5347    /// Whatever reading the install record reports.
5348    pub fn boot_unit_writable_directories(&self) -> Result<Vec<PathBuf>, ServiceError> {
5349        Ok(InstallRecord::read(&self.paths)?
5350            .filter(|record| {
5351                record.start_mode == StartMode::Boot
5352                    && host_definition_kind(record.start_mode) == DefinitionKind::SystemdUnit
5353            })
5354            .map(|record| {
5355                permitted_paths(
5356                    DefinitionKind::SystemdUnit,
5357                    &record.plan(self.identity.clone()),
5358                )
5359                .into_iter()
5360                .map(PathBuf::from)
5361                .collect()
5362            })
5363            .unwrap_or_default())
5364    }
5365
5366    /// Writes the plist this build renders over an outdated one, then has a
5367    /// detached helper boot the job out and bootstrap it again from the new
5368    /// file.
5369    ///
5370    /// launchd reads a plist only when the job is bootstrapped. Rewriting the
5371    /// file changes nothing for a job that is already loaded, and a `KeepAlive`
5372    /// restart reuses the loaded copy. So the job has to be booted out, and
5373    /// that terminates the daemon that asked for it. The helper runs in a
5374    /// session of its own, which launchd does not kill with the job, and
5375    /// retries the bootstrap: launchd refuses it for a few seconds while the
5376    /// old job is still exiting (`5: Input/output error`, measured).
5377    ///
5378    /// Call this only from the daemon the job runs, and only while it holds no
5379    /// runner. It is an operation on the registration the daemon itself runs
5380    /// under, and it never changes the start mode.
5381    ///
5382    /// # Errors
5383    ///
5384    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`] when the
5385    /// plist cannot be written or the helper cannot be started. The loaded job
5386    /// is untouched in both cases.
5387    #[cfg(target_os = "macos")]
5388    pub fn reload_launchd_definition(&self, drift: &DefinitionDrift) -> Result<(), ServiceError> {
5389        // The record names the start mode; the account this process runs as has
5390        // to agree, or the record describes a registration other than the one
5391        // running, and root would end up rewriting an operator's LaunchAgent.
5392        // SAFETY: `getuid` reads the calling process's real user id and cannot
5393        // fail.
5394        let root = unsafe { libc::getuid() } == 0;
5395        if root != (drift.start_mode == StartMode::Boot) {
5396            return Err(ServiceError::Control {
5397                operation: "rewrite",
5398                name: self.identity.name().to_string(),
5399                manager: "launchd",
5400                detail: format!(
5401                    "the install record says --start-at {}, but this daemon runs as {}; the plist \
5402                     is left as it is",
5403                    drift.start_mode,
5404                    if root { "root" } else { "the operator" }
5405                ),
5406            });
5407        }
5408        replace_definition(self.identity.name(), drift)?;
5409        // The definition is now this build's, as after the WSL unit rewrite.
5410        // Best effort: the record says who wrote the plist and nothing reads it
5411        // to decide whether to repair.
5412        if let Ok(Some(mut record)) = InstallRecord::read(&self.paths) {
5413            record.installed_by_version = env!("CARGO_PKG_VERSION").to_string();
5414            let _ = record.write(&self.paths);
5415        }
5416        sys::reload_detached(&self.identity, drift.start_mode, &drift.path)
5417    }
5418
5419    /// Creates or reconciles the runner root this start mode's account needs.
5420    ///
5421    /// The account is not an argument: it is [`ServiceAccount::for_start_mode`],
5422    /// the same function the registration's own principal comes from, so the
5423    /// directory admits exactly the identity the definition registers and a mode
5424    /// change reconciles both together or neither.
5425    ///
5426    /// On macOS and Linux this is a no-op that returns
5427    /// [`RootAccessSummary::NotApplicable`]; see [`crate::runner_root_access`].
5428    fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
5429        #[cfg(not(windows))]
5430        {
5431            // `runner_root` is consumed here as well as in the Windows arm: a
5432            // field only one platform reads is a dead field on the other.
5433            let _ = (mode, &self.runner_root);
5434            Ok(RootAccessChange::not_applicable())
5435        }
5436        #[cfg(windows)]
5437        {
5438            let wrap = |source| ServiceError::RunnerRoot {
5439                source: Box::new(source),
5440            };
5441            // The test seam, and the two things allowed through it. `cfg!(test)`
5442            // is false in every shipped binary, so what a released build honours
5443            // is the fixture name alone — and a fixture name cannot be the
5444            // product's, which is what keeps a released `service install`
5445            // pointed at the platform default whatever a caller passes.
5446            //
5447            // An overridden root is always reconciled **for this account**,
5448            // which is the foreground admission — a real mode of the product
5449            // rather than a concession invented here. It has to be: a boot-mode
5450            // root admits `SY` and `BA` only, and a test process holding an
5451            // ordinary filtered token is neither, so it could not inspect the
5452            // temporary directory it just supplied nor delete it afterwards.
5453            // What that costs is that the *boot* descriptor is not proved
5454            // through this path; it is proved purely, by this module's
5455            // `the_runner_root_a_boot_registration_needs_admits_only_the_service`
5456            // and by `runner_root_access`'s own tests, and for real by the
5457            // privileged installer test, which runs elevated.
5458            if let Some(root) = self
5459                .runner_root
5460                .as_ref()
5461                .filter(|_| self.identity.is_fixture() || cfg!(test))
5462            {
5463                let admission = RootAdmission::of_this_account().map_err(wrap)?;
5464                return crate::runner_root_access::reconcile(&self.paths, root, &admission)
5465                    .map_err(wrap);
5466            }
5467
5468            let admission = match ServiceAccount::for_start_mode(mode) {
5469                // A boot registration runs as LocalSystem, which the constant
5470                // `SY` ace already names.
5471                ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
5472                // A login registration runs as this account, under a filtered
5473                // token in which Administrators is deny-only — so without an
5474                // ace of its own it would be admitted by nothing.
5475                ServiceAccount::InvokingUser | ServiceAccount::Root => {
5476                    RootAdmission::of_this_account().map_err(wrap)?
5477                }
5478            };
5479            crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
5480        }
5481    }
5482
5483    /// Takes the single-instance lock, or refuses with `d1`'s own message.
5484    fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
5485        HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
5486            |source| match source {
5487                held @ LockError::Held { .. } => ServiceError::LockHeld {
5488                    source: Box::new(held),
5489                },
5490                other => ServiceError::LockUnreadable {
5491                    source: Box::new(other),
5492                },
5493            },
5494        )
5495    }
5496
5497    /// The registration, in whichever domain holds it.
5498    fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
5499        for mode in [StartMode::Boot, StartMode::Login] {
5500            let control = self.controls.control(mode)?;
5501            if let Some(registration) = control.query(&self.identity)? {
5502                return Ok(Some((registration.start_mode, registration)));
5503            }
5504        }
5505        Ok(None)
5506    }
5507
5508    /// The registration [`Self::status`] describes, with the directories it
5509    /// runs against when they are not this account's: this account's own
5510    /// registration when either domain holds one, else whichever there is.
5511    ///
5512    /// [`Self::find_registration`] stops at the boot domain, so a boot service
5513    /// made from another data root would hide this account's own login
5514    /// registration. The other domain is asked only in that case, and a
5515    /// failure to ask it is not this status's failure.
5516    fn registration_for_status(
5517        &self,
5518    ) -> Result<Option<(Registration, Option<ServiceDirectories>)>, ServiceError> {
5519        let Some((mode, found)) = self.find_registration()? else {
5520            return Ok(None);
5521        };
5522        let Some(directories) = self.foreign_directories(&found) else {
5523            return Ok(Some((found, None)));
5524        };
5525        let other = match mode {
5526            StartMode::Boot => StartMode::Login,
5527            StartMode::Login => StartMode::Boot,
5528        };
5529        let own = self
5530            .controls
5531            .control(other)
5532            .ok()
5533            .and_then(|control| control.query(&self.identity).ok().flatten())
5534            .filter(|registration| self.foreign_directories(registration).is_none());
5535        Ok(Some(match own {
5536            Some(own) => (own, None),
5537            None => (found, Some(directories)),
5538        }))
5539    }
5540
5541    /// Moves a Linux boot service registered against another tree onto this
5542    /// one, which for `root` is the system tree. Returns whether it did.
5543    ///
5544    /// `service install` calls this before it opens anything in this tree, so
5545    /// that the copy lands in an empty one; the install itself calls it again,
5546    /// which then only confirms the registration is to be replaced.
5547    ///
5548    /// Before the system tree, a boot service ran against whichever tree
5549    /// `sudo` resolved: `/root`'s on some distributions, the operator's own on
5550    /// others. A boot unit with no capabilities cannot open the operator's, and
5551    /// `ProtectHome=` hides both, so the service is stopped, its configuration
5552    /// and state are copied here unless this tree already has its own, and the
5553    /// install then replaces the registration. The old tree is left as it was.
5554    ///
5555    /// # Errors
5556    ///
5557    /// Whatever stopping the old service or copying its tree reports.
5558    pub fn adopt_boot_installation(&self, mode: StartMode) -> Result<bool, ServiceError> {
5559        #[cfg(target_os = "linux")]
5560        return self.adopt_boot_installation_on_linux(mode);
5561        #[cfg(not(target_os = "linux"))]
5562        {
5563            let _ = mode;
5564            Ok(false)
5565        }
5566    }
5567
5568    #[cfg(target_os = "linux")]
5569    fn adopt_boot_installation_on_linux(&self, mode: StartMode) -> Result<bool, ServiceError> {
5570        if mode != StartMode::Boot {
5571            return Ok(false);
5572        }
5573        self.refuse_overridden_account()?;
5574        if !crate::host_fitness::is_elevated() {
5575            return Ok(false);
5576        }
5577        let Some((StartMode::Boot, found)) = self.find_registration()? else {
5578            return Ok(false);
5579        };
5580        let Some(old) = self.foreign_directories(&found) else {
5581            return Ok(false);
5582        };
5583        self.controls
5584            .control(StartMode::Boot)?
5585            .stop(&self.identity)?;
5586        // No service was ever installed against this tree, so whatever is in it
5587        // is what an interrupted attempt or a stray command left: set aside,
5588        // never deleted, in favour of the data the running service used. A
5589        // tree with its own install record keeps it.
5590        if matches!(InstallRecord::read(&self.paths), Ok(None)) {
5591            let stamp = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
5592            for (from, to, skip) in [
5593                (&old.config, self.paths.config_dir(), None),
5594                // `service install` copies the binary in again.
5595                (&old.state, self.paths.state_dir(), Some("bin")),
5596            ] {
5597                set_aside(to, &stamp)
5598                    .and_then(|()| copy_tree(from, to, skip))
5599                    .map_err(|error| ServiceError::Control {
5600                        operation: "move the data of",
5601                        name: self.identity.name().to_string(),
5602                        manager: "systemd",
5603                        detail: format!(
5604                            "cannot copy {} to {}: {error}",
5605                            from.display(),
5606                            to.display()
5607                        ),
5608                    })?;
5609            }
5610        }
5611        Ok(true)
5612    }
5613
5614    /// Refuses a Linux boot install over a unit an administrator's drop-in made
5615    /// run as another account.
5616    ///
5617    /// A drop-in outlives the unit file `service install` rewrites, so the new
5618    /// unit would run as that account too, and that account cannot open the
5619    /// system tree `root` owns. Said before anything is stopped or copied.
5620    #[cfg(target_os = "linux")]
5621    fn refuse_overridden_account(&self) -> Result<(), ServiceError> {
5622        let Some((StartMode::Boot, found)) = self.find_registration()? else {
5623            return Ok(());
5624        };
5625        match found.account.as_deref() {
5626            Some(account) if account != ServiceAccount::Root.as_str() => {
5627                Err(ServiceError::Control {
5628                    operation: "install",
5629                    name: self.identity.name().to_string(),
5630                    manager: "systemd",
5631                    detail: format!(
5632                        "a drop-in under {SYSTEMD_SYSTEM_DIR}/{}.d/ makes it run as {account}, and \
5633                         a boot service must run as root to use {}. Remove `User=` and `Group=` \
5634                         from that drop-in, run `sudo systemctl daemon-reload`, and install again",
5635                        self.identity.systemd_unit(),
5636                        crate::paths::SYSTEM_DATA_ROOT,
5637                    ),
5638                })
5639            }
5640            _ => Ok(()),
5641        }
5642    }
5643
5644    /// Refuses `operation` when the machine's registration is another
5645    /// installation's. See [`ServiceError::ForeignInstallation`].
5646    fn refuse_foreign(&self, operation: &'static str) -> Result<(), ServiceError> {
5647        match self.registration_for_status()? {
5648            Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
5649            _ => Ok(()),
5650        }
5651    }
5652
5653    /// [`ServiceError::ForeignInstallation`] for the installation at
5654    /// `directories`.
5655    fn foreign_refusal(
5656        &self,
5657        operation: &'static str,
5658        directories: ServiceDirectories,
5659    ) -> ServiceError {
5660        let foreign = self.foreign_service_at(directories);
5661        ServiceError::ForeignInstallation {
5662            name: self.identity.name().to_string(),
5663            operation,
5664            installed_by: foreign.installed_by(),
5665            act_as: foreign.act_as(),
5666        }
5667    }
5668
5669    /// The directories `registration` runs against, when they are not this
5670    /// account's. See [`ForeignService`].
5671    fn foreign_directories(&self, registration: &Registration) -> Option<ServiceDirectories> {
5672        ServiceDirectories::from_command_line(&registration.command_line)
5673            .filter(|directories| !same_directory(&directories.config, self.paths.config_dir()))
5674    }
5675
5676    /// The machine's service and the installation it belongs to, when that is
5677    /// not this account's, without reading anything of that installation's
5678    /// beyond its path. `None` when the service is this account's, when there
5679    /// is none, or when the service manager cannot be asked.
5680    #[must_use]
5681    pub fn foreign_service(&self) -> Option<ForeignService> {
5682        let (_, directories) = self.registration_for_status().ok()??;
5683        Some(self.foreign_service_at(directories?))
5684    }
5685
5686    /// The [`ForeignService`] for an installation at `directories`, before
5687    /// anything of its own is read.
5688    fn foreign_service_at(&self, directories: ServiceDirectories) -> ForeignService {
5689        let root = directories.root();
5690        let home = host_home();
5691        // This account's own installation under other directories, when the
5692        // four are exactly a layout a command of this account resolves.
5693        let this_account = home
5694            .as_deref()
5695            .filter(|home| contains_directory(home, &root))
5696            .and_then(|_| {
5697                if directories == ServiceDirectories::of(&AppPaths::rooted_at(&root)) {
5698                    Some(format!(
5699                        "with `--data-dir {}`",
5700                        quote_argument(&root.to_string_lossy())
5701                    ))
5702                } else {
5703                    AppPaths::discover()
5704                        .ok()
5705                        .filter(|standard| directories == ServiceDirectories::of(standard))
5706                        .map(|_| "without `--data-dir`".to_owned())
5707                }
5708            });
5709        ForeignService {
5710            owner: data_root_owner(&root, home.as_deref()),
5711            this_account,
5712            root,
5713            directories,
5714            local_root: ServiceDirectories::of(&self.paths).root(),
5715            local_record: InstallRecord::path(&self.paths),
5716            unused_local_record: InstallRecord::read(&self.paths).ok().flatten(),
5717            unreadable: None,
5718        }
5719    }
5720
5721    /// [`Self::status`] for a registration made from another data root.
5722    ///
5723    /// What that installation recorded is read when this account may read it,
5724    /// and the status is then exactly the one its owner would see. When it may
5725    /// not, the status holds what the service manager says and nothing it
5726    /// would have to guess.
5727    fn foreign_status(
5728        &self,
5729        registration: Registration,
5730        directories: ServiceDirectories,
5731    ) -> ServiceStatus {
5732        let service_paths = directories.paths();
5733        let mut foreign = self.foreign_service_at(directories);
5734        let unreadable = match InstallRecord::read(&service_paths) {
5735            Ok(Some(record)) => {
5736                match self.status_at(
5737                    &service_paths,
5738                    Some(record),
5739                    None,
5740                    Some(registration.clone()),
5741                ) {
5742                    Ok(status) => return status.with_foreign(foreign),
5743                    Err(error) => error.to_string(),
5744                }
5745            }
5746            Ok(None) => format!(
5747                "there is no install record at {}",
5748                InstallRecord::path(&service_paths).display()
5749            ),
5750            // Not the error's own words: `RecordNotPermitted` and
5751            // `RecordUnreadable` both end in a `service install` remedy, which
5752            // from this account would re-register the machine's service
5753            // against the wrong directories.
5754            Err(
5755                ServiceError::RecordNotPermitted { path, detail }
5756                | ServiceError::RecordUnreadable { path, detail }
5757                | ServiceError::Record { path, detail, .. },
5758            ) => format!("this account cannot read {} ({detail})", path.display()),
5759            Err(error) => error.to_string(),
5760        };
5761        foreign.unreadable = Some(unreadable);
5762        ServiceStatus::foreign_unread(self.identity.clone(), registration, foreign)
5763    }
5764
5765    /// Moves this account's own configuration aside, when no registration on
5766    /// this machine runs against it: `host forget-local`.
5767    ///
5768    /// Everything in the configuration directory -- the install record, the
5769    /// database with its host row and policies, `runner.env` and the rest -- is
5770    /// **renamed** into a `forgotten-<timestamp>` directory beside it, never
5771    /// deleted, so moving it back restores it exactly. The state, runtime and
5772    /// log directories are left alone even where the platform puts them inside
5773    /// the configuration directory (macOS does), and so is every secret store
5774    /// and every directory a registration names.
5775    ///
5776    /// Both domains are asked, strictly: a domain that cannot be asked might
5777    /// hold this account's own registration, so it refuses rather than reads
5778    /// as empty.
5779    ///
5780    /// # Errors
5781    ///
5782    /// [`ServiceError::LocalConfigurationInUse`] when a registration on this
5783    /// machine runs against these directories, when a registration from
5784    /// another data root names a directory overlapping them, or when an agent
5785    /// holds this account's single-instance lock. [`ServiceError::Record`] when
5786    /// the move fails; whatever had already moved is put back first, and
5787    /// anything that could not be put back is named.
5788    pub fn forget_local(&self, at: DateTime<Utc>) -> Result<ForgottenLocal, ServiceError> {
5789        let config = self.paths.config_dir();
5790        let in_use = |detail: String| ServiceError::LocalConfigurationInUse { detail };
5791        let mut service_root = None;
5792        for mode in [StartMode::Boot, StartMode::Login] {
5793            let registration = self
5794                .controls
5795                .control(mode)
5796                .and_then(|control| control.query(&self.identity))
5797                .map_err(|error| {
5798                    in_use(format!(
5799                        "whether a {mode} registration runs against it could not be read ({error})"
5800                    ))
5801                })?;
5802            let Some(registration) = registration else {
5803                continue;
5804            };
5805            let Some(directories) = self.foreign_directories(&registration) else {
5806                return Err(in_use(format!(
5807                    "{} holds a registration that runs against {}. `service status` describes it",
5808                    registration.manager,
5809                    config.display()
5810                )));
5811            };
5812            if let Some(overlap) = directories.all().into_iter().find(|directory| {
5813                contains_directory(directory, config) || contains_directory(config, directory)
5814            }) {
5815                return Err(in_use(format!(
5816                    "the machine's service runs against {}, which overlaps {}",
5817                    overlap.display(),
5818                    config.display()
5819                )));
5820            }
5821            service_root.get_or_insert_with(|| directories.root());
5822        }
5823        let _lock =
5824            HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(|source| {
5825                in_use(format!(
5826                    "an agent may be running against these directories: {source}"
5827                ))
5828            })?;
5829        let failed = |operation: &'static str, path: &Path| {
5830            let path = path.to_path_buf();
5831            move |error: std::io::Error| ServiceError::Record {
5832                operation,
5833                path,
5834                detail: error.to_string(),
5835            }
5836        };
5837        // Never the agent's own directories, wherever the platform nests them.
5838        let kept = [
5839            self.paths.state_dir(),
5840            self.paths.runtime_dir(),
5841            self.paths.logs_dir(),
5842        ];
5843        let mut entries = Vec::new();
5844        for entry in std::fs::read_dir(config).map_err(failed("list", config))? {
5845            let entry = entry.map_err(failed("list", config))?.path();
5846            let archived = entry
5847                .file_name()
5848                .is_some_and(|name| name.to_string_lossy().starts_with(FORGOTTEN_PREFIX));
5849            if !archived && !kept.iter().any(|kept| contains_directory(&entry, kept)) {
5850                entries.push(entry);
5851            }
5852        }
5853        if entries.is_empty() {
5854            return Ok(ForgottenLocal {
5855                archive: None,
5856                moved: entries,
5857                service_root,
5858            });
5859        }
5860        entries.sort();
5861        let archive = config.join(format!("{FORGOTTEN_PREFIX}{}", at.format("%Y%m%dT%H%M%SZ")));
5862        std::fs::create_dir(&archive).map_err(failed("create", &archive))?;
5863        let inside = |entry: &Path| archive.join(entry.file_name().unwrap_or_default());
5864        for (index, entry) in entries.iter().enumerate() {
5865            if let Err(error) = std::fs::rename(entry, inside(entry)) {
5866                let stranded: Vec<String> = entries[..index]
5867                    .iter()
5868                    .rev()
5869                    .filter(|done| std::fs::rename(inside(done), done).is_err())
5870                    .map(|done| inside(done).display().to_string())
5871                    .collect();
5872                let _ = std::fs::remove_dir(&archive);
5873                let mut detail = error.to_string();
5874                if !stranded.is_empty() {
5875                    detail.push_str(&format!(
5876                        "; these could not be put back and are still in the archive: {}",
5877                        stranded.join(", ")
5878                    ));
5879                }
5880                return Err(ServiceError::Record {
5881                    operation: "archive",
5882                    path: entry.clone(),
5883                    detail,
5884                });
5885            }
5886        }
5887        Ok(ForgottenLocal {
5888            archive: Some(archive),
5889            moved: entries,
5890            service_root,
5891        })
5892    }
5893}
5894
5895/// The name every `host forget-local` archive starts with. A later run leaves
5896/// earlier archives where they are.
5897pub const FORGOTTEN_PREFIX: &str = "forgotten-";
5898
5899/// What [`ServiceOperations::forget_local`] did.
5900#[derive(Debug, Clone, PartialEq, Eq)]
5901pub struct ForgottenLocal {
5902    /// Where the configuration went, or `None` when there was nothing to move.
5903    pub archive: Option<PathBuf>,
5904    /// What was moved, by its old path.
5905    pub moved: Vec<PathBuf>,
5906    /// The data root of the machine's service, when one runs from another
5907    /// installation. Named so the operator can see it was not touched.
5908    pub service_root: Option<PathBuf>,
5909}
5910
5911impl fmt::Display for ForgottenLocal {
5912    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5913        let Some(archive) = &self.archive else {
5914            return write!(
5915                f,
5916                "Nothing to archive: this account's configuration directory is empty."
5917            );
5918        };
5919        writeln!(f, "Archived this account's unused local configuration")?;
5920        writeln!(f, "  archive                   {}", archive.display())?;
5921        for path in &self.moved {
5922            writeln!(f, "  moved                     {}", path.display())?;
5923        }
5924        if let Some(root) = &self.service_root {
5925            writeln!(
5926                f,
5927                "  service data root         {} (untouched)",
5928                root.display()
5929            )?;
5930        }
5931        write!(
5932            f,
5933            "  restore                   move everything in the archive back into its parent \
5934             directory"
5935        )
5936    }
5937}
5938
5939/// Whether two directory spellings name the same place: the same text on
5940/// this platform's terms, or the same canonical directory.
5941fn same_directory(left: &Path, right: &Path) -> bool {
5942    let text = |path: &Path| {
5943        path.to_string_lossy()
5944            .trim_end_matches(['/', '\\'])
5945            .to_owned()
5946    };
5947    same_path_text(&text(left), &text(right))
5948        || matches!(
5949            (std::fs::canonicalize(left), std::fs::canonicalize(right)),
5950            (Ok(left), Ok(right)) if left == right
5951        )
5952}
5953
5954/// Where the remedy starts in a [`StatusProblem`]'s detail: the sentence
5955/// openings every builder above uses for it.
5956const REMEDY_OPENINGS: [&str; 8] = [
5957    ". Run ",
5958    ". If nothing is waiting",
5959    ". Re-register",
5960    ". Switch the start mode",
5961    " Sign in again",
5962    ". Fix: ",
5963    "; to do it now",
5964    "; if the service manager denies",
5965];
5966
5967/// A problem's diagnosis without its remedy. See
5968/// [`ServiceStatus::with_foreign`].
5969///
5970/// A detail that still names a command that changes the registration or the
5971/// credential after the cut -- a builder this list does not know yet -- is
5972/// reduced to the first clause rather than passed on, because the cost of
5973/// showing too little is a shorter sentence and the cost of showing too much
5974/// is a broken service.
5975fn diagnosis_only(detail: &str) -> String {
5976    let cut = REMEDY_OPENINGS
5977        .iter()
5978        .filter_map(|opening| detail.find(opening))
5979        .min()
5980        .unwrap_or(detail.len());
5981    let diagnosis = detail[..cut].trim_end_matches(['.', ' ']);
5982    let names_a_change = |text: &str| {
5983        [
5984            "service install",
5985            "service uninstall",
5986            "auth login",
5987            "launchctl",
5988            "systemctl",
5989        ]
5990        .iter()
5991        .any(|command| text.contains(command))
5992    };
5993    if !names_a_change(diagnosis) {
5994        return diagnosis.to_owned();
5995    }
5996    // Clause ends followed by a space, so a `.` inside a path does not cut.
5997    let end = [", ", "; ", ". "]
5998        .iter()
5999        .filter_map(|separator| diagnosis.find(separator))
6000        .min()
6001        .unwrap_or(diagnosis.len());
6002    let first = &diagnosis[..end];
6003    if names_a_change(first) {
6004        "the installation's own records report this".to_owned()
6005    } else {
6006        first.to_owned()
6007    }
6008}
6009
6010/// A path's components as this platform compares them: case-insensitively
6011/// on Windows.
6012fn path_key(path: &Path) -> Vec<String> {
6013    path.components()
6014        .map(|component| {
6015            let text = component.as_os_str().to_string_lossy();
6016            if cfg!(windows) {
6017                text.to_lowercase()
6018            } else {
6019                text.into_owned()
6020            }
6021        })
6022        .collect()
6023}
6024
6025/// Whether `inner` is `outer` or a directory under it, by the rule
6026/// [`same_directory`] uses: on this platform's terms, or canonically.
6027fn contains_directory(outer: &Path, inner: &Path) -> bool {
6028    path_key(inner).starts_with(&path_key(outer))
6029        || matches!(
6030            (std::fs::canonicalize(outer), std::fs::canonicalize(inner)),
6031            (Ok(outer), Ok(inner)) if inner.starts_with(&outer)
6032        )
6033}
6034
6035/// The account whose data root `root` is, when the path says: the folder
6036/// under the directory that holds this account's `home`, `root` for root's
6037/// home on Unix, and `LocalSystem` for its profile on Windows. `None` when
6038/// the path names nobody, which is reported as the path alone.
6039#[must_use]
6040pub fn data_root_owner(root: &Path, home: Option<&Path>) -> Option<String> {
6041    let target = path_key(root);
6042    if cfg!(windows) {
6043        if target
6044            .windows(3)
6045            .any(|run| run == ["system32", "config", "systemprofile"])
6046        {
6047            return Some("LocalSystem".to_owned());
6048        }
6049    } else if [
6050        "/root",
6051        "/var/root",
6052        "/private/var/root",
6053        crate::paths::SYSTEM_DATA_ROOT,
6054    ]
6055    .iter()
6056    .any(|home| root.starts_with(home))
6057    {
6058        return Some("root".to_owned());
6059    }
6060    let users = path_key(home?.parent()?);
6061    // A home directly under the root (`/root` under `sudo`) names no directory
6062    // of homes: every path would then "belong" to its first component.
6063    if users.len() < 2 {
6064        return None;
6065    }
6066    if target.len() > users.len() && target[..users.len()] == users[..] {
6067        root.components()
6068            .nth(users.len())
6069            .map(|name| name.as_os_str().to_string_lossy().into_owned())
6070    } else {
6071        None
6072    }
6073}
6074
6075/// A registration whose command line names application-data directories that
6076/// are not this account's: **another installation's service**.
6077///
6078/// Every account on a machine sees the one registration its service manager
6079/// holds under the product's name. A second account -- one that ran a
6080/// login-mode install once, say -- has an install record and a database of its
6081/// own, and comparing them against the other installation's registration used
6082/// to report a stale binary, a mismatched start mode and a secret store in the
6083/// wrong scope, with `service uninstall`, `service install` and `auth login`
6084/// as the remedies. Followed from that account, each of them breaks or
6085/// replaces a service that is healthy. So the registration is reported as the
6086/// other installation's, with what can be read of it, and this account's own
6087/// record as the unused configuration it is.
6088#[derive(Debug, Clone, PartialEq, Eq)]
6089pub struct ForeignService {
6090    /// The directories its command line names.
6091    pub directories: ServiceDirectories,
6092    /// The data root they sit under. See [`ServiceDirectories::root`].
6093    pub root: PathBuf,
6094    /// The account that data root belongs to, when its path says.
6095    pub owner: Option<String>,
6096    /// How a command run by this account reaches the installation, when it is
6097    /// this account's own under other directories -- an install made with
6098    /// `--data-dir`, seen from a command run without it, or the other way
6099    /// round. `None` for another account's.
6100    pub this_account: Option<String>,
6101    /// This account's own data root, which no registration runs against.
6102    pub local_root: PathBuf,
6103    /// Where this account's own install record is, or would be.
6104    pub local_record: PathBuf,
6105    /// This account's own install record, from an earlier install.
6106    pub unused_local_record: Option<InstallRecord>,
6107    /// Why the service's own records could not be read, or `None` when they
6108    /// were and the status describes them.
6109    pub unreadable: Option<String>,
6110}
6111
6112impl ForeignService {
6113    /// How to see the service's own details: as its owner, or elevated.
6114    #[must_use]
6115    pub fn where_to_look(&self) -> String {
6116        if let Some(reach) = &self.this_account {
6117            return reach.clone();
6118        }
6119        let elevated = if cfg!(windows) {
6120            "from an elevated terminal (Run as administrator)"
6121        } else {
6122            "with sudo"
6123        };
6124        match &self.owner {
6125            Some(owner) if owner != "LocalSystem" && owner != "root" => {
6126                format!("{elevated}, or signed in as {owner}")
6127            }
6128            _ => elevated.to_owned(),
6129        }
6130    }
6131
6132    /// Where a change to the service has to be made from: the installation's
6133    /// own account, because every remedy a command prints acts on the
6134    /// directories of whoever runs it. Elevation alone reads the service's
6135    /// records but is still this account.
6136    #[must_use]
6137    pub fn act_as(&self) -> String {
6138        if let Some(reach) = &self.this_account {
6139            return reach.clone();
6140        }
6141        match &self.owner {
6142            Some(owner) if owner != "LocalSystem" && owner != "root" => {
6143                format!("signed in as {owner}")
6144            }
6145            _ => format!(
6146                "from the account whose data root {} is",
6147                self.root.display()
6148            ),
6149        }
6150    }
6151
6152    /// Whether the service's own records could not be read, so only what the
6153    /// service manager says is known.
6154    #[must_use]
6155    pub const fn is_unread(&self) -> bool {
6156        self.unreadable.is_some()
6157    }
6158
6159    /// Who installed it, for a sentence.
6160    #[must_use]
6161    pub fn installed_by(&self) -> String {
6162        match &self.owner {
6163            Some(owner) => format!("{owner}'s data root {}", self.root.display()),
6164            None => format!("the data root {}", self.root.display()),
6165        }
6166    }
6167
6168    /// The data root and its owner, for a field: `<root> (<owner>)`.
6169    #[must_use]
6170    pub fn label(&self) -> String {
6171        match &self.owner {
6172            Some(owner) => format!("{} ({owner})", self.root.display()),
6173            None => self.root.display().to_string(),
6174        }
6175    }
6176
6177    /// The note describing this account's own record, when there is one.
6178    #[must_use]
6179    pub fn unused_local_note(&self) -> Option<String> {
6180        let record = self.unused_local_record.as_ref()?;
6181        Some(format!(
6182            "unused local configuration from an earlier install: {} records a {} registration \
6183             made by runner-manager {} on {}, and no service on this machine runs against it. \
6184             `runner-manager host forget-local` moves it aside; it never touches the service's \
6185             own directories or any secret.",
6186            self.local_record.display(),
6187            record.start_mode,
6188            record.installed_by_version,
6189            record.installed_at.format("%Y-%m-%d"),
6190        ))
6191    }
6192}
6193
6194// ---------------------------------------------------------------------------
6195// Status
6196// ---------------------------------------------------------------------------
6197
6198/// One thing `service status` has to report as wrong.
6199#[derive(Debug, Clone, PartialEq, Eq)]
6200pub struct StatusProblem {
6201    /// What it is about.
6202    pub subject: &'static str,
6203    /// What is wrong, and what to do.
6204    pub detail: String,
6205}
6206
6207impl fmt::Display for StatusProblem {
6208    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6209        write!(f, "{}: {}", self.subject, self.detail)
6210    }
6211}
6212
6213/// Everything `service status` reports.
6214///
6215/// Journey 5 step 4 names four of these — the start mode, the resolved binary
6216/// path, the diagnostic log path, and the last successful GitHub contact — and
6217/// the release gate adds the fifth: *"`service status` reports a stale binary
6218/// path as an error rather than appearing healthy"*. [`Self::is_healthy`] is
6219/// what an exit code should be derived from, and it is false whenever
6220/// [`Self::problems`] is non-empty.
6221#[derive(Debug, Clone)]
6222pub struct ServiceStatus {
6223    identity: ServiceIdentity,
6224    record: Option<InstallRecord>,
6225    registration: Option<Registration>,
6226    binary: Option<BinaryPath>,
6227    log_file: PathBuf,
6228    store: Option<crate::secrets::ActiveStore>,
6229    last_github_contact: Option<DateTime<Utc>>,
6230    runner_root: Option<(PathBuf, RootAccessReport)>,
6231    credential_rejected_since: Option<DateTime<Utc>>,
6232    launches_blocked: Option<crate::launch_health::LaunchesBlocked>,
6233    unattended_gap: Option<UnattendedGap>,
6234    foreign: Option<ForeignService>,
6235    problems: Vec<StatusProblem>,
6236    notes: Vec<String>,
6237}
6238
6239/// Why a registration does not come back by itself after an unattended
6240/// restart, and what an operator can do about it.
6241#[derive(Debug, Clone, PartialEq, Eq)]
6242pub struct UnattendedGap {
6243    /// One or more sentences, the way `service status` prints them.
6244    pub reason: String,
6245    /// What changes it. Never something this product does by itself.
6246    pub remedy: String,
6247}
6248
6249/// The name of the account a registration runs as: the invoking user's own
6250/// for a login-mode one.
6251#[must_use]
6252pub fn service_account_name(account: &ServiceAccount) -> String {
6253    match account {
6254        // Under `sudo` the invoking user is the one sudo names, not root.
6255        ServiceAccount::InvokingUser => std::env::var("SUDO_USER")
6256            .ok()
6257            .filter(|user| !user.is_empty() && crate::host_fitness::is_elevated())
6258            .or_else(crate::unattended_login::current_account)
6259            .unwrap_or_else(|| "the operator".to_owned()),
6260        other => other.as_str().to_owned(),
6261    }
6262}
6263
6264/// The account `registration` names, which an administrator can change from
6265/// the one the install record saved, and otherwise the record's.
6266fn registered_account<'a>(registration: Option<&'a Registration>, recorded: &'a str) -> &'a str {
6267    registration
6268        .and_then(|registration| registration.account.as_deref())
6269        .unwrap_or(recorded)
6270}
6271
6272/// A Windows service account as `QueryServiceConfigW` names it, spelled the
6273/// way [`ServiceAccount`] does, so a service nobody edited reports the account
6274/// the record holds.
6275#[cfg(any(windows, test))]
6276fn windows_service_account(name: &str) -> String {
6277    if name.eq_ignore_ascii_case("LocalSystem") || name.eq_ignore_ascii_case(".\\LocalSystem") {
6278        ServiceAccount::LocalSystem.as_str().to_string()
6279    } else {
6280        name.to_string()
6281    }
6282}
6283
6284/// A Task Scheduler `UserId`, unless it is a raw SID, which names no account
6285/// an operator would recognise.
6286#[cfg(any(windows, test))]
6287fn scheduled_task_account(user_id: Option<String>) -> Option<String> {
6288    user_id.filter(|id| !id.to_ascii_uppercase().starts_with("S-1-"))
6289}
6290
6291/// Works out the gap for a registration in `mode` running as `account`.
6292///
6293/// `found` is `None` where nothing could be probed (every platform but macOS):
6294/// a login registration then keeps the general statement that it waits for a
6295/// sign-in. On a Mac the statement depends on automatic login and FileVault,
6296/// and a login registration that automatic login brings back has no gap. The
6297/// earlier statement was printed for every login registration, so a Mac set to
6298/// sign its runner account in automatically was told it would not resume.
6299#[must_use]
6300pub fn unattended_gap(
6301    mode: StartMode,
6302    account: &str,
6303    found: Option<&crate::unattended_login::UnattendedLogin>,
6304) -> Option<UnattendedGap> {
6305    let Some(found) = found else {
6306        return (mode == StartMode::Login).then(|| UnattendedGap {
6307            reason: "This registration starts at login, so the agent does not run until the \
6308                     operator signs in; this host does not resume work after an unattended \
6309                     reboot."
6310                .to_owned(),
6311            remedy: "For machine-on operation, move the service and credential to `--start-at \
6312                     boot` from an elevated terminal."
6313                .to_owned(),
6314        });
6315    };
6316    let resume = crate::unattended_login::resume(found, mode, account);
6317    if matches!(resume, crate::unattended_login::Resume::Unknown(_)) {
6318        // `host doctor` says what could not be read; a note here would claim
6319        // a gap nobody has seen.
6320        return None;
6321    }
6322    Some(UnattendedGap {
6323        reason: resume.explain(account)?,
6324        remedy: resume.remedy().unwrap_or_else(|| {
6325            "Run `runner-manager host doctor` on the Mac's desktop, where automatic login and \
6326             FileVault can be read."
6327                .to_owned()
6328        }),
6329    })
6330}
6331
6332impl ServiceStatus {
6333    /// `record_refused` carries a [`ServiceError::RecordNotPermitted`] when
6334    /// there **is** a record and this account may not read it, which is not the
6335    /// same state as having none and must not be reported as one. See
6336    /// [`Operations::status`].
6337    fn compose(
6338        identity: ServiceIdentity,
6339        record: Option<InstallRecord>,
6340        record_refused: Option<&ServiceError>,
6341        registration: Option<Registration>,
6342        last_github_contact: Option<DateTime<Utc>>,
6343        paths: &AppPaths,
6344    ) -> Self {
6345        let mut problems = Vec::new();
6346        let mut notes = Vec::new();
6347
6348        if let Some(refusal) = record_refused {
6349            // A problem rather than a note: `is_healthy` stays false, because
6350            // an operator who cannot read their own install record does have
6351            // something to fix. It is just not a reason to withhold everything
6352            // the service manager knows.
6353            problems.push(StatusProblem {
6354                subject: "install record",
6355                detail: refusal.to_string(),
6356            });
6357        }
6358
6359        let log_file = record.as_ref().map_or_else(
6360            || ServiceDirectories::of(paths).log_file(),
6361            |record| record.log_file.clone(),
6362        );
6363
6364        let binary = record.as_ref().map(|record| {
6365            inspect_binary(
6366                &record.binary,
6367                registration
6368                    .as_ref()
6369                    .and_then(Registration::binary)
6370                    .as_deref(),
6371            )
6372        });
6373        if let Some(state) = &binary
6374            && state.is_error()
6375        {
6376            problems.push(StatusProblem {
6377                subject: "binary",
6378                detail: state.to_string(),
6379            });
6380        }
6381
6382        match (&record, &registration) {
6383            (Some(_), None) => problems.push(StatusProblem {
6384                subject: "registration",
6385                detail: "this host has a service record but no service manager knows the \
6386                         registration. Run `service install` again; it deletes nothing."
6387                    .to_string(),
6388            }),
6389            // Not reported when the record was refused rather than absent: the
6390            // problem above already names the real state, and "there is no
6391            // install record" would be a second, wrong diagnosis of the same
6392            // file, with a `service uninstall` in its remedy.
6393            (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
6394                subject: "record",
6395                detail: format!(
6396                    "{} holds a registration for this service but there is no install record, so \
6397                     the path it was installed from and the directories it was installed against \
6398                     are unknown. Run `service uninstall` and `service install`.",
6399                    found.manager
6400                ),
6401            }),
6402            (None, Some(_)) => {}
6403            (Some(record), Some(found)) => {
6404                if record.start_mode != found.start_mode {
6405                    problems.push(StatusProblem {
6406                        subject: "start mode",
6407                        detail: format!(
6408                            "the record says {} and {} holds a {} registration. Switch the start \
6409                             mode again to make them agree.",
6410                            record.start_mode, found.manager, found.start_mode
6411                        ),
6412                    });
6413                }
6414                if record.start_mode == StartMode::Boot && !found.starts_automatically {
6415                    problems.push(StatusProblem {
6416                        subject: "start mode",
6417                        detail: format!(
6418                            "{} holds the registration but will not start it by itself, so this \
6419                             host does not resume work after a reboot.",
6420                            found.manager
6421                        ),
6422                    });
6423                }
6424                if let Some(actual) = found.restart_delay {
6425                    let expected = record.restart().effective_delay(found.manager);
6426                    if actual != expected {
6427                        problems.push(StatusProblem {
6428                            subject: "restart policy",
6429                            detail: format!(
6430                                "the record says the service restarts after {}s and {} reports \
6431                                 {}s. Something has edited the registration since it was \
6432                                 installed.",
6433                                expected.as_secs(),
6434                                found.manager,
6435                                actual.as_secs()
6436                            ),
6437                        });
6438                    } else if expected != record.restart().delay() {
6439                        // Not a fault. Task Scheduler expresses this in whole
6440                        // minutes, so the delay in force is longer than the one
6441                        // asked for -- never shorter, which is the direction
6442                        // the requirement cares about.
6443                        notes.push(format!(
6444                            "{} expresses the restart delay in whole minutes, so the {}s asked \
6445                             for is enforced as {}s. The service therefore never restarts faster \
6446                             than the configured bound.",
6447                            found.manager,
6448                            record.restart().delay().as_secs(),
6449                            expected.as_secs()
6450                        ));
6451                    }
6452                }
6453                if found.starts_automatically && !found.running {
6454                    problems.push(StatusProblem {
6455                        subject: "runtime",
6456                        detail: format!(
6457                            "{} holds an automatic registration, but the daemon is stopped. Run \
6458                             `runner-manager service install --start-at {}`; if the service \
6459                             manager denies replacement, retry from an elevated terminal. If it \
6460                             stops again, inspect {}.",
6461                            found.manager,
6462                            found.start_mode,
6463                            log_file.display()
6464                        ),
6465                    });
6466                }
6467            }
6468            (None, None) => {}
6469        }
6470
6471        // The two facts here really are independent, which is what `f1`'s
6472        // `status.rs` says this check needs and could not have there: the scope
6473        // is derived from the mode the **record** carries, and it is compared
6474        // against the mode the **service manager** actually holds.
6475        let store = record.as_ref().and_then(|record| {
6476            let registered_mode = registration
6477                .as_ref()
6478                .map_or(record.start_mode, |found| found.start_mode);
6479            crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
6480                .ok()
6481                .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
6482        });
6483        if let Some(store) = &store
6484            && !store.agrees_with_start_mode()
6485        {
6486            problems.push(StatusProblem {
6487                subject: "secret store",
6488                detail: format!(
6489                    "{store}. Run `auth login` again so the token is stored where the registered \
6490                     start mode can read it."
6491                ),
6492            });
6493        }
6494
6495        if last_github_contact.is_none() {
6496            notes.push(
6497                "GitHub has not been reached successfully since this host's state directory was \
6498                 created."
6499                    .to_string(),
6500            );
6501        }
6502
6503        // The privileged inspection output. Read-only, tolerant of an account
6504        // that may not read the descriptor at all, and never a `problem`: a
6505        // root this account cannot inspect is a true statement about this
6506        // account's rights, not a fault in the registration. What refuses an
6507        // install is the preflight in `runner_root_access`, which runs as the
6508        // installing account and has the authority to know.
6509        let runner_root = crate::runner_root::default_runner_root(paths)
6510            .ok()
6511            .map(|root| {
6512                let path = root.as_path().to_path_buf();
6513                let report = crate::runner_root_access::report(&path);
6514                (path, report)
6515            });
6516        // Reported, and deliberately not a `problem`. The Definition of Done
6517        // asks for a broad root to be "reported and fail the security
6518        // preflight", and the security preflight is `install`'s -- which runs
6519        // as the installing account and refuses outright. `service status` runs
6520        // as whoever typed it, is expected to be readable on a host with
6521        // nothing installed at all, and drives an exit code; turning a
6522        // directory that predates this feature into a non-zero exit for a
6523        // machine that has never installed the service would report a fault
6524        // that is not this registration's.
6525        if let Some((
6526            path,
6527            RootAccessReport::Present {
6528                broad_write: true, ..
6529            },
6530        )) = &runner_root
6531        {
6532            notes.push(format!(
6533                "the platform default runner root {} can be written by ordinary local users, so \
6534                 it is not a safe place to run jobs. `service install` refuses it rather than \
6535                 tightening it, because the contents of a directory anybody could write cannot \
6536                 be trusted: remove or empty it, or choose another root with `runner-manager \
6537                 host set-runtime-root --path <PATH>`.",
6538                path.display()
6539            ));
6540        }
6541
6542        // The surface the agent has nowhere else to reach. A root that refuses
6543        // a launch does so before any attempt row exists, and the daemon's log
6544        // redacts the paths out of the sentence, so without this the operator
6545        // sees `runner_start_failed reason=other` once per poll and nothing
6546        // that names the directory or the fix.
6547        //
6548        // A **note** and not a problem, by the same rule the broad-write root
6549        // above obeys: `service status` runs as whoever typed it, is expected
6550        // to be readable on a host with nothing installed at all, and drives an
6551        // exit code. A problem here would fail that exit code on three hosts
6552        // that are not broken -- one whose root was fixed but which has had no
6553        // queued job since, one whose service was uninstalled without clearing
6554        // `state/`, and one that only ever ran the agent in the foreground --
6555        // and the printed remedy (`service uninstall && service install`) does
6556        // not touch `state/`, so following it would not clear the error either.
6557        // A note is read by the same operator at the same moment and traps
6558        // nobody in a loop.
6559        //
6560        // A read failure is reported and not swallowed, for the reason
6561        // `runner_root_refusals` documents: absence means "every policy is
6562        // placing runners", and answering a parse failure with that would be
6563        // the wrong answer to the question this row exists for.
6564        match runner_root_refusals(paths) {
6565            Ok(refusals) => {
6566                for refusal in refusals {
6567                    notes.push(format!(
6568                        "policy {} started no runner: its runner root {} refused the launch \
6569                         ({}), last at {}. {} This clears when that policy next places a \
6570                         runner.",
6571                        refusal.policy,
6572                        refusal.root,
6573                        refusal.kind,
6574                        refusal.at.to_rfc3339(),
6575                        refusal.detail,
6576                    ));
6577                }
6578            }
6579            Err(error) => notes.push(format!(
6580                "whether the agent could use its runner roots could not be read: {error}"
6581            )),
6582        }
6583
6584        Self {
6585            identity,
6586            record,
6587            registration,
6588            binary,
6589            log_file,
6590            store,
6591            last_github_contact,
6592            runner_root,
6593            credential_rejected_since: None,
6594            launches_blocked: None,
6595            unattended_gap: None,
6596            foreign: None,
6597            problems,
6598            notes,
6599        }
6600    }
6601
6602    /// Marks a status read from another installation's directories as that
6603    /// installation's. See [`ForeignService`].
6604    ///
6605    /// Its problems were written for its owner, and their remedies -- `service
6606    /// install`, `auth login`, re-registering a drifted definition -- act on
6607    /// the directories of whoever runs them. Printed to this account, which
6608    /// reaches here precisely by following "run it elevated", they would
6609    /// re-register the machine's service against this account's data root.
6610    /// So each keeps its diagnosis and loses its remedy, and says where the
6611    /// remedy has to be run instead.
6612    fn with_foreign(mut self, foreign: ForeignService) -> Self {
6613        let act_as = foreign.act_as();
6614        for problem in &mut self.problems {
6615            problem.detail = format!(
6616                "{}. Act on it {act_as}: a remedy run from this account acts on this account's \
6617                 own directories, not the service's.",
6618                diagnosis_only(&problem.detail)
6619            );
6620        }
6621        if let Some(note) = foreign.unused_local_note() {
6622            self.notes.push(note);
6623        }
6624        self.foreign = Some(foreign);
6625        self
6626    }
6627
6628    /// A registration made from another data root whose records this account
6629    /// cannot read: what the service manager reports, and nothing else.
6630    ///
6631    /// The only problems are ones the service manager alone settles -- an
6632    /// automatic registration that is stopped, a boot registration that will
6633    /// not start by itself -- and their remedy is to look as the owner, never
6634    /// to reinstall from here.
6635    fn foreign_unread(
6636        identity: ServiceIdentity,
6637        registration: Registration,
6638        foreign: ForeignService,
6639    ) -> Self {
6640        let mut problems = Vec::new();
6641        let look = foreign.where_to_look();
6642        if registration.starts_automatically && !registration.running {
6643            problems.push(StatusProblem {
6644                subject: "runtime",
6645                detail: format!(
6646                    "{} holds an automatic registration for the service installed from {}, but \
6647                     it is stopped. Run `runner-manager service status` {look} to see why; do not \
6648                     reinstall it from this account.",
6649                    registration.manager,
6650                    foreign.installed_by(),
6651                ),
6652            });
6653        }
6654        if registration.start_mode == StartMode::Boot && !registration.starts_automatically {
6655            problems.push(StatusProblem {
6656                subject: "start mode",
6657                detail: format!(
6658                    "{} holds the boot registration installed from {} but will not start it by \
6659                     itself. Run `runner-manager service status` {look}.",
6660                    registration.manager,
6661                    foreign.installed_by(),
6662                ),
6663            });
6664        }
6665        let mut notes = Vec::new();
6666        if let Some(reason) = &foreign.unreadable {
6667            notes.push(format!(
6668                "its health is not known here: {reason}. Run `runner-manager service status` \
6669                 {look} to see it."
6670            ));
6671        }
6672        notes.extend(foreign.unused_local_note());
6673        Self {
6674            identity,
6675            record: None,
6676            log_file: foreign.directories.log_file(),
6677            registration: Some(registration),
6678            binary: None,
6679            store: None,
6680            last_github_contact: None,
6681            runner_root: None,
6682            credential_rejected_since: None,
6683            launches_blocked: None,
6684            unattended_gap: None,
6685            foreign: Some(foreign),
6686            problems,
6687            notes,
6688        }
6689    }
6690
6691    /// The other installation this machine's service belongs to, when it is
6692    /// not this account's. See [`ForeignService`].
6693    #[must_use]
6694    pub const fn foreign(&self) -> Option<&ForeignService> {
6695        self.foreign.as_ref()
6696    }
6697
6698    /// Whether this is another installation's service whose own records this
6699    /// account could not read, so only the service manager's facts are here.
6700    #[must_use]
6701    pub fn is_unread(&self) -> bool {
6702        self.foreign.as_ref().is_some_and(ForeignService::is_unread)
6703    }
6704
6705    /// The one-word-or-so verdict `service status` ends with.
6706    #[must_use]
6707    pub fn verdict(&self) -> String {
6708        if !self.is_healthy() {
6709            return "NOT healthy".to_owned();
6710        }
6711        match &self.foreign {
6712            Some(foreign) if foreign.is_unread() => format!(
6713                "{}; installed from {}, so its health is read {}",
6714                if self.is_running() {
6715                    "running"
6716                } else {
6717                    "not running"
6718                },
6719                foreign.installed_by(),
6720                foreign.where_to_look()
6721            ),
6722            _ => "healthy".to_owned(),
6723        }
6724    }
6725
6726    /// Reports a definition this build would render differently.
6727    #[must_use]
6728    pub fn with_definition_drift(mut self, drift: Option<DefinitionDrift>) -> Self {
6729        if let Some(drift) = drift {
6730            if drift.manager == DefinitionKind::LaunchdPlist {
6731                // A restart, not `service install`: the running daemon holds the
6732                // single-instance lock, which refuses an install, and a daemon
6733                // that starts with no runner repairs the plist itself.
6734                let restart = match drift.start_mode {
6735                    StartMode::Boot => format!(
6736                        "sudo launchctl kickstart -k system/{}",
6737                        self.identity.launchd_label()
6738                    ),
6739                    StartMode::Login => format!(
6740                        "launchctl kickstart -k gui/$(id -u)/{}",
6741                        self.identity.launchd_label()
6742                    ),
6743                };
6744                self.problems.push(StatusProblem {
6745                    subject: "definition",
6746                    detail: format!(
6747                        "{} was written by runner-manager {} with a `ProcessType` other than \
6748                         `{LAUNCHD_PROCESS_TYPE}`, which launchd applies to every runner the \
6749                         daemon starts, so their jobs run below normal priority. The daemon \
6750                         rewrites it and reloads itself when it starts with no runner, which it \
6751                         does after every upgrade; to do it now, restart the service while no \
6752                         job is running: `{restart}`. Configuration and credentials are kept.",
6753                        drift.path.display(),
6754                        drift.installed_by_version,
6755                    ),
6756                });
6757                return self;
6758            }
6759            let repair = match drift.start_mode {
6760                StartMode::Boot => format!(
6761                    "`sudo systemctl stop runner-manager && {}` (for a managed WSL host, run \
6762                     `runner-manager wsl install --distribution <name>` from Windows)",
6763                    sudo_command("service install --start-at boot")
6764                ),
6765                StartMode::Login => "`systemctl --user stop runner-manager && runner-manager \
6766                                     service install --start-at login`"
6767                    .to_owned(),
6768            };
6769            self.problems.push(StatusProblem {
6770                subject: "definition",
6771                detail: format!(
6772                    "{} was written by runner-manager {} and differs from what {} renders, so \
6773                     the daemon runs under outdated settings (for example a credential frozen \
6774                     at service start). Re-register it with {repair}; configuration and \
6775                     credentials are kept.",
6776                    drift.path.display(),
6777                    drift.installed_by_version,
6778                    env!("CARGO_PKG_VERSION"),
6779                ),
6780            });
6781        }
6782        self
6783    }
6784
6785    /// Reports that GitHub is rejecting the daemon's credential.
6786    #[must_use]
6787    pub fn with_credential_rejection(mut self, since: Option<DateTime<Utc>>) -> Self {
6788        if let Some(since) = since {
6789            self.credential_rejected_since = Some(since);
6790            self.problems.push(StatusProblem {
6791                subject: "github credential",
6792                detail: format!(
6793                    "GitHub has rejected the service's credential since {}, so the service \
6794                     cannot see queued jobs or start runners. Run `runner-manager auth login` \
6795                     (for a managed WSL host, `runner-manager --host wsl:<name> auth login` \
6796                     from Windows).",
6797                    since.to_rfc3339()
6798                ),
6799            });
6800        }
6801        self
6802    }
6803
6804    /// Since when GitHub has rejected the service's credential, if it has.
6805    #[must_use]
6806    pub const fn credential_rejected_since(&self) -> Option<DateTime<Utc>> {
6807        self.credential_rejected_since
6808    }
6809
6810    /// Reports that the daemon starts no runner because its launches are
6811    /// blocked. See [`crate::launch_health`].
6812    #[must_use]
6813    pub fn with_launches_blocked(
6814        mut self,
6815        blocked: Option<crate::launch_health::LaunchesBlocked>,
6816    ) -> Self {
6817        if let Some(blocked) = &blocked {
6818            self.problems.push(StatusProblem {
6819                subject: LAUNCHES_BLOCKED_SUBJECT,
6820                detail: format!(
6821                    "the service has started no runner {blocked}. Fix: {}.",
6822                    blocked.remedy
6823                ),
6824            });
6825        }
6826        self.launches_blocked = blocked;
6827        self
6828    }
6829
6830    /// Why the service starts no runner, when its launches are blocked.
6831    #[must_use]
6832    pub const fn launches_blocked(&self) -> Option<&crate::launch_health::LaunchesBlocked> {
6833        self.launches_blocked.as_ref()
6834    }
6835
6836    /// Reports that the daemon cannot read its own stored credential, and the
6837    /// exact command that ends it. See [`record_credential_unreadable`].
6838    #[must_use]
6839    pub fn with_credential_unreadable(mut self, since: Option<DateTime<Utc>>) -> Self {
6840        if let Some(since) = since {
6841            let mode = self.start_mode().unwrap_or_default();
6842            self.problems.push(StatusProblem {
6843                subject: CREDENTIAL_UNREADABLE_SUBJECT,
6844                detail: format!(
6845                    "the service has been unable to read its stored GitHub credential since {}, \
6846                     so it starts no runner; the diagnostic log has the store's own answer.{} \
6847                     Sign in again: {}.",
6848                    since.to_rfc3339(),
6849                    if cfg!(target_os = "macos") {
6850                        " That is what a keychain item written by a different build looks like."
6851                    } else {
6852                        ""
6853                    },
6854                    sign_in_instruction(mode)
6855                ),
6856            });
6857        }
6858        self
6859    }
6860
6861    /// What this host's default runner root grants, and to whom.
6862    ///
6863    /// `None` when the platform default could not even be resolved. The
6864    /// descriptor inside has been through
6865    /// [`crate::runner_root_access::redact`], so it names the well-known
6866    /// trustees and says "an account" for everything else — no more identity
6867    /// than the `account` line above it already prints.
6868    #[must_use]
6869    pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
6870        self.runner_root
6871            .as_ref()
6872            .map(|(path, report)| (path.as_path(), report))
6873    }
6874
6875    /// Whether a registration exists at all.
6876    #[must_use]
6877    pub const fn is_installed(&self) -> bool {
6878        self.registration.is_some() || self.record.is_some()
6879    }
6880
6881    /// Whether the daemon is running now.
6882    #[must_use]
6883    pub fn is_running(&self) -> bool {
6884        self.registration
6885            .as_ref()
6886            .is_some_and(|registration| registration.running)
6887    }
6888
6889    /// **False whenever anything is wrong**, including a stale binary path.
6890    #[must_use]
6891    pub fn is_healthy(&self) -> bool {
6892        self.problems.is_empty()
6893    }
6894
6895    /// Everything that is wrong.
6896    #[must_use]
6897    pub fn problems(&self) -> &[StatusProblem] {
6898        &self.problems
6899    }
6900
6901    /// True statements that are not faults — a login-mode registration not
6902    /// running unattended, or an agent that has not yet reached GitHub.
6903    #[must_use]
6904    pub fn notes(&self) -> &[String] {
6905        &self.notes
6906    }
6907
6908    /// Reports that the daemon starts no runner because a required host check
6909    /// fails from its own session. That is the service not doing its job, so
6910    /// it is a problem: a host whose daemon refused every runner used to read
6911    /// `verdict healthy`.
6912    #[must_use]
6913    pub fn with_host_unfit(mut self, unfit: Option<crate::host_fitness::HostUnfitRecord>) -> Self {
6914        if let Some(unfit) = unfit {
6915            self.problems.push(StatusProblem {
6916                subject: HOST_UNFIT_SUBJECT,
6917                detail: format!(
6918                    "the service has started no runner since {}, because a required host check \
6919                     fails from its own session: {}",
6920                    unfit.since.to_rfc3339(),
6921                    unfit.describe()
6922                ),
6923            });
6924        }
6925        self
6926    }
6927
6928    /// Reports a daemon that is running and has stopped making progress. See
6929    /// [`crate::daemon_heartbeat`]: launchd, systemd and the SCM all report
6930    /// such a daemon running, so this used to read `verdict healthy`.
6931    #[must_use]
6932    pub fn with_stall(mut self, stall: Option<String>) -> Self {
6933        if let Some(detail) = stall {
6934            self.problems.push(StatusProblem {
6935                subject: DAEMON_STALLED_SUBJECT,
6936                detail,
6937            });
6938        }
6939        self
6940    }
6941
6942    /// Records whether this registration comes back by itself after an
6943    /// unattended restart, as a note. See [`unattended_gap`].
6944    #[must_use]
6945    pub fn with_unattended_gap(mut self, gap: Option<UnattendedGap>) -> Self {
6946        if let Some(gap) = &gap {
6947            self.notes.push(gap.reason.clone());
6948        }
6949        self.unattended_gap = gap;
6950        self
6951    }
6952
6953    /// Why this registration does not come back by itself after an
6954    /// unattended restart, when it does not.
6955    #[must_use]
6956    pub const fn unattended_gap(&self) -> Option<&UnattendedGap> {
6957        self.unattended_gap.as_ref()
6958    }
6959
6960    /// The service manager's command that restarts this registration, for its
6961    /// start mode (boot when unknown). See [`ServiceIdentity::restart_command`].
6962    #[must_use]
6963    pub fn restart_command(&self) -> String {
6964        self.identity
6965            .restart_command(self.start_mode().unwrap_or(StartMode::Boot))
6966    }
6967
6968    /// The recorded start mode. Journey 5 step 4. For another installation's
6969    /// service whose record this account cannot read, the mode the service
6970    /// manager's domain implies.
6971    #[must_use]
6972    pub fn start_mode(&self) -> Option<StartMode> {
6973        self.record
6974            .as_ref()
6975            .map(|record| record.start_mode)
6976            .or_else(|| {
6977                self.registration
6978                    .as_ref()
6979                    .filter(|_| self.is_unread())
6980                    .map(|registration| registration.start_mode)
6981            })
6982    }
6983
6984    /// The binary the service runs: the recorded one, or for another
6985    /// installation's service whose record this account cannot read, the one
6986    /// the service manager names.
6987    #[must_use]
6988    pub fn service_binary(&self) -> Option<PathBuf> {
6989        self.record
6990            .as_ref()
6991            .map(|record| record.binary.clone())
6992            .or_else(|| {
6993                self.registration
6994                    .as_ref()
6995                    .filter(|_| self.is_unread())
6996                    .and_then(Registration::binary)
6997            })
6998    }
6999
7000    /// The resolved absolute binary path and what became of it. Journey 5
7001    /// step 4 and `05-infrastructure.md` item 6.
7002    #[must_use]
7003    pub const fn binary(&self) -> Option<&BinaryPath> {
7004        self.binary.as_ref()
7005    }
7006
7007    /// The diagnostic log path. `05-infrastructure.md` item 4.
7008    #[must_use]
7009    pub fn log_file(&self) -> &Path {
7010        &self.log_file
7011    }
7012
7013    /// The last successful GitHub contact. Journey 5 step 4.
7014    #[must_use]
7015    pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
7016        self.last_github_contact
7017    }
7018
7019    /// Which store the daemon reads, and whether that agrees with the
7020    /// registration.
7021    #[must_use]
7022    pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
7023        self.store.as_ref()
7024    }
7025
7026    /// The record `install` wrote.
7027    #[must_use]
7028    pub const fn record(&self) -> Option<&InstallRecord> {
7029        self.record.as_ref()
7030    }
7031
7032    /// What the service manager says.
7033    #[must_use]
7034    pub const fn registration(&self) -> Option<&Registration> {
7035        self.registration.as_ref()
7036    }
7037}
7038
7039impl fmt::Display for ServiceStatus {
7040    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
7041        writeln!(f, "Service: {}", self.identity)?;
7042        match (&self.record, &self.registration) {
7043            (None, None) => {
7044                writeln!(
7045                    f,
7046                    "  installed                 no. `service install` registers `{} {}`.",
7047                    SERVICE_NAME,
7048                    DAEMON_ARGUMENTS.join(" ")
7049                )?;
7050            }
7051            _ => {
7052                let manager = self
7053                    .registration
7054                    .as_ref()
7055                    .map(|registration| registration.manager.manager());
7056                writeln!(
7057                    f,
7058                    "  installed                 {}",
7059                    manager.unwrap_or("yes, but no service manager knows it")
7060                )?;
7061                writeln!(
7062                    f,
7063                    "  state                     {}",
7064                    if self.is_running() {
7065                        "running"
7066                    } else {
7067                        "not running"
7068                    }
7069                )?;
7070            }
7071        }
7072        if let Some(foreign) = &self.foreign {
7073            writeln!(
7074                f,
7075                "  installed from            {} -- another installation's, not this account's ({})",
7076                foreign.installed_by(),
7077                foreign.local_root.display()
7078            )?;
7079            // Without the record, the service manager's own facts.
7080            if foreign.is_unread()
7081                && let Some(registration) = &self.registration
7082            {
7083                writeln!(f, "  start mode                {}", registration.start_mode)?;
7084                if let Some(account) = &registration.account {
7085                    writeln!(f, "  account                   {account}")?;
7086                }
7087                if let Some(binary) = self.service_binary() {
7088                    writeln!(f, "  binary                    {}", binary.display())?;
7089                }
7090            }
7091        }
7092        if let Some(record) = &self.record {
7093            writeln!(f, "  start mode                {}", record.start_mode)?;
7094            let recorded = record.account.to_string();
7095            let account = registered_account(self.registration.as_ref(), &recorded);
7096            writeln!(f, "  account                   {account}")?;
7097            writeln!(f, "  restart on failure        {}", record.restart())?;
7098            writeln!(
7099                f,
7100                "  arguments                 {}",
7101                record.arguments.join(" ")
7102            )?;
7103        }
7104        if let Some(binary) = &self.binary {
7105            writeln!(f, "  binary                    {binary}")?;
7106        }
7107        writeln!(f, "  diagnostic log            {}", self.log_file.display())?;
7108        if let Some((path, report)) = &self.runner_root {
7109            // Named as the *default* rather than as "the runner root", because
7110            // it is only the effective one until an operator runs
7111            // `host set-runtime-root`. This crate cannot see that setting — it
7112            // lives in the application's store — so an unqualified label here
7113            // would contradict the `runner root … (host-configured)` row that
7114            // `status` and `host show` print from the value that is in force.
7115            writeln!(f, "  default runner root       {}", path.display())?;
7116            if *report != RootAccessReport::NotApplicable {
7117                writeln!(f, "  default root access       {report}")?;
7118            }
7119        }
7120        if let Some(store) = &self.store {
7121            writeln!(f, "  secret store              {store}")?;
7122        }
7123        // Not known, rather than "never", for a service whose records were not
7124        // read: its contact file is in a directory this account cannot open.
7125        if !self.is_unread() {
7126            writeln!(
7127                f,
7128                "  last GitHub contact       {}",
7129                match self.last_github_contact {
7130                    Some(at) => at.to_rfc3339(),
7131                    None => "never".to_string(),
7132                }
7133            )?;
7134        }
7135        for note in &self.notes {
7136            writeln!(f, "  note                      {note}")?;
7137        }
7138        for problem in &self.problems {
7139            writeln!(f, "  ERROR                     {problem}")?;
7140        }
7141        write!(f, "  verdict                   {}", self.verdict())
7142    }
7143}
7144
7145// ---------------------------------------------------------------------------
7146// The in-memory double
7147// ---------------------------------------------------------------------------
7148
7149/// A [`ControlFactory`] that registers nothing and remembers everything.
7150///
7151/// Public rather than `#[cfg(test)]` on purpose. `f3` builds three commands on
7152/// [`ServiceOperations`] and has to be able to test them without a service
7153/// manager, and a double that lived behind `#[cfg(test)]` here would be
7154/// invisible from another crate — which would leave `f3` either untested or
7155/// writing a second double that drifts from this one.
7156///
7157/// It is not a simulator. It stores what it was asked to register and reports it
7158/// back, which is exactly enough to exercise the logic that sits *above* a
7159/// service manager: the lock refusal, the record, the stale-path detection, the
7160/// start-mode switch, and the promise that uninstall deletes nothing else.
7161#[derive(Debug, Clone, Default)]
7162pub struct RecordingControls {
7163    state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
7164}
7165
7166#[derive(Debug, Default)]
7167struct RecordingState {
7168    registrations: BTreeMap<(StartMode, String), Registration>,
7169    definitions: BTreeMap<String, ServiceDefinition>,
7170    calls: Vec<String>,
7171    #[cfg(test)]
7172    install_failures: BTreeMap<StartMode, String>,
7173    #[cfg(test)]
7174    after_install: BTreeMap<StartMode, TestInstallSideEffect>,
7175}
7176
7177#[cfg(test)]
7178#[derive(Debug, Clone)]
7179enum TestInstallSideEffect {
7180    HideDirectory { directory: PathBuf, hidden: PathBuf },
7181}
7182
7183impl RecordingControls {
7184    /// A factory holding no registrations.
7185    #[must_use]
7186    pub fn new() -> Self {
7187        Self::default()
7188    }
7189
7190    /// Every call made through this factory, in order, as
7191    /// `"<operation> <name> (<mode>)"`.
7192    #[must_use]
7193    pub fn calls(&self) -> Vec<String> {
7194        self.state.lock().expect("not poisoned").calls.clone()
7195    }
7196
7197    /// Every registration currently held, with the domain it is in.
7198    #[must_use]
7199    pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
7200        self.state
7201            .lock()
7202            .expect("not poisoned")
7203            .registrations
7204            .iter()
7205            .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
7206            .collect()
7207    }
7208
7209    /// The definition applied for a registration, if it is held.
7210    #[must_use]
7211    pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
7212        self.state
7213            .lock()
7214            .expect("not poisoned")
7215            .definitions
7216            .get(name)
7217            .cloned()
7218    }
7219
7220    /// Edits a held registration, as something outside this product would.
7221    ///
7222    /// This is how the divergence and start-type problems are made reachable
7223    /// from a test: `sc config`, `launchctl` and `systemctl` can all change a
7224    /// registration after installation, and a status command that could not be
7225    /// shown detecting that would be a status command nobody had tested against
7226    /// the case it exists for.
7227    ///
7228    /// Does nothing when no registration of that name is held.
7229    pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
7230        let mut state = self.state.lock().expect("not poisoned");
7231        if let Some((_, registration)) = state
7232            .registrations
7233            .iter_mut()
7234            .find(|((_, held), _)| held == name)
7235        {
7236            edit(registration);
7237        }
7238    }
7239
7240    #[cfg(test)]
7241    fn fail_next_install(&self, mode: StartMode, detail: &str) {
7242        self.state
7243            .lock()
7244            .expect("not poisoned")
7245            .install_failures
7246            .insert(mode, detail.to_string());
7247    }
7248
7249    #[cfg(test)]
7250    fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
7251        self.state
7252            .lock()
7253            .expect("not poisoned")
7254            .after_install
7255            .insert(
7256                mode,
7257                TestInstallSideEffect::HideDirectory { directory, hidden },
7258            );
7259    }
7260}
7261
7262impl ControlFactory for RecordingControls {
7263    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7264        Ok(Box::new(RecordingControl {
7265            mode,
7266            state: std::sync::Arc::clone(&self.state),
7267        }))
7268    }
7269}
7270
7271#[derive(Debug)]
7272struct RecordingControl {
7273    mode: StartMode,
7274    state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
7275}
7276
7277impl RecordingControl {
7278    fn note(&self, operation: &str, name: &str) {
7279        self.state
7280            .lock()
7281            .expect("not poisoned")
7282            .calls
7283            .push(format!("{operation} {name} ({})", self.mode));
7284    }
7285}
7286
7287impl ServiceControl for RecordingControl {
7288    fn manager(&self) -> DefinitionKind {
7289        // The double reports the kind this host's real backend would, so a test
7290        // that asserts on the manager name asserts something true of the
7291        // platform it is running on.
7292        host_definition_kind(self.mode)
7293    }
7294
7295    fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7296        self.note("install", plan.identity().name());
7297        #[cfg(test)]
7298        if let Some(detail) = self
7299            .state
7300            .lock()
7301            .expect("not poisoned")
7302            .install_failures
7303            .remove(&self.mode)
7304        {
7305            return Err(ServiceError::Control {
7306                operation: "install",
7307                name: plan.identity().name().to_string(),
7308                manager: "recording control",
7309                detail,
7310            });
7311        }
7312        // The real definition, not a stub. Rendering is pure, so the double can
7313        // afford it -- and a stub would make `Installed::review` report that the
7314        // definition confirms nothing, which is exactly the shape of false
7315        // assurance a test double must never hand back.
7316        let definition = ServiceDefinition::for_host(plan)?;
7317        let mut state = self.state.lock().expect("not poisoned");
7318        state.registrations.insert(
7319            (self.mode, plan.identity().name().to_string()),
7320            Registration {
7321                manager: host_definition_kind(self.mode),
7322                start_mode: self.mode,
7323                command_line: plan.command_line(),
7324                account: (*plan.account() != ServiceAccount::InvokingUser)
7325                    .then(|| plan.account().as_str().to_string()),
7326                running: false,
7327                starts_automatically: true,
7328                restart_delay: Some(plan.restart().delay()),
7329            },
7330        );
7331        state
7332            .definitions
7333            .insert(plan.identity().name().to_string(), definition.clone());
7334        #[cfg(test)]
7335        let side_effect = state.after_install.remove(&self.mode);
7336        drop(state);
7337        #[cfg(test)]
7338        if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
7339            std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
7340            std::fs::write(&directory, b"blocks recreation")
7341                .expect("test fault can block record directory recreation");
7342        }
7343        Ok(definition)
7344    }
7345
7346    fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7347        self.note("uninstall", identity.name());
7348        let mut state = self.state.lock().expect("not poisoned");
7349        state.definitions.remove(identity.name());
7350        Ok(state
7351            .registrations
7352            .remove(&(self.mode, identity.name().to_string()))
7353            .is_some())
7354    }
7355
7356    fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7357        self.note("query", identity.name());
7358        Ok(self
7359            .state
7360            .lock()
7361            .expect("not poisoned")
7362            .registrations
7363            .get(&(self.mode, identity.name().to_string()))
7364            .cloned())
7365    }
7366
7367    fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7368        self.note("start", identity.name());
7369        let mut state = self.state.lock().expect("not poisoned");
7370        match state
7371            .registrations
7372            .get_mut(&(self.mode, identity.name().to_string()))
7373        {
7374            Some(registration) => {
7375                registration.running = true;
7376                Ok(())
7377            }
7378            None => Err(ServiceError::NotInstalled {
7379                name: identity.name().to_string(),
7380                operation: "start",
7381            }),
7382        }
7383    }
7384
7385    fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7386        self.note("stop", identity.name());
7387        let mut state = self.state.lock().expect("not poisoned");
7388        match state
7389            .registrations
7390            .get_mut(&(self.mode, identity.name().to_string()))
7391        {
7392            Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
7393            None => Err(ServiceError::NotInstalled {
7394                name: identity.name().to_string(),
7395                operation: "stop",
7396            }),
7397        }
7398    }
7399}
7400
7401impl ControlFactory for HostControls {
7402    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7403        sys::control(mode)
7404    }
7405
7406    fn installed_definition(&self, path: &Path) -> Option<String> {
7407        std::fs::read_to_string(path).ok()
7408    }
7409
7410    fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
7411        crate::unattended_login::probe()
7412    }
7413}
7414
7415/// The operator's home directory, when the platform reports one.
7416///
7417/// Only login-mode registrations need it — a LaunchAgent and a systemd user
7418/// unit both live under it — and it is resolved lazily so that a boot-mode
7419/// operation on an account with no profile is not refused for wanting something
7420/// it never uses.
7421#[must_use]
7422pub fn host_home() -> Option<PathBuf> {
7423    directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
7424}
7425
7426/// The Unix backends' name for the same thing.
7427#[cfg(unix)]
7428fn home_directory() -> Option<PathBuf> {
7429    host_home()
7430}
7431
7432/// Runs a command and returns its exit status with both streams as text.
7433///
7434/// The three Unix backends and the Windows Task Scheduler backend all drive a
7435/// stock command-line tool, and all four want the same three things back. The
7436/// tools are chosen for machine-readable, locale-independent output wherever one
7437/// exists — `systemctl is-active`, `launchctl print`, `schtasks /XML` — because
7438/// parsing a localised human-facing table is how a status command starts lying
7439/// on somebody else's machine.
7440fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
7441    let output = std::process::Command::new(program)
7442        .args(arguments)
7443        .output()?;
7444    Ok((
7445        output.status.success(),
7446        String::from_utf8_lossy(&output.stdout).into_owned(),
7447        String::from_utf8_lossy(&output.stderr).into_owned(),
7448    ))
7449}
7450
7451/// Which manager holds a given start-mode domain on the platform this binary
7452/// was built for.
7453#[must_use]
7454pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
7455    if cfg!(windows) {
7456        match mode {
7457            StartMode::Boot => DefinitionKind::WindowsService,
7458            StartMode::Login => DefinitionKind::WindowsScheduledTask,
7459        }
7460    } else if cfg!(target_os = "macos") {
7461        DefinitionKind::LaunchdPlist
7462    } else {
7463        DefinitionKind::SystemdUnit
7464    }
7465}
7466
7467/// Enables a launchd label after it has been bootstrapped.
7468///
7469/// `launchctl bootstrap` deliberately preserves a label's disabled bit. An
7470/// `enable` failure therefore means the new registration cannot satisfy its
7471/// start policy. Compensate by booting it out and removing the plist so a
7472/// failed install cannot look installed to `service status`.
7473#[cfg(any(target_os = "macos", test))]
7474fn enable_launchd_registration(
7475    mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
7476    domain: &str,
7477    service_target: &str,
7478    plist: &Path,
7479    name: &str,
7480    elevation_remedy: &'static str,
7481) -> Result<(), ServiceError> {
7482    let (enabled, cause) = launchctl(&[
7483        std::ffi::OsStr::new("enable"),
7484        std::ffi::OsStr::new(service_target),
7485    ]);
7486    if enabled {
7487        return Ok(());
7488    }
7489
7490    let (booted_out, bootout_detail) = launchctl(&[
7491        std::ffi::OsStr::new("bootout"),
7492        std::ffi::OsStr::new(service_target),
7493    ]);
7494    let removed = std::fs::remove_file(plist);
7495    if !booted_out || removed.is_err() {
7496        return Err(ServiceError::Rollback {
7497            operation: "enable launchd registration",
7498            name: name.to_string(),
7499            cause,
7500            rollback: format!(
7501                "launchctl bootout {domain}: {}; remove {}: {}",
7502                if booted_out {
7503                    "succeeded".to_string()
7504                } else {
7505                    bootout_detail
7506                },
7507                plist.display(),
7508                removed
7509                    .err()
7510                    .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
7511            ),
7512        });
7513    }
7514
7515    if cause.to_ascii_lowercase().contains("permission denied") {
7516        Err(ServiceError::NeedsElevation {
7517            operation: "enable",
7518            name: name.to_string(),
7519            detail: cause,
7520            remedy: elevation_remedy,
7521        })
7522    } else {
7523        Err(ServiceError::Control {
7524            operation: "enable",
7525            name: name.to_string(),
7526            manager: "launchd",
7527            detail: cause,
7528        })
7529    }
7530}
7531
7532// ---------------------------------------------------------------------------
7533// Windows
7534// ---------------------------------------------------------------------------
7535
7536/// A stop request delivered by the Windows Service Control Manager.
7537///
7538/// The application owns the drain policy; this platform boundary only turns
7539/// `SERVICE_CONTROL_STOP`/`SHUTDOWN` into an awaitable notification.
7540#[derive(Debug, Clone)]
7541pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
7542
7543impl ServiceShutdown {
7544    /// Waits until SCM asks the service to stop or shut down.
7545    pub async fn wait(mut self) {
7546        if !*self.0.borrow() {
7547            let _ = self.0.changed().await;
7548        }
7549    }
7550}
7551
7552/// Runs the production process as a real Windows service host.
7553///
7554/// `StartServiceCtrlDispatcher` must run in the service process's main thread,
7555/// so the callback is handed through a process-global slot to the entrypoint
7556/// invoked by SCM. The slot is single-use by design: one process hosts exactly
7557/// one own-process service.
7558#[cfg(windows)]
7559pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
7560where
7561    F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
7562{
7563    windows_host::run(Box::new(run))
7564}
7565
7566#[cfg(windows)]
7567mod windows_host {
7568    use std::ffi::OsString;
7569    use std::sync::{Arc, Mutex, OnceLock, mpsc};
7570    use std::time::Duration;
7571
7572    use windows_service::service::{
7573        ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
7574        ServiceType,
7575    };
7576    use windows_service::service_control_handler::{
7577        self, ServiceControlHandlerResult, ServiceStatusHandle,
7578    };
7579
7580    use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
7581
7582    type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
7583
7584    struct Invocation {
7585        run: Runner,
7586        result: mpsc::SyncSender<Result<u8, String>>,
7587    }
7588
7589    static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
7590
7591    windows_service::define_windows_service!(ffi_service_main, service_main);
7592
7593    pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
7594        let (result_tx, result_rx) = mpsc::sync_channel(1);
7595        let slot = INVOCATION.get_or_init(|| Mutex::new(None));
7596        let mut invocation = slot
7597            .lock()
7598            .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
7599        if invocation.is_some() {
7600            return Err(host_error(
7601                "prepare",
7602                "the service-host slot was already used",
7603            ));
7604        }
7605        *invocation = Some(Invocation {
7606            run,
7607            result: result_tx,
7608        });
7609        drop(invocation);
7610
7611        windows_service::service_dispatcher::start("", ffi_service_main)
7612            .map_err(|error| host_error("connect", &error.to_string()))?;
7613        result_rx
7614            .recv()
7615            .map_err(|error| host_error("finish", &error.to_string()))?
7616            .map_err(|detail| host_error("run", &detail))
7617    }
7618
7619    fn service_main(_arguments: Vec<OsString>) {
7620        let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
7621            return;
7622        };
7623        let result = run_service(invocation.run);
7624        let _ = invocation.result.send(result);
7625    }
7626
7627    fn run_service(run: Runner) -> Result<u8, String> {
7628        let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
7629        let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
7630        let handler_status = Arc::clone(&status);
7631        let handler = move |control| match control {
7632            ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
7633            ServiceControl::Stop | ServiceControl::Shutdown => {
7634                if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
7635                    let _ = handle.set_service_status(service_status(
7636                        ServiceState::StopPending,
7637                        ServiceControlAccept::empty(),
7638                        1,
7639                        Duration::from_secs(300),
7640                        0,
7641                    ));
7642                }
7643                let _ = stop_tx.send(true);
7644                ServiceControlHandlerResult::NoError
7645            }
7646            _ => ServiceControlHandlerResult::NotImplemented,
7647        };
7648        let handle = service_control_handler::register("", handler)
7649            .map_err(|error| format!("cannot register the service control handler: {error}"))?;
7650        *status
7651            .lock()
7652            .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
7653        handle
7654            .set_service_status(service_status(
7655                ServiceState::Running,
7656                ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
7657                0,
7658                Duration::default(),
7659                0,
7660            ))
7661            .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
7662
7663        let exit = run(ServiceShutdown(stop_rx));
7664        handle
7665            .set_service_status(service_status(
7666                ServiceState::Stopped,
7667                ServiceControlAccept::empty(),
7668                0,
7669                Duration::default(),
7670                u32::from(exit),
7671            ))
7672            .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
7673        Ok(exit)
7674    }
7675
7676    fn service_status(
7677        state: ServiceState,
7678        accepted: ServiceControlAccept,
7679        checkpoint: u32,
7680        wait_hint: Duration,
7681        exit: u32,
7682    ) -> ServiceStatus {
7683        ServiceStatus {
7684            service_type: ServiceType::OWN_PROCESS,
7685            current_state: state,
7686            controls_accepted: accepted,
7687            exit_code: if exit == 0 {
7688                ServiceExitCode::Win32(0)
7689            } else {
7690                ServiceExitCode::ServiceSpecific(exit)
7691            },
7692            checkpoint,
7693            wait_hint,
7694            process_id: None,
7695        }
7696    }
7697
7698    fn host_error(operation: &'static str, detail: &str) -> ServiceError {
7699        ServiceError::Control {
7700            operation,
7701            name: SERVICE_NAME.to_string(),
7702            manager: "the Windows Service Control Manager",
7703            detail: detail.to_string(),
7704        }
7705    }
7706}
7707
7708#[cfg(windows)]
7709mod sys {
7710    //! Two managers, because Windows has two answers.
7711    //!
7712    //! `--start-at boot` is a service in the Service Control Manager, which is
7713    //! the only Windows facility that starts something before anybody logs in.
7714    //! `--start-at login` is a Task Scheduler task with a logon trigger, which
7715    //! is the only Windows facility that starts something when somebody does.
7716    //! There is no single mechanism that does both: service trigger-start has no
7717    //! logon trigger, and a scheduled task cannot run before a session exists.
7718
7719    use std::ffi::{OsStr, OsString};
7720    use std::time::{Duration, Instant};
7721
7722    use runner_manager_domain::model::StartMode;
7723    use windows_service::service::{
7724        ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
7725        ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
7726        ServiceState, ServiceType,
7727    };
7728    use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
7729
7730    use super::{
7731        DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
7732        ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
7733    };
7734
7735    /// `ERROR_SERVICE_DOES_NOT_EXIST`. Not an error here: it is the answer to
7736    /// "is this registered".
7737    const SERVICE_DOES_NOT_EXIST: i32 = 1060;
7738    /// `ERROR_SERVICE_MARKED_FOR_DELETE`. `DeleteService` is asynchronous: the
7739    /// registration remains in this state until every open service handle is
7740    /// closed, and callers must not mistake that short window for a leak.
7741    const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
7742    /// `ERROR_ACCESS_DENIED`.
7743    const ACCESS_DENIED: i32 = 5;
7744    const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
7745    const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
7746
7747    const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
7748         choose \"Run as administrator\".";
7749
7750    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7751        Ok(match mode {
7752            StartMode::Boot => Box::new(ScmControl),
7753            StartMode::Login => Box::new(TaskControl),
7754        })
7755    }
7756
7757    // -- the Service Control Manager -----------------------------------------
7758
7759    #[derive(Debug)]
7760    struct ScmControl;
7761
7762    /// Turns a `windows-service` failure into this module's error, keeping the
7763    /// "needs elevation" case separate: an operator told "access is denied" and
7764    /// an operator told "run this elevated" are not equally well served.
7765    fn scm_error(
7766        operation: &'static str,
7767        name: &str,
7768        error: &windows_service::Error,
7769    ) -> ServiceError {
7770        let raw = match error {
7771            windows_service::Error::Winapi(io) => io.raw_os_error(),
7772            _ => None,
7773        };
7774        let detail = match error {
7775            windows_service::Error::Winapi(io) => io.to_string(),
7776            other => other.to_string(),
7777        };
7778        if raw == Some(ACCESS_DENIED) {
7779            return ServiceError::NeedsElevation {
7780                operation,
7781                name: name.to_string(),
7782                detail,
7783                remedy: ELEVATION_REMEDY,
7784            };
7785        }
7786        ServiceError::Control {
7787            operation,
7788            name: name.to_string(),
7789            manager: "the Windows Service Control Manager",
7790            detail,
7791        }
7792    }
7793
7794    fn open_manager(
7795        access: ServiceManagerAccess,
7796        operation: &'static str,
7797        name: &str,
7798    ) -> Result<ServiceManager, ServiceError> {
7799        ServiceManager::local_computer(None::<&OsStr>, access)
7800            .map_err(|error| scm_error(operation, name, &error))
7801    }
7802
7803    impl ServiceControl for ScmControl {
7804        fn manager(&self) -> DefinitionKind {
7805            DefinitionKind::WindowsService
7806        }
7807
7808        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7809            let spec = windows_service_spec(plan);
7810            let manager = open_manager(
7811                ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
7812                "install",
7813                &spec.name,
7814            )?;
7815            let info = ServiceInfo {
7816                name: OsString::from(&spec.name),
7817                display_name: OsString::from(&spec.display_name),
7818                // Never INTERACTIVE_PROCESS: see `windows_service_descriptor`.
7819                service_type: ServiceType::OWN_PROCESS,
7820                start_type: if spec.automatic_start {
7821                    ServiceStartType::AutoStart
7822                } else {
7823                    ServiceStartType::OnDemand
7824                },
7825                error_control: ServiceErrorControl::Normal,
7826                executable_path: plan.binary().to_path_buf(),
7827                launch_arguments: plan.arguments().to_vec(),
7828                dependencies: Vec::new(),
7829                // `None` is LocalSystem, which is the account `d2`'s DACL admits.
7830                account_name: spec.account.as_ref().map(OsString::from),
7831                // No password is ever taken, asked for, or stored. An account
7832                // that needed one would be an account this installer refuses.
7833                account_password: None,
7834            };
7835            let service = manager
7836                .create_service(
7837                    &info,
7838                    ServiceAccess::CHANGE_CONFIG
7839                        | ServiceAccess::QUERY_CONFIG
7840                        | ServiceAccess::QUERY_STATUS
7841                        | ServiceAccess::START
7842                        | ServiceAccess::STOP
7843                        | ServiceAccess::DELETE,
7844                )
7845                .map_err(|error| scm_error("install", &spec.name, &error))?;
7846            service
7847                .set_description(&spec.description)
7848                .map_err(|error| scm_error("describe", &spec.name, &error))?;
7849            service
7850                .update_failure_actions(ServiceFailureActions {
7851                    reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
7852                    reboot_msg: None,
7853                    command: None,
7854                    // Three identical restart actions rather than one: the
7855                    // Service Control Manager applies the first action to the
7856                    // first failure, the second to the second, and the last to
7857                    // every failure after that. One action would leave the
7858                    // second and later failures unhandled, which is a service
7859                    // that comes back once and then stays down.
7860                    actions: Some(vec![
7861                        ServiceAction {
7862                            action_type: ServiceActionType::Restart,
7863                            delay: spec.restart.delay(),
7864                        };
7865                        3
7866                    ]),
7867                })
7868                .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7869            // Without this the failure actions apply only to a crash, and a
7870            // daemon that exits non-zero is not a crash.
7871            service
7872                .set_failure_actions_on_non_crash_failures(true)
7873                .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7874            Ok(ServiceDefinition::windows_service(plan))
7875        }
7876
7877        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7878            let manager =
7879                open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
7880            let service = match manager.open_service(
7881                identity.name(),
7882                ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
7883            ) {
7884                Ok(service) => Some(service),
7885                Err(error) if is_missing(&error) => return Ok(false),
7886                // A previous caller has already asked SCM to delete this
7887                // registration. That is the desired state, not an uninstall
7888                // failure. Wait for SCM to release the name so a following
7889                // `service install` can use it immediately.
7890                Err(error) if is_marked_for_delete(&error) => None,
7891                Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
7892            };
7893            if let Some(service) = service {
7894                // A running service can be deleted, but it lingers until it stops.
7895                // Stopping first is what makes `uninstall` followed by `install`
7896                // work in one sitting, which is what the start-mode switch needs.
7897                if let Ok(status) = service.query_status()
7898                    && status.current_state != ServiceState::Stopped
7899                {
7900                    let _ = service.stop();
7901                }
7902                match service.delete() {
7903                    Ok(()) => {}
7904                    // DeleteService is idempotent from the operator's point of
7905                    // view. Another process can mark the service between our
7906                    // OpenService and DeleteService calls.
7907                    Err(error) if is_marked_for_delete(&error) => {}
7908                    Err(error) => {
7909                        return Err(scm_error("uninstall", identity.name(), &error));
7910                    }
7911                }
7912                drop(service);
7913            }
7914
7915            // `DeleteService` marks a registration for deletion and returns;
7916            // SCM removes it only after the last service handle closes. Drop
7917            // ours before polling, then wait for the observable postcondition
7918            // promised by `uninstall`: an immediate status check must not see
7919            // a registration that is merely on its way out. This also keeps a
7920            // stop/uninstall/install sequence deterministic on busy hosts.
7921            let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
7922                match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
7923                    Ok(service) => {
7924                        drop(service);
7925                        Ok(false)
7926                    }
7927                    Err(error) if is_missing(&error) => Ok(true),
7928                    Err(error) if is_marked_for_delete(&error) => Ok(false),
7929                    Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
7930                }
7931            })?;
7932            if !absent {
7933                return Err(ServiceError::Control {
7934                    operation: "verify uninstall of",
7935                    name: identity.name().to_string(),
7936                    manager: "the Windows Service Control Manager",
7937                    detail: format!(
7938                        "the registration was still visible {} seconds after DeleteService; \
7939                         retry `service uninstall` from an elevated prompt",
7940                        DELETE_TIMEOUT.as_secs()
7941                    ),
7942                });
7943            }
7944            Ok(true)
7945        }
7946
7947        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7948            let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
7949            let service = match manager.open_service(
7950                identity.name(),
7951                ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
7952            ) {
7953                Ok(service) => service,
7954                Err(error) if is_missing(&error) => return Ok(None),
7955                Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
7956            };
7957            let config = service
7958                .query_config()
7959                .map_err(|error| scm_error("inspect", identity.name(), &error))?;
7960            let status = service
7961                .query_status()
7962                .map_err(|error| scm_error("inspect", identity.name(), &error))?;
7963            let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
7964                actions
7965                    .actions
7966                    .and_then(|actions| actions.into_iter().next())
7967                    .filter(|action| action.action_type == ServiceActionType::Restart)
7968                    .map(|action| action.delay)
7969            });
7970            Ok(Some(Registration {
7971                manager: DefinitionKind::WindowsService,
7972                start_mode: StartMode::Boot,
7973                // `lpBinaryPathName` holds the executable *and* its arguments
7974                // as one string, which is why this module carries its own
7975                // parser rather than trusting `executable_path`'s name.
7976                command_line: config.executable_path.to_string_lossy().into_owned(),
7977                account: config
7978                    .account_name
7979                    .map(|account| super::windows_service_account(&account.to_string_lossy())),
7980                running: status.current_state == ServiceState::Running,
7981                starts_automatically: config.start_type == ServiceStartType::AutoStart,
7982                restart_delay,
7983            }))
7984        }
7985
7986        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7987            let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
7988            let service = manager
7989                .open_service(identity.name(), ServiceAccess::START)
7990                .map_err(|error| scm_error("start", identity.name(), &error))?;
7991            service
7992                .start::<&OsStr>(&[])
7993                .map_err(|error| scm_error("start", identity.name(), &error))
7994        }
7995
7996        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7997            let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
7998            let service = manager
7999                .open_service(
8000                    identity.name(),
8001                    ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
8002                )
8003                .map_err(|error| scm_error("stop", identity.name(), &error))?;
8004            let status = service
8005                .query_status()
8006                .map_err(|error| scm_error("stop", identity.name(), &error))?;
8007            if status.current_state == ServiceState::Stopped {
8008                return Ok(false);
8009            }
8010            service
8011                .stop()
8012                .map_err(|error| scm_error("stop", identity.name(), &error))?;
8013            Ok(true)
8014        }
8015    }
8016
8017    fn is_missing(error: &windows_service::Error) -> bool {
8018        matches!(error, windows_service::Error::Winapi(io)
8019            if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
8020    }
8021
8022    pub(super) fn is_marked_for_delete(error: &windows_service::Error) -> bool {
8023        matches!(error, windows_service::Error::Winapi(io)
8024            if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
8025    }
8026
8027    pub(super) fn wait_until_scm_absent(
8028        timeout: Duration,
8029        poll_interval: Duration,
8030        mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
8031    ) -> Result<bool, ServiceError> {
8032        let deadline = Instant::now() + timeout;
8033        loop {
8034            if probe_absent()? {
8035                return Ok(true);
8036            }
8037            if Instant::now() >= deadline {
8038                return Ok(false);
8039            }
8040            std::thread::sleep(poll_interval);
8041        }
8042    }
8043
8044    // -- Task Scheduler ------------------------------------------------------
8045
8046    #[derive(Debug)]
8047    struct TaskControl;
8048
8049    fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
8050        if detail.to_ascii_lowercase().contains("access is denied") {
8051            return ServiceError::NeedsElevation {
8052                operation,
8053                name: name.to_string(),
8054                detail,
8055                remedy: ELEVATION_REMEDY,
8056            };
8057        }
8058        ServiceError::Control {
8059            operation,
8060            name: name.to_string(),
8061            manager: "Windows Task Scheduler",
8062            detail,
8063        }
8064    }
8065
8066    fn schtasks(
8067        operation: &'static str,
8068        name: &str,
8069        arguments: &[&OsStr],
8070    ) -> Result<(bool, String), ServiceError> {
8071        match run("schtasks.exe", arguments) {
8072            Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
8073            Err(error) => Err(task_error(operation, name, error.to_string())),
8074        }
8075    }
8076
8077    /// Task Scheduler reads `/XML` files as UTF-16, so the document is written
8078    /// as UTF-16 little-endian with a byte-order mark rather than as UTF-8.
8079    fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
8080        let mut bytes = vec![0xFF, 0xFE];
8081        for unit in text.encode_utf16() {
8082            bytes.extend_from_slice(&unit.to_le_bytes());
8083        }
8084        std::fs::write(path, bytes)
8085    }
8086
8087    impl ServiceControl for TaskControl {
8088        fn manager(&self) -> DefinitionKind {
8089            DefinitionKind::WindowsScheduledTask
8090        }
8091
8092        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8093            let name = plan.identity().name().to_string();
8094            let principal = TaskPrincipal::current()?;
8095            let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
8096            let directory = tempfile::tempdir()
8097                .map_err(|error| task_error("install", &name, error.to_string()))?;
8098            let document = directory.path().join("task.xml");
8099            write_utf16(&document, definition.text())
8100                .map_err(|error| task_error("install", &name, error.to_string()))?;
8101            let (ok, message) = schtasks(
8102                "install",
8103                &name,
8104                &[
8105                    OsStr::new("/Create"),
8106                    OsStr::new("/TN"),
8107                    OsStr::new(&name),
8108                    OsStr::new("/XML"),
8109                    document.as_os_str(),
8110                ],
8111            )?;
8112            if !ok {
8113                return Err(task_error("install", &name, message));
8114            }
8115            Ok(definition)
8116        }
8117
8118        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8119            if self.query(identity)?.is_none() {
8120                return Ok(false);
8121            }
8122            let name = identity.name().to_string();
8123            let (ok, message) = schtasks(
8124                "uninstall",
8125                &name,
8126                &[
8127                    OsStr::new("/Delete"),
8128                    OsStr::new("/TN"),
8129                    OsStr::new(&name),
8130                    OsStr::new("/F"),
8131                ],
8132            )?;
8133            if !ok {
8134                return Err(task_error("uninstall", &name, message));
8135            }
8136            Ok(true)
8137        }
8138
8139        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8140            let name = identity.name().to_string();
8141            let (ok, document) = schtasks(
8142                "inspect",
8143                &name,
8144                &[
8145                    OsStr::new("/Query"),
8146                    OsStr::new("/TN"),
8147                    OsStr::new(&name),
8148                    OsStr::new("/XML"),
8149                    OsStr::new("ONE"),
8150                ],
8151            )?;
8152            if !ok {
8153                // `schtasks` reports a missing task and a broken Task Scheduler
8154                // the same way, with a non-zero exit; there is no distinct code.
8155                // Treating it as absence is the safe reading: the caller either
8156                // installs, which will then fail loudly, or reports "not
8157                // installed", which is what an operator with no task sees.
8158                return Ok(None);
8159            }
8160            let command = xml_value(&document, "Command").unwrap_or_default();
8161            let arguments = xml_value(&document, "Arguments").unwrap_or_default();
8162            let command_line = if arguments.is_empty() {
8163                super::quote_argument(&command)
8164            } else {
8165                format!("{} {arguments}", super::quote_argument(&command))
8166            };
8167            Ok(Some(Registration {
8168                manager: DefinitionKind::WindowsScheduledTask,
8169                start_mode: StartMode::Login,
8170                command_line,
8171                account: super::scheduled_task_account(xml_value(&document, "UserId")),
8172                running: task_is_running(&name),
8173                starts_automatically: super::windows_login_task_starts_automatically(&document),
8174                restart_delay: xml_value(&document, "Interval")
8175                    .as_deref()
8176                    .and_then(parse_iso8601),
8177            }))
8178        }
8179
8180        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8181            let name = identity.name().to_string();
8182            let (ok, message) = schtasks(
8183                "start",
8184                &name,
8185                &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
8186            )?;
8187            if ok {
8188                Ok(())
8189            } else {
8190                Err(task_error("start", &name, message))
8191            }
8192        }
8193
8194        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8195            let running = self
8196                .query(identity)?
8197                .is_some_and(|registration| registration.running);
8198            if !running {
8199                return Ok(false);
8200            }
8201            let name = identity.name().to_string();
8202            let (ok, message) = schtasks(
8203                "stop",
8204                &name,
8205                &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
8206            )?;
8207            if ok {
8208                Ok(true)
8209            } else {
8210                Err(task_error("stop", &name, message))
8211            }
8212        }
8213    }
8214
8215    /// Whether Task Scheduler reports the task as running.
8216    ///
8217    /// **This is the one value in this module read from localised output.**
8218    /// `schtasks /Query /FO CSV` prints a `Status` column in the machine's
8219    /// display language, and Task Scheduler exposes no locale-independent
8220    /// equivalent short of COM. On a non-English Windows this therefore reports
8221    /// `false` for a task that is in fact running.
8222    ///
8223    /// That is stated rather than hidden because of what does *not* depend on
8224    /// it: Journey 5's liveness question is answered by the last successful
8225    /// GitHub contact, which is a timestamp this product writes itself and no
8226    /// locale can move. Nothing in the Definition of Done rests on this
8227    /// boolean.
8228    fn task_is_running(name: &str) -> bool {
8229        let Ok((true, stdout, _)) = run(
8230            "schtasks.exe",
8231            &[
8232                OsStr::new("/Query"),
8233                OsStr::new("/TN"),
8234                OsStr::new(name),
8235                OsStr::new("/FO"),
8236                OsStr::new("CSV"),
8237                OsStr::new("/NH"),
8238            ],
8239        ) else {
8240            return false;
8241        };
8242        stdout
8243            .lines()
8244            .filter_map(|line| line.rsplit(',').next())
8245            .any(|status| {
8246                status
8247                    .trim()
8248                    .trim_matches('"')
8249                    .eq_ignore_ascii_case("running")
8250            })
8251    }
8252
8253    /// Parses the `PT<n>M` shape this module writes, and the `PT<n>S` shape
8254    /// Task Scheduler would accept if it took seconds. Anything richer -- days,
8255    /// hours, a combination -- is `None` rather than a guess.
8256    fn parse_iso8601(value: &str) -> Option<Duration> {
8257        let rest = value.strip_prefix("PT")?;
8258        if let Some(minutes) = rest.strip_suffix('M') {
8259            return minutes
8260                .parse::<u64>()
8261                .ok()
8262                .map(|minutes| Duration::from_secs(minutes * 60));
8263        }
8264        rest.strip_suffix('S')?
8265            .parse::<u64>()
8266            .ok()
8267            .map(Duration::from_secs)
8268    }
8269}
8270
8271// ---------------------------------------------------------------------------
8272// Unix: shared plumbing
8273// ---------------------------------------------------------------------------
8274
8275/// Writes a definition file, creating its directory, and says when the refusal
8276/// was a permissions one.
8277///
8278/// Both Unix backends write a file into a directory only `root` can write —
8279/// `/Library/LaunchDaemons` and `/etc/systemd/system` — and an operator who ran
8280/// `service install` without `sudo` deserves to be told that rather than handed
8281/// an `EACCES`.
8282#[cfg(unix)]
8283fn write_definition(
8284    operation: &'static str,
8285    name: &str,
8286    path: &Path,
8287    text: &str,
8288    remedy: &'static str,
8289) -> Result<(), ServiceError> {
8290    if let Some(parent) = path.parent()
8291        && let Err(error) = std::fs::create_dir_all(parent)
8292        && error.kind() != std::io::ErrorKind::AlreadyExists
8293    {
8294        return Err(definition_error(operation, name, error, remedy, parent));
8295    }
8296    std::fs::write(path, text)
8297        .map_err(|error| definition_error(operation, name, error, remedy, path))
8298}
8299
8300/// Replaces an installed definition with [`DefinitionDrift::rendered`].
8301///
8302/// Through a temporary file in the same directory and a rename, so a failure
8303/// at any point leaves the old definition in place rather than a truncated
8304/// one. The mode is set to `0644` before the rename, as `service install`
8305/// leaves it: launchd refuses a plist that others can write.
8306#[cfg(all(unix, any(target_os = "macos", test)))]
8307fn replace_definition(name: &str, drift: &DefinitionDrift) -> Result<(), ServiceError> {
8308    use std::io::Write as _;
8309
8310    let path = drift.path.as_path();
8311    let directory = path.parent().unwrap_or_else(|| Path::new("/"));
8312    let failed = |error: std::io::Error, at: &Path| {
8313        definition_error("rewrite", name, error, SUDO_REMEDY, at)
8314    };
8315    let mut staged =
8316        tempfile::NamedTempFile::new_in(directory).map_err(|error| failed(error, directory))?;
8317    staged
8318        .write_all(drift.rendered.as_bytes())
8319        .and_then(|()| staged.as_file().sync_all())
8320        .map_err(|error| failed(error, staged.path()))?;
8321    std::fs::set_permissions(
8322        staged.path(),
8323        <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o644),
8324    )
8325    .map_err(|error| failed(error, path))?;
8326    staged
8327        .persist(path)
8328        .map(drop)
8329        .map_err(|error| failed(error.error, path))
8330}
8331
8332#[cfg(unix)]
8333fn definition_error(
8334    operation: &'static str,
8335    name: &str,
8336    error: std::io::Error,
8337    remedy: &'static str,
8338    path: &Path,
8339) -> ServiceError {
8340    let detail = format!("{}: {error}", path.display());
8341    if error.kind() == std::io::ErrorKind::PermissionDenied {
8342        ServiceError::NeedsElevation {
8343            operation,
8344            name: name.to_string(),
8345            detail,
8346            remedy,
8347        }
8348    } else {
8349        ServiceError::Control {
8350            operation,
8351            name: name.to_string(),
8352            manager: "the local service manager",
8353            detail,
8354        }
8355    }
8356}
8357
8358/// `sudo` is the remedy on both Unixes, and the message says which command.
8359#[cfg(unix)]
8360const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
8361     `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
8362     agent not running until you sign in.";
8363
8364// ---------------------------------------------------------------------------
8365// macOS
8366// ---------------------------------------------------------------------------
8367
8368#[cfg(target_os = "macos")]
8369mod sys {
8370    //! One manager, two domains. `--start-at boot` is a LaunchDaemon in
8371    //! `/Library/LaunchDaemons`, loaded into launchd's `system` domain;
8372    //! `--start-at login` is a LaunchAgent in the operator's own
8373    //! `~/Library/LaunchAgents`, loaded into `gui/<uid>`.
8374    //!
8375    //! `launchctl print` is the only inspection command here whose output is
8376    //! parsed, and it is English-only on every macOS release — launchd has no
8377    //! localisation for it. The registration's *existence* is decided by the
8378    //! plist file rather than by that output, so a launchd that changed its
8379    //! wording would cost this module the running/not-running line and nothing
8380    //! else.
8381
8382    use std::ffi::OsStr;
8383    use std::path::PathBuf;
8384    use std::time::Duration;
8385
8386    use runner_manager_domain::model::StartMode;
8387
8388    use super::{
8389        DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
8390        SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
8391        enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
8392        write_definition, xml_value,
8393    };
8394
8395    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
8396        Ok(Box::new(LaunchdControl { mode }))
8397    }
8398
8399    #[derive(Debug)]
8400    struct LaunchdControl {
8401        mode: StartMode,
8402    }
8403
8404    impl LaunchdControl {
8405        /// launchd's domain target for this start mode.
8406        fn domain(&self) -> String {
8407            match self.mode {
8408                StartMode::Boot => "system".to_string(),
8409                // SAFETY: `getuid` reads the calling process's real user id and
8410                // cannot fail; it takes no arguments and touches no memory.
8411                StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
8412            }
8413        }
8414
8415        fn service_target(&self, identity: &ServiceIdentity) -> String {
8416            format!("{}/{}", self.domain(), identity.launchd_label())
8417        }
8418
8419        /// Where this domain's plist lives, when the domain has a home to put
8420        /// it in.
8421        fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
8422            let file = format!("{}.plist", identity.launchd_label());
8423            match self.mode {
8424                StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
8425                StartMode::Login => {
8426                    home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
8427                }
8428            }
8429        }
8430
8431        fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
8432            ServiceError::Control {
8433                operation,
8434                name: name.to_string(),
8435                manager: "launchd",
8436                detail,
8437            }
8438        }
8439
8440        fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
8441            match run("launchctl", arguments) {
8442                Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
8443                Err(error) => (false, error.to_string()),
8444            }
8445        }
8446    }
8447
8448    impl ServiceControl for LaunchdControl {
8449        fn manager(&self) -> DefinitionKind {
8450            DefinitionKind::LaunchdPlist
8451        }
8452
8453        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8454            let name = plan.identity().name().to_string();
8455            let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
8456            let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
8457                return Err(self.failed(
8458                    "install",
8459                    &name,
8460                    "this account has no home directory, so there is nowhere to put a \
8461                     LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
8462                     /Library/LaunchDaemons."
8463                        .to_string(),
8464                ));
8465            };
8466            write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8467            let target = self.domain();
8468            let (ok, message) = self.launchctl(&[
8469                OsStr::new("bootstrap"),
8470                OsStr::new(&target),
8471                path.as_os_str(),
8472            ]);
8473            if !ok {
8474                // Leave nothing behind: a plist launchd refused is a file that
8475                // would make the next `service status` claim a registration
8476                // that does not exist.
8477                let _ = std::fs::remove_file(&path);
8478                if message.to_ascii_lowercase().contains("permission denied") {
8479                    return Err(ServiceError::NeedsElevation {
8480                        operation: "install",
8481                        name,
8482                        detail: message,
8483                        remedy: SUDO_REMEDY,
8484                    });
8485                }
8486                return Err(self.failed("install", &name, message));
8487            }
8488            // A previously disabled label stays disabled through a bootstrap,
8489            // which is a service that is installed and will never start.
8490            let service_target = self.service_target(plan.identity());
8491            enable_launchd_registration(
8492                |arguments| self.launchctl(arguments),
8493                &target,
8494                &service_target,
8495                &path,
8496                &name,
8497                SUDO_REMEDY,
8498            )?;
8499            Ok(definition)
8500        }
8501
8502        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8503            let Some(path) = self.plist_path(identity) else {
8504                return Ok(false);
8505            };
8506            if !path.exists() {
8507                return Ok(false);
8508            }
8509            let target = self.service_target(identity);
8510            let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
8511            if !ok
8512                && !message.to_ascii_lowercase().contains("no such process")
8513                && !message.contains("113")
8514            {
8515                if message.to_ascii_lowercase().contains("permission denied") {
8516                    return Err(ServiceError::NeedsElevation {
8517                        operation: "uninstall",
8518                        name: identity.name().to_string(),
8519                        detail: message,
8520                        remedy: SUDO_REMEDY,
8521                    });
8522                }
8523                return Err(self.failed("uninstall", identity.name(), message));
8524            }
8525            // Only the definition. `05-infrastructure.md` item 5: nothing else
8526            // on this host is touched here or anywhere below it.
8527            std::fs::remove_file(&path).map_err(|error| {
8528                super::definition_error(
8529                    "uninstall",
8530                    identity.name(),
8531                    error,
8532                    SUDO_REMEDY,
8533                    path.as_path(),
8534                )
8535            })?;
8536            Ok(true)
8537        }
8538
8539        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8540            let Some(path) = self.plist_path(identity) else {
8541                return Ok(None);
8542            };
8543            let Ok(document) = std::fs::read_to_string(&path) else {
8544                return Ok(None);
8545            };
8546            let target = self.service_target(identity);
8547            let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
8548            Ok(Some(Registration {
8549                manager: DefinitionKind::LaunchdPlist,
8550                start_mode: self.mode,
8551                command_line: program_arguments(&document),
8552                // A LaunchDaemon without `UserName` runs as root; a LaunchAgent
8553                // runs as whoever signs in, which names no one in particular.
8554                account: plist_string_value(&document, "UserName").or_else(|| {
8555                    (self.mode == StartMode::Boot)
8556                        .then(|| super::ServiceAccount::Root.as_str().to_string())
8557                }),
8558                running: loaded && printed.contains("state = running"),
8559                starts_automatically: document.contains("<key>RunAtLoad</key>")
8560                    && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
8561                restart_delay: xml_value(
8562                    super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
8563                    "integer",
8564                )
8565                .and_then(|value| value.parse::<u64>().ok())
8566                .map(Duration::from_secs),
8567            }))
8568        }
8569
8570        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8571            let target = self.service_target(identity);
8572            let (ok, message) = self.launchctl(&[
8573                OsStr::new("kickstart"),
8574                OsStr::new("-k"),
8575                OsStr::new(&target),
8576            ]);
8577            if ok {
8578                Ok(())
8579            } else {
8580                Err(self.failed("start", identity.name(), message))
8581            }
8582        }
8583
8584        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8585            let running = self
8586                .query(identity)?
8587                .is_some_and(|registration| registration.running);
8588            if !running {
8589                return Ok(false);
8590            }
8591            let target = self.service_target(identity);
8592            let (ok, message) = self.launchctl(&[
8593                OsStr::new("kill"),
8594                OsStr::new("SIGTERM"),
8595                OsStr::new(&target),
8596            ]);
8597            if ok {
8598                Ok(true)
8599            } else {
8600                Err(self.failed("stop", identity.name(), message))
8601            }
8602        }
8603    }
8604
8605    /// Boots the job out and bootstraps it again from `plist`, from a shell
8606    /// that outlives this process.
8607    ///
8608    /// `setsid` puts the shell in a session and process group of its own. On a
8609    /// bootout launchd sends SIGTERM to the job's whole process group, the
8610    /// process that asked for the bootout included, and spares a child in
8611    /// another session (measured on macOS 27). Without `setsid` the helper
8612    /// survived that only by luck: one run in four left the job unloaded. The
8613    /// work runs in a background subshell, so the outer shell exits at once and
8614    /// is reaped here rather than left behind. `bootout` blocks until the daemon
8615    /// has exited; the bootstrap is retried for a minute because launchd
8616    /// refuses it while the old job is still being torn down. When the job is
8617    /// still loaded after a failed bootout, every bootstrap fails and the
8618    /// running daemon carries on as it was.
8619    pub(super) fn reload_detached(
8620        identity: &ServiceIdentity,
8621        mode: StartMode,
8622        plist: &std::path::Path,
8623    ) -> Result<(), ServiceError> {
8624        use std::os::unix::process::CommandExt;
8625
8626        const SCRIPT: &str = r#"(
8627  sleep 1
8628  /bin/launchctl bootout "$1"
8629  tries=0
8630  until /bin/launchctl bootstrap "$2" "$3"; do
8631    tries=$((tries + 1))
8632    if [ "$tries" -ge 60 ]; then
8633      echo "runner-manager: launchd did not load $3 again; run: $4" >&2
8634      exit 1
8635    fi
8636    sleep 1
8637  done
8638) </dev/null &"#;
8639
8640        let control = LaunchdControl { mode };
8641        // Once the job is booted out nothing holds the single-instance lock,
8642        // so a reinstall in the same start mode is the remedy.
8643        let reinstall = match mode {
8644            StartMode::Boot => "sudo runner-manager service install --start-at boot",
8645            StartMode::Login => "runner-manager service install --start-at login",
8646        };
8647        let mut command = std::process::Command::new("/bin/sh");
8648        command
8649            .args(["-c", SCRIPT, "runner-manager-reload"])
8650            .arg(control.service_target(identity))
8651            .arg(control.domain())
8652            .arg(plist)
8653            .arg(reinstall)
8654            .stdin(std::process::Stdio::null());
8655        // SAFETY: one async-signal-safe system call between `fork` and `exec`,
8656        // touching no memory, allocator or lock.
8657        unsafe {
8658            command.pre_exec(|| {
8659                if libc::setsid() == -1 {
8660                    return Err(std::io::Error::last_os_error());
8661                }
8662                Ok(())
8663            });
8664        }
8665        let status = command.status().map_err(|error| {
8666            control.failed("reload", identity.name(), format!("/bin/sh: {error}"))
8667        })?;
8668        if status.success() {
8669            Ok(())
8670        } else {
8671            Err(control.failed(
8672                "reload",
8673                identity.name(),
8674                format!("the reload helper exited with {status}"),
8675            ))
8676        }
8677    }
8678
8679    /// Rebuilds the command line from a plist's `ProgramArguments` array.
8680    fn program_arguments(document: &str) -> String {
8681        let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
8682            return String::new();
8683        };
8684        let Some(end) = rest.find("</array>") else {
8685            return String::new();
8686        };
8687        let mut out = Vec::new();
8688        let mut cursor = &rest[..end];
8689        while let Some(open) = cursor.find("<string>") {
8690            let after = &cursor[open + "<string>".len()..];
8691            let Some(close) = after.find("</string>") else {
8692                break;
8693            };
8694            out.push(quote_argument(&super::xml_unescape(&after[..close])));
8695            cursor = &after[close..];
8696        }
8697        out.join(" ")
8698    }
8699}
8700
8701// ---------------------------------------------------------------------------
8702// Linux and the other Unixes
8703// ---------------------------------------------------------------------------
8704
8705#[cfg(all(unix, not(target_os = "macos")))]
8706mod sys {
8707    //! One manager, two domains. `--start-at boot` is a system unit in
8708    //! `/etc/systemd/system`, wanted by `multi-user.target`; `--start-at login`
8709    //! is a user unit in `~/.config/systemd/user`, wanted by `default.target`.
8710    //!
8711    //! Every inspection here goes through `systemctl cat` and `systemctl show`,
8712    //! whose output is machine-readable `key=value` text rather than a
8713    //! sentence, so nothing in this module depends on the machine's display
8714    //! language.
8715
8716    use std::ffi::OsStr;
8717    use std::path::PathBuf;
8718
8719    use runner_manager_domain::model::StartMode;
8720
8721    use super::{
8722        DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
8723        SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
8724        home_directory, ini_directives, run, systemd_exec_command_line, systemd_timespan,
8725        write_definition,
8726    };
8727
8728    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
8729        Ok(Box::new(SystemdControl { mode }))
8730    }
8731
8732    #[derive(Debug)]
8733    struct SystemdControl {
8734        mode: StartMode,
8735    }
8736
8737    impl SystemdControl {
8738        fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
8739            let file = identity.systemd_unit();
8740            match self.mode {
8741                StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
8742                StartMode::Login => {
8743                    home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
8744                }
8745            }
8746        }
8747
8748        /// `systemctl`, with `--user` for the login domain.
8749        fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
8750            let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
8751            if self.mode == StartMode::Login {
8752                all.push(OsStr::new("--user"));
8753            }
8754            all.extend(arguments.iter().map(OsStr::new));
8755            match run("systemctl", &all) {
8756                Ok((ok, stdout, stderr)) => (
8757                    ok,
8758                    if stdout.trim().is_empty() {
8759                        stderr
8760                    } else {
8761                        stdout
8762                    },
8763                ),
8764                Err(error) => (false, error.to_string()),
8765            }
8766        }
8767
8768        fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
8769            ServiceError::Control {
8770                operation,
8771                name: name.to_string(),
8772                manager: "systemd",
8773                detail,
8774            }
8775        }
8776    }
8777
8778    impl ServiceControl for SystemdControl {
8779        fn manager(&self) -> DefinitionKind {
8780            DefinitionKind::SystemdUnit
8781        }
8782
8783        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8784            use std::os::unix::fs::MetadataExt as _;
8785
8786            let name = plan.identity().name().to_string();
8787            if self.mode == StartMode::Boot {
8788                // The credential store's directory as well as the four: the
8789                // service rewrites the credential there when GitHub rotates it.
8790                let refusals = super::permitted_paths(DefinitionKind::SystemdUnit, plan)
8791                    .iter()
8792                    .filter_map(|path| {
8793                        let path = std::path::Path::new(path);
8794                        let owner = std::fs::metadata(path).ok().map(|metadata| metadata.uid());
8795                        super::boot_directory_refusal(path, owner)
8796                    })
8797                    .collect::<Vec<_>>();
8798                if !refusals.is_empty() {
8799                    return Err(self.failed(
8800                        "install",
8801                        &name,
8802                        format!(
8803                            "{}. Run `{}` without --data-dir, which uses {} and {}; a \
8804                             directory there that root does not own is handed back with \
8805                             `sudo chown -R root:root <directory>`",
8806                            refusals.join("; "),
8807                            super::sudo_command("service install --start-at boot"),
8808                            crate::paths::SYSTEM_DATA_ROOT,
8809                            crate::paths::SYSTEM_LOGS_DIR,
8810                        ),
8811                    ));
8812                }
8813            }
8814            // The accounts the daemon starts its runners as, one per concurrent
8815            // runner. Created here, from the elevated install, since the
8816            // service itself can create no account.
8817            if self.mode == StartMode::Boot
8818                && let Err(detail) = crate::runner_account::RunnerPool::ensure()
8819            {
8820                return Err(self.failed("install", &name, detail));
8821            }
8822            let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
8823            let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
8824                return Err(self.failed(
8825                    "install",
8826                    &name,
8827                    "this account has no home directory, so there is nowhere to put a systemd \
8828                     user unit. Use --start-at boot, which installs a system unit under \
8829                     /etc/systemd/system."
8830                        .to_string(),
8831                ));
8832            };
8833            write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8834            let unit = plan.identity().systemd_unit();
8835            let (reloaded, message) = self.systemctl(&["daemon-reload"]);
8836            if !reloaded {
8837                let _ = std::fs::remove_file(&path);
8838                return Err(self.failed("install", &name, message));
8839            }
8840            let (enabled, message) = self.systemctl(&["enable", &unit]);
8841            if !enabled {
8842                let _ = std::fs::remove_file(&path);
8843                let _ = self.systemctl(&["daemon-reload"]);
8844                return Err(self.failed("install", &name, message));
8845            }
8846            Ok(definition)
8847        }
8848
8849        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8850            let Some(path) = self.unit_path(identity) else {
8851                return Ok(false);
8852            };
8853            if !path.exists() {
8854                return Ok(false);
8855            }
8856            let unit = identity.systemd_unit();
8857            // `--now` stops it as well as disabling it. A failure here is not
8858            // fatal: the unit file is about to go, and refusing to remove it
8859            // because `systemctl` disliked something would leave the host with
8860            // a unit nothing manages.
8861            let _ = self.systemctl(&["disable", "--now", &unit]);
8862            std::fs::remove_file(&path).map_err(|error| {
8863                super::definition_error(
8864                    "uninstall",
8865                    identity.name(),
8866                    error,
8867                    SUDO_REMEDY,
8868                    path.as_path(),
8869                )
8870            })?;
8871            let _ = self.systemctl(&["daemon-reload"]);
8872            Ok(true)
8873        }
8874
8875        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8876            let Some(path) = self.unit_path(identity) else {
8877                return Ok(None);
8878            };
8879            if !path.exists() {
8880                return Ok(None);
8881            }
8882            let unit = identity.systemd_unit();
8883            // `cat` includes administrator drop-ins in systemd's order. Reading
8884            // only our base file hides User/ExecStart/RestartSec overrides, so
8885            // the file is read only when `systemctl` cannot answer.
8886            let effective = match self.systemctl(&["cat", &unit]) {
8887                (true, text) => text,
8888                _ => match std::fs::read_to_string(&path) {
8889                    Ok(document) => document,
8890                    Err(_) => return Ok(None),
8891                },
8892            };
8893            let directives = ini_directives(&effective, "Service");
8894            // One `show` answers what `is-active` and `is-enabled` each did.
8895            let (_, shown) = self.systemctl(&[
8896                "show",
8897                "--property=ActiveState",
8898                "--property=UnitFileState",
8899                &unit,
8900            ]);
8901            let state = |key: &str| {
8902                shown
8903                    .lines()
8904                    .find_map(|line| line.strip_prefix(key)?.strip_prefix('='))
8905                    .map(str::trim)
8906            };
8907            Ok(Some(Registration {
8908                manager: DefinitionKind::SystemdUnit,
8909                start_mode: self.mode,
8910                command_line: directives
8911                    .get("ExecStart")
8912                    .map(|value| systemd_exec_command_line(value))
8913                    .unwrap_or_default(),
8914                // A system unit without `User=` runs as root; a user unit runs
8915                // as whoever owns the session, which names no one in particular.
8916                account: directives
8917                    .get("User")
8918                    .filter(|user| !user.is_empty())
8919                    .cloned()
8920                    .or_else(|| {
8921                        (self.mode == StartMode::Boot)
8922                            .then(|| super::ServiceAccount::Root.as_str().to_string())
8923                    }),
8924                running: state("ActiveState") == Some("active"),
8925                starts_automatically: state("UnitFileState") == Some("enabled"),
8926                restart_delay: directives
8927                    .get("RestartSec")
8928                    .and_then(|value| systemd_timespan(value)),
8929            }))
8930        }
8931
8932        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8933            let unit = identity.systemd_unit();
8934            let (ok, message) = self.systemctl(&["start", &unit]);
8935            if ok {
8936                Ok(())
8937            } else {
8938                Err(self.failed("start", identity.name(), message))
8939            }
8940        }
8941
8942        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8943            let running = self
8944                .query(identity)?
8945                .is_some_and(|registration| registration.running);
8946            if !running {
8947                return Ok(false);
8948            }
8949            let unit = identity.systemd_unit();
8950            let (ok, message) = self.systemctl(&["stop", &unit]);
8951            if ok {
8952                Ok(true)
8953            } else {
8954                Err(self.failed("stop", identity.name(), message))
8955            }
8956        }
8957    }
8958}
8959
8960/// What the macOS tests that load real launch agents share.
8961#[cfg(all(test, target_os = "macos"))]
8962pub(crate) mod launchd_fixture {
8963    use std::path::{Path, PathBuf};
8964
8965    /// Opts a macOS host into the tests that load launch agents into the
8966    /// caller's GUI session. CI's macOS leg sets it, and
8967    /// `privileged_tests_are_wired_into_ci.rs` asserts that it still does.
8968    const ACCEPTANCE: &str = "RUNNER_MANAGER_LAUNCHD_ACCEPTANCE";
8969
8970    /// Whether this run may load launch agents. Says so when it may not, since
8971    /// the test then passes having measured nothing.
8972    pub(crate) fn enabled() -> bool {
8973        let enabled = std::env::var_os(ACCEPTANCE).is_some_and(|value| value == "1");
8974        if !enabled {
8975            eprintln!("skipped: set {ACCEPTANCE}=1 to load test launch agents");
8976        }
8977        enabled
8978    }
8979
8980    /// A copy of the running test binary in `directory`, for a launchd job to
8981    /// run as its program.
8982    ///
8983    /// A copy, because a launchd job whose program lives on an external volume
8984    /// waits on a privacy approval nobody gives before `main` runs (measured
8985    /// with a target directory under `/Volumes`), and the temporary directory
8986    /// is on the boot volume.
8987    pub(crate) fn job_binary(directory: &Path) -> PathBuf {
8988        let binary = directory.join("launch-agent-job");
8989        std::fs::copy(std::env::current_exe().expect("test binary"), &binary)
8990            .expect("test binary copied");
8991        binary
8992    }
8993
8994    /// A launch agent loaded into the caller's GUI domain, booted out on drop.
8995    pub(crate) struct LoadedAgent(String);
8996
8997    impl LoadedAgent {
8998        /// Bootstraps `plist`, whose `Label` is `label`, and fails the test when
8999        /// launchd refuses it.
9000        pub(crate) fn bootstrap(label: &str, plist: &Path) -> Self {
9001            // SAFETY: `getuid` reads the calling process's real user id and
9002            // cannot fail.
9003            let domain = format!("gui/{}", unsafe { libc::getuid() });
9004            // Armed before the bootstrap, so a half-loaded job is removed too.
9005            let loaded = Self(format!("{domain}/{label}"));
9006            let bootstrap = std::process::Command::new("launchctl")
9007                .arg("bootstrap")
9008                .arg(&domain)
9009                .arg(plist)
9010                .output()
9011                .expect("launchctl runs");
9012            assert!(
9013                bootstrap.status.success(),
9014                "launchctl bootstrap {domain} refused {label}, so nothing was measured: {}",
9015                String::from_utf8_lossy(&bootstrap.stderr)
9016            );
9017            loaded
9018        }
9019
9020        /// `gui/<uid>/<label>`.
9021        pub(crate) fn target(&self) -> &str {
9022            &self.0
9023        }
9024    }
9025
9026    impl Drop for LoadedAgent {
9027        fn drop(&mut self) {
9028            let _ = std::process::Command::new("launchctl")
9029                .args(["bootout", &self.0])
9030                .output();
9031        }
9032    }
9033}
9034
9035#[cfg(test)]
9036mod tests {
9037    use super::*;
9038
9039    use std::collections::BTreeMap;
9040
9041    #[test]
9042    fn task_scheduler_omitting_default_enabled_still_means_automatic() {
9043        assert!(windows_login_task_starts_automatically(
9044            "<Task><Triggers><LogonTrigger></LogonTrigger></Triggers></Task>"
9045        ));
9046        assert!(windows_login_task_starts_automatically(
9047            "<Task><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Task>"
9048        ));
9049        assert!(!windows_login_task_starts_automatically(
9050            "<Task><LogonTrigger><Enabled>false</Enabled></LogonTrigger></Task>"
9051        ));
9052        assert!(!windows_login_task_starts_automatically(
9053            "<Task><BootTrigger></BootTrigger></Task>"
9054        ));
9055    }
9056
9057    // -----------------------------------------------------------------------
9058    // Fixtures
9059    // -----------------------------------------------------------------------
9060
9061    /// A plan against paths that exist on no platform, so that every renderer
9062    /// can be asserted on every leg of the CI matrix.
9063    fn linux_plan(mode: StartMode) -> InstallPlan {
9064        InstallPlan::unchecked(
9065            ServiceIdentity::product(),
9066            mode,
9067            "/opt/runner-manager/bin/runner-manager",
9068            ServiceDirectories {
9069                config: PathBuf::from("/var/lib/runner-manager/config"),
9070                state: PathBuf::from("/var/lib/runner-manager/state"),
9071                runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
9072                logs: PathBuf::from("/var/log/runner-manager"),
9073            },
9074        )
9075        .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
9076    }
9077
9078    fn windows_plan(mode: StartMode) -> InstallPlan {
9079        InstallPlan::unchecked(
9080            ServiceIdentity::product(),
9081            mode,
9082            "C:\\Program Files\\runner-manager\\runner-manager.exe",
9083            ServiceDirectories {
9084                config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
9085                state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
9086                runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
9087                logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
9088            },
9089        )
9090    }
9091
9092    /// Replaces one fragment of a rendered definition, and **fails the test if
9093    /// the fragment was not there**.
9094    ///
9095    /// Without the assertion a renderer change would quietly turn every
9096    /// "widened definition is rejected" test below into a test that reviews the
9097    /// unmodified definition and passes for the wrong reason. This is the
9098    /// single guard that keeps that class of vacuity out of this file.
9099    fn edited(text: &str, from: &str, to: &str) -> String {
9100        assert!(
9101            text.contains(from),
9102            "the rendered definition does not contain `{from}`, so this test would assert \
9103             nothing about a widened one"
9104        );
9105        text.replace(from, to)
9106    }
9107
9108    /// Every file under `root`, keyed by its path relative to `root`.
9109    fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
9110        fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
9111            let Ok(entries) = std::fs::read_dir(directory) else {
9112                return;
9113            };
9114            for entry in entries.flatten() {
9115                let path = entry.path();
9116                if path.is_dir() {
9117                    walk(&path, out);
9118                } else if let Ok(bytes) = std::fs::read(&path) {
9119                    out.insert(path, bytes);
9120                }
9121            }
9122        }
9123        let mut out = BTreeMap::new();
9124        for root in roots {
9125            walk(root, &mut out);
9126        }
9127        out
9128    }
9129
9130    struct Host {
9131        _root: tempfile::TempDir,
9132        paths: AppPaths,
9133        binary: PathBuf,
9134        /// A runner root inside this host's own temporary tree.
9135        ///
9136        /// Without it every `install` in this module would create and
9137        /// re-permission the **real** `%SystemDrive%\rman` on the machine
9138        /// running the tests, and would fail outright on any machine where that
9139        /// directory already exists with the access control `C:\` gives it.
9140        /// A sibling of the four application-data directories rather than a
9141        /// child of one, so `b1`'s overlap check has nothing to object to.
9142        runner_root: LocalAbsolutePath,
9143        controls: RecordingControls,
9144    }
9145
9146    impl Host {
9147        fn new() -> Self {
9148            let root = tempfile::tempdir().expect("a temporary directory");
9149            let paths = AppPaths::rooted_at(root.path());
9150            paths.create_all().expect("the four directories");
9151            let binary = root.path().join(if cfg!(windows) {
9152                "runner-manager.exe"
9153            } else {
9154                "runner-manager"
9155            });
9156            std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
9157            let runner_root = LocalAbsolutePath::new(
9158                root.path()
9159                    .join("runner-root")
9160                    .to_str()
9161                    .expect("a unicode temporary path"),
9162            )
9163            .expect("a local absolute path");
9164            Self {
9165                _root: root,
9166                paths,
9167                binary,
9168                runner_root,
9169                controls: RecordingControls::new(),
9170            }
9171        }
9172
9173        fn operations(&self) -> ServiceOperations {
9174            ServiceOperations::with_controls(
9175                self.paths.clone(),
9176                ServiceIdentity::product(),
9177                std::sync::Arc::new(self.controls.clone()),
9178            )
9179            .with_runner_root(self.runner_root.clone())
9180        }
9181
9182        fn request(&self, mode: StartMode) -> InstallRequest {
9183            InstallRequest::new(mode).for_binary(&self.binary)
9184        }
9185    }
9186
9187    #[cfg(windows)]
9188    #[test]
9189    fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
9190        let probes = std::cell::Cell::new(0);
9191        let absent =
9192            super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
9193                let next = probes.get() + 1;
9194                probes.set(next);
9195                Ok(next == 3)
9196            })
9197            .expect("the simulated SCM probe succeeds");
9198
9199        assert!(absent);
9200        assert_eq!(
9201            probes.get(),
9202            3,
9203            "uninstall must recheck after transient presence instead of treating it as a leak"
9204        );
9205    }
9206
9207    #[cfg(windows)]
9208    #[test]
9209    fn windows_recognises_an_already_pending_service_deletion() {
9210        let error = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(1072));
9211
9212        assert!(super::sys::is_marked_for_delete(&error));
9213    }
9214
9215    #[test]
9216    fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
9217        let root = tempfile::tempdir().expect("a temporary directory");
9218        let plist = root.path().join("fixture.plist");
9219        std::fs::write(&plist, b"fixture").expect("a plist fixture");
9220        let calls = std::cell::RefCell::new(Vec::new());
9221
9222        let error = enable_launchd_registration(
9223            |arguments| {
9224                let call = arguments
9225                    .iter()
9226                    .map(|argument| argument.to_string_lossy().into_owned())
9227                    .collect::<Vec<_>>();
9228                let operation = call[0].clone();
9229                calls.borrow_mut().push(call);
9230                if operation == "enable" {
9231                    (false, "label remains disabled".to_string())
9232                } else {
9233                    (true, String::new())
9234                }
9235            },
9236            "system",
9237            "system/com.openai.runner-manager-selftest",
9238            &plist,
9239            "runner-manager-selftest",
9240            "rerun with administrative rights",
9241        )
9242        .expect_err("enable failure must fail the install");
9243
9244        assert!(
9245            matches!(
9246                error,
9247                ServiceError::Control {
9248                    operation: "enable",
9249                    ..
9250                }
9251            ),
9252            "{error}"
9253        );
9254        assert_eq!(calls.borrow().len(), 2);
9255        assert_eq!(calls.borrow()[0][0], "enable");
9256        assert_eq!(calls.borrow()[1][0], "bootout");
9257        assert!(!plist.exists(), "rollback must remove the plist");
9258    }
9259
9260    // -----------------------------------------------------------------------
9261    // Quoting, and reading a command line back
9262    // -----------------------------------------------------------------------
9263
9264    #[test]
9265    fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
9266        let plan = windows_plan(StartMode::Boot);
9267        let command_line = plan.command_line();
9268        assert!(
9269            command_line.starts_with('"'),
9270            "a path with a space must be quoted, got {command_line}"
9271        );
9272        assert_eq!(
9273            executable_from_command_line(&command_line).as_deref(),
9274            Some(plan.binary())
9275        );
9276    }
9277
9278    #[test]
9279    fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
9280        let plan = linux_plan(StartMode::Boot);
9281        let command_line = plan.command_line();
9282        assert!(!command_line.starts_with('"'), "got {command_line}");
9283        assert_eq!(
9284            executable_from_command_line(&command_line).as_deref(),
9285            Some(plan.binary())
9286        );
9287    }
9288
9289    #[test]
9290    fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
9291        // Not a path anybody has, but it is the input that separates a real
9292        // implementation of Windows' quoting rules from a `split_whitespace`.
9293        let awkward = r#"C:\odd "name"\rm.exe"#;
9294        let quoted = quote_argument(awkward);
9295        assert_eq!(
9296            executable_from_command_line(&format!("{quoted} daemon run"))
9297                .as_deref()
9298                .map(Path::to_string_lossy)
9299                .as_deref(),
9300            Some(awkward)
9301        );
9302    }
9303
9304    #[test]
9305    fn an_empty_command_line_has_no_executable() {
9306        assert_eq!(executable_from_command_line("   "), None);
9307        assert_eq!(executable_from_command_line(""), None);
9308    }
9309
9310    #[test]
9311    fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
9312        let awkward = r#"DOMAIN\R&D <team> "ops""#;
9313        assert_eq!(
9314            xml_escape(awkward),
9315            "DOMAIN\\R&amp;D &lt;team&gt; &quot;ops&quot;"
9316        );
9317        assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
9318    }
9319
9320    // -----------------------------------------------------------------------
9321    // The restart policy is bounded at both ends
9322    // -----------------------------------------------------------------------
9323
9324    #[test]
9325    fn a_restart_delay_under_the_floor_is_refused() {
9326        let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
9327            .expect_err("half a second is under the one-second floor");
9328        assert!(
9329            matches!(error, ServiceError::RestartDelay { .. }),
9330            "{error}"
9331        );
9332    }
9333
9334    #[test]
9335    fn a_restart_delay_over_the_ceiling_is_refused() {
9336        let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
9337            .expect_err("an hour is over the five-minute ceiling");
9338        assert!(
9339            matches!(error, ServiceError::RestartDelay { .. }),
9340            "{error}"
9341        );
9342    }
9343
9344    #[test]
9345    fn a_reset_window_no_longer_than_the_delay_is_refused() {
9346        let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
9347            .expect_err("a window equal to the delay can never elapse between restarts");
9348        assert!(
9349            matches!(error, ServiceError::RestartResetWindow { .. }),
9350            "{error}"
9351        );
9352    }
9353
9354    #[test]
9355    fn a_delay_inside_the_bound_is_accepted() {
9356        let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
9357            .expect("twenty seconds is inside the bound");
9358        assert_eq!(policy.delay(), Duration::from_secs(20));
9359        assert_eq!(policy.reset_after(), Duration::from_secs(300));
9360    }
9361
9362    // -----------------------------------------------------------------------
9363    // Identity
9364    // -----------------------------------------------------------------------
9365
9366    #[test]
9367    fn a_fixture_identity_can_never_be_the_product_identity() {
9368        let fixture = ServiceIdentity::fixture("abc123");
9369        assert!(fixture.is_fixture());
9370        assert!(!ServiceIdentity::product().is_fixture());
9371        assert_ne!(fixture.name(), ServiceIdentity::product().name());
9372        assert_ne!(
9373            fixture.launchd_label(),
9374            ServiceIdentity::product().launchd_label()
9375        );
9376        assert_ne!(
9377            fixture.systemd_unit(),
9378            ServiceIdentity::product().systemd_unit()
9379        );
9380    }
9381
9382    #[test]
9383    fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
9384        let fixture = ServiceIdentity::fixture("A b/c:\\d");
9385        assert!(
9386            fixture
9387                .name()
9388                .chars()
9389                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
9390            "got {}",
9391            fixture.name()
9392        );
9393    }
9394
9395    // -----------------------------------------------------------------------
9396    // The systemd unit
9397    // -----------------------------------------------------------------------
9398
9399    #[test]
9400    fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
9401        let unit = systemd_unit(&linux_plan(StartMode::Boot));
9402        assert!(unit.contains("KillMode=process\n"), "{unit}");
9403        assert!(unit.contains("Restart=on-failure\n"), "{unit}");
9404        assert!(unit.contains("RestartSec=15\n"), "{unit}");
9405        assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
9406        assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
9407        assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
9408    }
9409
9410    #[test]
9411    fn the_boot_unit_reads_the_live_store_instead_of_a_frozen_systemd_copy() {
9412        let unit = systemd_unit(&linux_plan(StartMode::Boot));
9413        assert!(
9414            !unit.contains("LoadCredential="),
9415            "a startup snapshot would shadow every rotated credential until restart:\n{unit}"
9416        );
9417    }
9418
9419    #[test]
9420    fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
9421        let unit = systemd_unit(&linux_plan(StartMode::Login));
9422        assert!(
9423            !unit.contains("LoadCredential="),
9424            "a user unit must not name a root-owned credential file, got:\n{unit}"
9425        );
9426        assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
9427    }
9428
9429    #[test]
9430    fn the_unit_can_atomically_replace_the_credential_and_write_only_required_directories() {
9431        let plan = linux_plan(StartMode::Boot);
9432        let unit = systemd_unit(&plan);
9433        let directives = ini_directives(&unit, "Service");
9434        let listed = split_quoted(
9435            directives
9436                .get("ReadWritePaths")
9437                .expect("the unit names its writable paths"),
9438        );
9439        assert_eq!(listed.len(), 5, "{listed:?}");
9440        for path in plan.directories().all() {
9441            assert!(
9442                listed.iter().any(|entry| entry == &path.to_string_lossy()),
9443                "{} is missing from {listed:?}",
9444                path.display()
9445            );
9446        }
9447        assert!(
9448            listed
9449                .iter()
9450                .any(|entry| entry == "/var/lib/runner-manager/secrets"),
9451            "atomic credential replacement needs its parent directory in {listed:?}"
9452        );
9453    }
9454
9455    #[test]
9456    fn the_unit_records_the_absolute_binary_path() {
9457        let plan = linux_plan(StartMode::Boot);
9458        let unit = systemd_unit(&plan);
9459        assert!(
9460            unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
9461            "{unit}"
9462        );
9463    }
9464
9465    // -----------------------------------------------------------------------
9466    // The launchd property list
9467    // -----------------------------------------------------------------------
9468
9469    #[test]
9470    fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
9471        let plist = launchd_plist(&linux_plan(StartMode::Boot));
9472        // A bare `KeepAlive` would also restart a job the operator stopped
9473        // deliberately, which turns `service stop` into a fight with launchd.
9474        assert!(
9475            plist.contains(
9476                "<key>KeepAlive</key>\n  <dict>\n    <key>SuccessfulExit</key>\n    <false/>\n"
9477            ),
9478            "{plist}"
9479        );
9480        assert!(
9481            plist.contains("<key>ThrottleInterval</key>\n  <integer>15</integer>"),
9482            "{plist}"
9483        );
9484    }
9485
9486    #[test]
9487    fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
9488        let daemon = launchd_plist(&linux_plan(StartMode::Boot));
9489        assert_eq!(
9490            plist_string_value(&daemon, "UserName").as_deref(),
9491            Some("root")
9492        );
9493        assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
9494
9495        let agent = launchd_plist(&linux_plan(StartMode::Login));
9496        assert_eq!(
9497            plist_string_value(&agent, "UserName"),
9498            None,
9499            "a LaunchAgent already runs as the operator:\n{agent}"
9500        );
9501    }
9502
9503    #[test]
9504    fn the_plist_runs_the_daemon_and_its_runners_as_an_interactive_job() {
9505        for mode in [StartMode::Boot, StartMode::Login] {
9506            let plan = linux_plan(mode);
9507            let plist = launchd_plist(&plan);
9508            assert_eq!(
9509                plist_string_value(&plist, "ProcessType").as_deref(),
9510                Some("Interactive"),
9511                "launchd applies ProcessType to every runner the daemon starts:\n{plist}"
9512            );
9513            let review = review_least_privilege(
9514                &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, plist),
9515                &plan,
9516            );
9517            assert!(
9518                review
9519                    .controls()
9520                    .iter()
9521                    .any(|control| control == "ProcessType=Interactive"),
9522                "{review}"
9523            );
9524            assert!(
9525                !review
9526                    .findings()
9527                    .iter()
9528                    .any(|finding| finding.subject == "ProcessType"),
9529                "{review}"
9530            );
9531        }
9532    }
9533
9534    /// The value 0.4.31 and earlier installed, and launchd's default, both
9535    /// throttle every runner. That is a shortfall the update repairs, not an
9536    /// excess: a scheduling class grants no authority.
9537    #[test]
9538    fn a_plist_that_throttles_its_runners_is_a_shortfall() {
9539        let plan = linux_plan(StartMode::Login);
9540        let rendered = launchd_plist(&plan);
9541        let interactive = "<key>ProcessType</key>\n  <string>Interactive</string>\n";
9542        for (label, throttled) in [
9543            (
9544                "Background",
9545                edited(
9546                    &rendered,
9547                    interactive,
9548                    "<key>ProcessType</key>\n  <string>Background</string>\n",
9549                ),
9550            ),
9551            ("absent", edited(&rendered, interactive, "")),
9552        ] {
9553            let review = review_least_privilege(
9554                &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, throttled),
9555                &plan,
9556            );
9557            assert!(review.is_least_privilege(), "{label}: {review}");
9558            assert!(
9559                review.findings().iter().any(|finding| {
9560                    finding.kind == FindingKind::Shortfall
9561                        && finding.subject == "ProcessType"
9562                        && finding.detail.contains(label)
9563                }),
9564                "{label}: {review}"
9565            );
9566        }
9567    }
9568
9569    #[cfg(unix)]
9570    #[test]
9571    fn converging_a_unit_first_creates_the_directories_it_lets_the_service_write() {
9572        use std::os::unix::fs::PermissionsExt as _;
9573        let root = tempfile::tempdir().expect("dir");
9574        let config = root.path().join(".config").join("runner-manager");
9575        let state = root.path().join("state");
9576        std::fs::create_dir_all(&state).expect("state");
9577        let drift = DefinitionDrift {
9578            path: root.path().join("runner-manager.service"),
9579            installed_by_version: "0.4.44".into(),
9580            rendered: String::new(),
9581            start_mode: StartMode::Boot,
9582            manager: DefinitionKind::SystemdUnit,
9583            writable: vec![config.clone(), state.clone()],
9584        };
9585        drift.create_writable().expect("created");
9586        assert!(config.is_dir(), "the missing one is made");
9587        assert_eq!(
9588            std::fs::metadata(&config).unwrap().permissions().mode() & 0o777,
9589            0o700
9590        );
9591        drift.create_writable().expect("and again changes nothing");
9592
9593        std::fs::remove_dir(&state).expect("state removed");
9594        std::fs::write(&state, "").expect("a file in its place");
9595        assert!(
9596            drift.create_writable().is_err(),
9597            "a file is not a directory"
9598        );
9599    }
9600
9601    #[test]
9602    fn a_plist_with_an_old_process_type_is_drift_and_an_operator_edit_is_not() {
9603        let host = Host::new();
9604        let identity = ServiceIdentity::product();
9605        let plan = InstallPlan::resolve(
9606            identity.clone(),
9607            &host.request(StartMode::Login),
9608            ServiceDirectories::of(&host.paths),
9609        )
9610        .expect("a resolvable plan");
9611        let installed = launchd_plist(&plan);
9612        let definition =
9613            ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, installed.clone());
9614        let mut record = InstallRecord::of(&plan, &definition, Utc::now());
9615        record.installed_by_version = "0.4.31".into();
9616        let path = PathBuf::from("/Users/op/Library/LaunchAgents/runner-manager.plist");
9617
9618        assert_eq!(
9619            launchd_definition_drift(&identity, &record, path.clone(), &installed),
9620            None
9621        );
9622        let edited_by_operator = edited(
9623            &installed,
9624            "</dict>\n</plist>\n",
9625            "  <key>Nice</key>\n  <integer>0</integer>\n</dict>\n</plist>\n",
9626        );
9627        assert_eq!(
9628            launchd_definition_drift(&identity, &record, path.clone(), &edited_by_operator),
9629            None,
9630            "the daemon repairs this drift itself, so it must not key on edits it did not cause"
9631        );
9632
9633        let written_by_0_4_31 = edited(
9634            &installed,
9635            "<string>Interactive</string>",
9636            "<string>Background</string>",
9637        );
9638        let drift = launchd_definition_drift(&identity, &record, path, &written_by_0_4_31)
9639            .expect("a Background plist is drift");
9640        assert_eq!(drift.manager, DefinitionKind::LaunchdPlist);
9641        assert_eq!(drift.start_mode, StartMode::Login);
9642        assert_eq!(
9643            drift.rendered, installed,
9644            "the repair must write exactly what `service install` writes"
9645        );
9646
9647        let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
9648            .with_definition_drift(Some(drift));
9649        let problem = status
9650            .problems()
9651            .iter()
9652            .find(|problem| problem.subject == "definition")
9653            .expect("the drift is reported");
9654        // The remedy is a restart: a running daemon holds the lock that refuses
9655        // `service install`.
9656        assert!(
9657            problem.detail.contains("0.4.31")
9658                && problem.detail.contains(
9659                    "launchctl kickstart -k gui/$(id -u)/io.github.IvanMurzak.runner-manager`"
9660                )
9661                && !problem.detail.contains("service install")
9662                && !problem.detail.contains("systemctl")
9663                && !problem.detail.contains("sudo"),
9664            "{}",
9665            problem.detail
9666        );
9667    }
9668
9669    #[cfg(unix)]
9670    #[test]
9671    fn a_replaced_definition_is_the_rendering_and_readable_by_launchd() {
9672        use std::os::unix::fs::PermissionsExt as _;
9673
9674        let root = tempfile::tempdir().expect("a temporary directory");
9675        let path = root
9676            .path()
9677            .join("io.github.IvanMurzak.runner-manager.plist");
9678        std::fs::write(&path, "<string>Background</string>").expect("old plist");
9679        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
9680        let drift = DefinitionDrift {
9681            path: path.clone(),
9682            installed_by_version: "0.4.31".into(),
9683            rendered: launchd_plist(&linux_plan(StartMode::Login)),
9684            start_mode: StartMode::Login,
9685            manager: DefinitionKind::LaunchdPlist,
9686            writable: Vec::new(),
9687        };
9688
9689        replace_definition("runner-manager", &drift).expect("replaced");
9690
9691        assert_eq!(std::fs::read_to_string(&path).unwrap(), drift.rendered);
9692        assert_eq!(
9693            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
9694            0o644
9695        );
9696        assert_eq!(
9697            std::fs::read_dir(root.path()).unwrap().count(),
9698            1,
9699            "no staging file is left beside the plist"
9700        );
9701    }
9702
9703    /// The job of [`a_job_reloads_itself_from_its_rewritten_plist`], run by
9704    /// launchd and by nothing else.
9705    ///
9706    /// It does what the daemon does when it starts: when its own plist (kept
9707    /// in its working directory) does not carry [`LAUNCHD_PROCESS_TYPE`], it
9708    /// rewrites the plist and asks for the reload from inside the job that the
9709    /// reload boots out. Either way it then stays up, to be booted out by the
9710    /// helper or, once reloaded, by the test.
9711    #[cfg(target_os = "macos")]
9712    #[test]
9713    #[ignore = "run only by launchd, as the job of the detached-reload test"]
9714    fn detached_reload_job() {
9715        let label = std::env::var("XPC_SERVICE_NAME").unwrap_or_default();
9716        let tag = label
9717            .split_once("-selftest-")
9718            .map(|(_, tag)| tag.to_string())
9719            .unwrap_or_else(|| panic!("run only by launchd for the reload test, not {label:?}"));
9720        let identity = ServiceIdentity::fixture(&tag);
9721        assert_eq!(identity.launchd_label(), label);
9722        let path = std::env::current_dir()
9723            .expect("working directory")
9724            .join(format!("{label}.plist"));
9725        let installed = std::fs::read_to_string(&path).expect("own plist");
9726        if plist_string_value(&installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE) {
9727            let drift = DefinitionDrift {
9728                path: path.clone(),
9729                installed_by_version: "0.4.31".into(),
9730                rendered: installed.replace(
9731                    "<string>Background</string>",
9732                    &format!("<string>{LAUNCHD_PROCESS_TYPE}</string>"),
9733                ),
9734                start_mode: StartMode::Login,
9735                manager: DefinitionKind::LaunchdPlist,
9736                writable: Vec::new(),
9737            };
9738            replace_definition(identity.name(), &drift).expect("plist replaced");
9739            super::sys::reload_detached(&identity, StartMode::Login, &path)
9740                .expect("helper started");
9741        }
9742        std::thread::sleep(Duration::from_secs(120));
9743    }
9744
9745    /// A job that rewrites its own plist and asks for the reload comes back
9746    /// under the rewritten plist.
9747    ///
9748    /// The request comes from inside the job, as the daemon's does, so the
9749    /// helper has to outlive the bootout of the very job that started it.
9750    /// launchd sends SIGTERM to the job's whole process group, so a helper that
9751    /// stayed in it would sometimes die before its bootstrap and leave the job
9752    /// unloaded. Measured with `setsid` removed, that failed one run in four
9753    /// here: this test catches it, though not on every run.
9754    #[cfg(target_os = "macos")]
9755    #[test]
9756    fn a_job_reloads_itself_from_its_rewritten_plist() {
9757        if !super::launchd_fixture::enabled() {
9758            return;
9759        }
9760        let root = tempfile::tempdir().expect("a temporary directory");
9761        let identity = ServiceIdentity::fixture(&format!("reload-{}", std::process::id()));
9762        let label = identity.launchd_label();
9763        let path = root.path().join(format!("{label}.plist"));
9764        let binary = super::launchd_fixture::job_binary(root.path());
9765        let mut plist = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
9766        plist.push_str("<plist version=\"1.0\">\n<dict>\n");
9767        plist.push_str(&plist_string("Label", &label));
9768        plist.push_str("  <key>ProgramArguments</key>\n  <array>\n");
9769        for argument in [
9770            binary.to_string_lossy().as_ref(),
9771            "--ignored",
9772            "--exact",
9773            "service::tests::detached_reload_job",
9774            "--test-threads=1",
9775        ] {
9776            plist.push_str(&format!("    <string>{}</string>\n", xml_escape(argument)));
9777        }
9778        plist.push_str("  </array>\n  <key>RunAtLoad</key>\n  <true/>\n");
9779        plist.push_str(&plist_string(
9780            "WorkingDirectory",
9781            &root.path().to_string_lossy(),
9782        ));
9783        plist.push_str(&plist_string("ProcessType", "Background"));
9784        plist.push_str("</dict>\n</plist>\n");
9785        std::fs::write(&path, plist).expect("plist written");
9786
9787        let loaded = super::launchd_fixture::LoadedAgent::bootstrap(&label, &path);
9788        let spawn_type = || {
9789            let printed = std::process::Command::new("launchctl")
9790                .args(["print", loaded.target()])
9791                .output()
9792                .expect("launchctl runs");
9793            String::from_utf8_lossy(&printed.stdout)
9794                .lines()
9795                .find_map(|line| {
9796                    line.trim()
9797                        .strip_prefix("spawn type = ")
9798                        .map(str::to_string)
9799                })
9800        };
9801        assert_eq!(spawn_type().as_deref(), Some("background (5)"));
9802
9803        let deadline = std::time::Instant::now() + Duration::from_secs(90);
9804        while spawn_type().as_deref() != Some("interactive (4)") {
9805            assert!(
9806                std::time::Instant::now() < deadline,
9807                "the job did not come back under its rewritten plist; launchd reports {:?}",
9808                spawn_type()
9809            );
9810            std::thread::sleep(Duration::from_millis(250));
9811        }
9812        let rewritten = std::fs::read_to_string(&path).expect("plist readable");
9813        assert_eq!(
9814            plist_string_value(&rewritten, "ProcessType").as_deref(),
9815            Some(LAUNCHD_PROCESS_TYPE)
9816        );
9817    }
9818
9819    #[test]
9820    fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
9821        let plist = launchd_plist(&linux_plan(StartMode::Boot));
9822        assert!(
9823            plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
9824            "{plist}"
9825        );
9826        assert!(plist.contains("<string>daemon</string>"), "{plist}");
9827        assert!(plist.contains("<string>run</string>"), "{plist}");
9828    }
9829
9830    #[test]
9831    fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
9832        assert_eq!(
9833            ServiceIdentity::product().launchd_label(),
9834            "io.github.IvanMurzak.runner-manager"
9835        );
9836    }
9837
9838    // -----------------------------------------------------------------------
9839    // The Task Scheduler document
9840    // -----------------------------------------------------------------------
9841
9842    #[test]
9843    fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
9844        let xml = windows_scheduled_task_xml(
9845            &windows_plan(StartMode::Login),
9846            &TaskPrincipal::named("HOST\\operator"),
9847        );
9848        assert!(xml.contains("<LogonTrigger>"), "{xml}");
9849        assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
9850        assert!(
9851            xml.contains("<LogonType>InteractiveToken</LogonType>"),
9852            "{xml}"
9853        );
9854        assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
9855        assert!(
9856            xml.contains("<Interval>PT1M</Interval>"),
9857            "Task Scheduler takes whole minutes only, and rejects the registration outright \
9858             for anything finer:\n{xml}"
9859        );
9860    }
9861
9862    #[test]
9863    fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
9864        // Measured against the real Task Scheduler, which answers `PT15S` with
9865        // "The task XML contains a value which is incorrectly formatted or out
9866        // of range". Rounding *up* is what keeps "does not restart-loop faster
9867        // than that bound" true on this manager.
9868        for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
9869            let policy =
9870                RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
9871                    .expect("inside the supported range");
9872            assert_eq!(
9873                policy
9874                    .effective_delay(DefinitionKind::WindowsScheduledTask)
9875                    .as_secs(),
9876                enforced,
9877                "a {asked}s delay must be enforced as {enforced}s"
9878            );
9879            assert!(
9880                policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
9881                "rounding must never shorten the bound"
9882            );
9883        }
9884    }
9885
9886    #[test]
9887    fn every_other_manager_enforces_the_delay_exactly_as_configured() {
9888        let policy = RestartPolicy::default();
9889        for kind in [
9890            DefinitionKind::WindowsService,
9891            DefinitionKind::LaunchdPlist,
9892            DefinitionKind::SystemdUnit,
9893        ] {
9894            assert_eq!(
9895                policy.effective_delay(kind),
9896                policy.delay(),
9897                "{kind:?} takes seconds and enforces exactly what it is given"
9898            );
9899        }
9900    }
9901
9902    #[test]
9903    fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
9904        let host = Host::new();
9905        let operations = host.operations();
9906        operations
9907            .install(&host.request(StartMode::Login))
9908            .expect("an install at login");
9909        // What a real Task Scheduler reports back for a 15-second policy.
9910        host.controls.edit("runner-manager", |registration| {
9911            registration.manager = DefinitionKind::WindowsScheduledTask;
9912            registration.restart_delay = Some(Duration::from_secs(60));
9913        });
9914
9915        let status = operations.status().expect("a status");
9916        assert!(
9917            status.is_healthy(),
9918            "minute granularity is the manager's, not a mis-registration: {status}"
9919        );
9920        assert!(
9921            status
9922                .notes()
9923                .iter()
9924                .any(|note| note.contains("whole minutes")),
9925            "but the operator must be told why 15 became 60: {status}"
9926        );
9927
9928        // The discriminator: a delay that is neither the configured one nor its
9929        // rounding is still a fault.
9930        host.controls.edit("runner-manager", |registration| {
9931            registration.restart_delay = Some(Duration::from_secs(1));
9932        });
9933        assert!(
9934            !operations.status().expect("a status").is_healthy(),
9935            "a one-second delay is not what any manager was asked for"
9936        );
9937    }
9938
9939    #[test]
9940    fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
9941        let plan = windows_plan(StartMode::Login);
9942        let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
9943        assert_eq!(
9944            xml_value(&xml, "Command").as_deref(),
9945            Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
9946            "{xml}"
9947        );
9948        assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
9949    }
9950
9951    #[test]
9952    fn an_account_name_holding_xml_punctuation_is_escaped() {
9953        let xml = windows_scheduled_task_xml(
9954            &windows_plan(StartMode::Login),
9955            &TaskPrincipal::named("R&D\\ops"),
9956        );
9957        assert!(xml.contains("<UserId>R&amp;D\\ops</UserId>"), "{xml}");
9958        assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
9959    }
9960
9961    // -----------------------------------------------------------------------
9962    // The Windows service descriptor
9963    // -----------------------------------------------------------------------
9964
9965    #[test]
9966    fn the_service_starts_automatically_under_the_account_the_store_admits() {
9967        let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
9968        let directives = ini_directives(&text, "windows-service");
9969        assert_eq!(
9970            directives.get("StartType").map(String::as_str),
9971            Some("AutoStart")
9972        );
9973        assert_eq!(
9974            directives.get("Account").map(String::as_str),
9975            Some("NT AUTHORITY\\SYSTEM")
9976        );
9977        assert_eq!(
9978            directives.get("ServiceType").map(String::as_str),
9979            Some("OWN_PROCESS")
9980        );
9981        assert_eq!(
9982            directives
9983                .get("FailureActionRestartDelaySecs")
9984                .map(String::as_str),
9985            Some("15")
9986        );
9987        assert_eq!(
9988            directives
9989                .get("FailureActionsOnNonCrashFailures")
9990                .map(String::as_str),
9991            Some("true"),
9992            "without this flag a non-zero exit is not a failure the manager restarts"
9993        );
9994    }
9995
9996    #[test]
9997    fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
9998        let spec = windows_service_spec(&windows_plan(StartMode::Boot));
9999        assert_eq!(spec.account, None);
10000        assert!(spec.automatic_start);
10001        assert!(
10002            spec.command_line.contains("daemon run"),
10003            "{}",
10004            spec.command_line
10005        );
10006    }
10007
10008    // -----------------------------------------------------------------------
10009    // Where each definition is installed
10010    // -----------------------------------------------------------------------
10011
10012    #[test]
10013    fn each_definition_goes_where_its_platform_expects_it() {
10014        let home = PathBuf::from("/home/op");
10015        assert_eq!(
10016            ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
10017            Some(Path::new(
10018                "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
10019            ))
10020        );
10021        assert_eq!(
10022            ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
10023            Some(Path::new(
10024                "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
10025            ))
10026        );
10027        assert_eq!(
10028            ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
10029            Some(Path::new("/etc/systemd/system/runner-manager.service"))
10030        );
10031        assert_eq!(
10032            ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
10033            Some(Path::new(
10034                "/home/op/.config/systemd/user/runner-manager.service"
10035            ))
10036        );
10037        assert_eq!(
10038            ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
10039            None,
10040            "the Service Control Manager has no file"
10041        );
10042    }
10043
10044    #[test]
10045    fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
10046        assert_eq!(
10047            ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
10048            None
10049        );
10050        assert_eq!(
10051            ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
10052            None
10053        );
10054    }
10055
10056    // -----------------------------------------------------------------------
10057    // The least-privilege review: the passing case, then every failing one
10058    // -----------------------------------------------------------------------
10059
10060    #[test]
10061    fn the_rendered_definitions_are_all_least_privilege() {
10062        let linux = linux_plan(StartMode::Boot);
10063        let windows = windows_plan(StartMode::Boot);
10064        for (definition, plan) in [
10065            (ServiceDefinition::systemd(&linux, None), &linux),
10066            (ServiceDefinition::launchd(&linux, None), &linux),
10067            (ServiceDefinition::windows_service(&windows), &windows),
10068        ] {
10069            let review = review_least_privilege(&definition, plan);
10070            assert!(
10071                review.is_least_privilege(),
10072                "{:?} should be least privilege, got:\n{review}",
10073                definition.kind()
10074            );
10075            assert!(
10076                !review.controls().is_empty(),
10077                "a review that confirms nothing proves nothing: {:?}",
10078                definition.kind()
10079            );
10080        }
10081    }
10082
10083    #[test]
10084    fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
10085        let plan = windows_plan(StartMode::Login);
10086        let definition =
10087            ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
10088        let review = review_least_privilege(&definition, &plan);
10089        assert!(review.is_least_privilege(), "{review}");
10090        assert!(
10091            review
10092                .controls()
10093                .iter()
10094                .any(|control| control.contains("LeastPrivilege")),
10095            "{review}"
10096        );
10097    }
10098
10099    #[test]
10100    fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
10101        let plan = linux_plan(StartMode::Boot);
10102        let rendered = systemd_unit(&plan);
10103        let widened = edited(
10104            &rendered,
10105            "ReadWritePaths=/var/lib/runner-manager/config",
10106            "ReadWritePaths=/etc /var/lib/runner-manager/config",
10107        );
10108        let review = review_least_privilege(
10109            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10110            &plan,
10111        );
10112        assert!(!review.is_least_privilege(), "{review}");
10113        assert!(
10114            review
10115                .excesses()
10116                .iter()
10117                .any(|finding| finding.detail.contains("/etc")),
10118            "the review must name the directory it objects to: {review}"
10119        );
10120    }
10121
10122    #[test]
10123    fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
10124        let plan = linux_plan(StartMode::Boot);
10125        let rendered = systemd_unit(&plan);
10126        let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
10127        let review = review_least_privilege(
10128            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
10129            &plan,
10130        );
10131        assert!(!review.is_least_privilege(), "{review}");
10132        assert!(
10133            review
10134                .excesses()
10135                .iter()
10136                .any(|finding| finding.subject == "NoNewPrivileges"),
10137            "{review}"
10138        );
10139    }
10140
10141    /// Whether reviewing `unit` against `plan` reports `kind` for `subject`.
10142    fn reports(unit: &str, plan: &InstallPlan, subject: &str, kind: FindingKind) -> bool {
10143        review_least_privilege(
10144            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, unit.to_string()),
10145            plan,
10146        )
10147        .findings()
10148        .iter()
10149        .any(|finding| finding.subject == subject && finding.kind == kind)
10150    }
10151
10152    #[test]
10153    fn protected_paths_are_inaccessible_and_the_review_keeps_them_so() {
10154        let plan = linux_plan(StartMode::Boot).with_protected_paths(vec![
10155            PathBuf::from("/srv/keys"),
10156            PathBuf::from("/data/my backups"),
10157        ]);
10158        let unit = systemd_unit(&plan);
10159        let directives = ini_directives(&unit, "Service");
10160        assert_eq!(
10161            split_quoted(&directives["InaccessiblePaths"]),
10162            ["-/srv/keys", "-/data/my backups"]
10163        );
10164        assert!(!reports(
10165            &unit,
10166            &plan,
10167            "InaccessiblePaths",
10168            FindingKind::Excess
10169        ));
10170        let dropped = unit.replace(
10171            &format!("InaccessiblePaths={}\n", directives["InaccessiblePaths"]),
10172            "",
10173        );
10174        assert!(reports(
10175            &dropped,
10176            &plan,
10177            "InaccessiblePaths",
10178            FindingKind::Excess
10179        ));
10180        assert!(
10181            !ini_directives(&systemd_unit(&linux_plan(StartMode::Boot)), "Service")
10182                .contains_key("InaccessiblePaths")
10183        );
10184    }
10185
10186    // Unix paths: protected paths are a systemd mechanism.
10187    #[cfg(unix)]
10188    #[test]
10189    fn a_path_every_job_or_the_service_needs_cannot_be_protected() {
10190        let own = [PathBuf::from("/var/lib/runner-manager/config")];
10191        for refused in [
10192            "/",
10193            "/usr",
10194            "/etc",
10195            "/tmp",
10196            "relative",
10197            "/srv/../etc",
10198            "/var/lib",
10199            "/var/lib/runner-manager/config/x",
10200        ] {
10201            assert!(
10202                ProtectedPaths::refusal(Path::new(refused), &own).is_some(),
10203                "{refused}"
10204            );
10205        }
10206        for accepted in ["/srv/keys", "/home/ivan/.ssh", "/var/lib/postgresql"] {
10207            assert_eq!(
10208                ProtectedPaths::refusal(Path::new(accepted), &own),
10209                None,
10210                "{accepted}"
10211            );
10212        }
10213        let directory = tempfile::tempdir().unwrap();
10214        assert_eq!(
10215            ProtectedPaths::load(directory.path()).unwrap(),
10216            ProtectedPaths::default()
10217        );
10218        let list = ProtectedPaths {
10219            paths: vec![PathBuf::from("/srv/keys")],
10220        };
10221        list.save(directory.path()).unwrap();
10222        assert_eq!(ProtectedPaths::load(directory.path()).unwrap(), list);
10223    }
10224
10225    #[test]
10226    fn a_boot_unit_with_directories_under_root_binds_them_back_into_its_tmpfs_home() {
10227        let legacy = InstallPlan::unchecked(
10228            ServiceIdentity::product(),
10229            StartMode::Boot,
10230            "/root/.local/state/runner-manager/bin/runner-manager",
10231            ServiceDirectories {
10232                config: PathBuf::from("/root/.config/runner-manager"),
10233                state: PathBuf::from("/root/.local/state/runner-manager"),
10234                runtime: PathBuf::from("/root/.local/share/runner-manager/runtime"),
10235                logs: PathBuf::from("/root/.local/share/runner-manager/logs"),
10236            },
10237        )
10238        .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token");
10239        let unit = systemd_unit(&legacy);
10240        let directives = ini_directives(&unit, "Service");
10241        assert_eq!(directives["ProtectHome"], "tmpfs");
10242        assert_eq!(
10243            directives["BindPaths"],
10244            "/root/.config/runner-manager /root/.local/state/runner-manager \
10245             /root/.local/share/runner-manager/runtime /root/.local/share/runner-manager/logs",
10246            "only the directories under a home tree, never the secrets outside it"
10247        );
10248        assert!(!reports(&unit, &legacy, "BindPaths", FindingKind::Excess));
10249        let widened = unit.replace("BindPaths=", "BindPaths=/root/.ssh /etc:/root/.config ");
10250        assert!(reports(&widened, &legacy, "BindPaths", FindingKind::Excess));
10251    }
10252
10253    #[test]
10254    fn no_unit_breaks_tar_by_answering_openat2_with_enosys() {
10255        for mode in [StartMode::Boot, StartMode::Login] {
10256            let unit = systemd_unit(&linux_plan(mode));
10257            assert!(
10258                !unit.contains("RestrictSUIDSGID"),
10259                "RestrictSUIDSGID fails every tar extraction in a job: {unit}"
10260            );
10261        }
10262    }
10263
10264    #[test]
10265    fn only_a_boot_daemon_keeps_capabilities_and_boot_units_hide_home_trees() {
10266        let boot = systemd_unit(&linux_plan(StartMode::Boot));
10267        let directives = ini_directives(&boot, "Service");
10268        assert_eq!(directives["CapabilityBoundingSet"], BOOT_CAPABILITIES);
10269        assert_eq!(directives["AmbientCapabilities"], "");
10270        assert_eq!(directives["NoNewPrivileges"], "yes");
10271        assert_eq!(directives["ProtectHome"], "tmpfs");
10272        assert!(!directives.contains_key("BindPaths"));
10273
10274        let login = systemd_unit(&linux_plan(StartMode::Login));
10275        let directives = ini_directives(&login, "Service");
10276        assert_eq!(directives["CapabilityBoundingSet"], "");
10277        assert!(!directives.contains_key("ProtectHome"));
10278
10279        let unprotected = boot.replace("ProtectHome=tmpfs\n", "");
10280        assert!(reports(
10281            &unprotected,
10282            &linux_plan(StartMode::Boot),
10283            "ProtectHome",
10284            FindingKind::Excess
10285        ));
10286        let boot_set = format!("CapabilityBoundingSet={BOOT_CAPABILITIES}\n");
10287        let inverted = boot.replace(&boot_set, "CapabilityBoundingSet=~CAP_SYS_ADMIN\n");
10288        assert!(reports(
10289            &inverted,
10290            &linux_plan(StartMode::Boot),
10291            "CapabilityBoundingSet",
10292            FindingKind::Excess
10293        ));
10294        let widened = boot.replace(
10295            &boot_set,
10296            &format!("CapabilityBoundingSet={BOOT_CAPABILITIES} CAP_SYS_ADMIN\n"),
10297        );
10298        assert!(reports(
10299            &widened,
10300            &linux_plan(StartMode::Boot),
10301            "CapabilityBoundingSet",
10302            FindingKind::Excess
10303        ));
10304        let narrowed = boot.replace(&boot_set, "CapabilityBoundingSet=CAP_SETUID\n");
10305        assert!(reports(
10306            &narrowed,
10307            &linux_plan(StartMode::Boot),
10308            "CapabilityBoundingSet",
10309            FindingKind::Shortfall
10310        ));
10311    }
10312
10313    #[test]
10314    fn status_reports_the_registered_account_and_falls_back_to_the_record() {
10315        let recorded = ServiceAccount::for_start_mode(StartMode::Boot).to_string();
10316        for (registered, shown) in [(Some("ivan"), "ivan"), (None, recorded.as_str())] {
10317            let host = Host::new();
10318            let operations = host.operations();
10319            operations.install(&host.request(StartMode::Boot)).unwrap();
10320            host.controls.edit("runner-manager", |registration| {
10321                registration.account = registered.map(str::to_string);
10322            });
10323            let status = operations.status().unwrap().to_string();
10324            assert!(
10325                status.contains(&format!("account                   {shown}\n")),
10326                "{status}"
10327            );
10328        }
10329    }
10330
10331    #[cfg(target_os = "linux")]
10332    #[test]
10333    fn a_boot_install_refuses_a_unit_a_drop_in_runs_as_another_account() {
10334        let host = Host::new();
10335        let operations = host.operations();
10336        operations.install(&host.request(StartMode::Boot)).unwrap();
10337        host.controls.edit("runner-manager", |registration| {
10338            registration.account = Some("ivan".to_string());
10339        });
10340        let refused = operations
10341            .install(&host.request(StartMode::Boot))
10342            .expect_err("a unit running as ivan cannot use root's tree");
10343        let message = refused.to_string();
10344        assert!(message.contains("run as ivan"), "{message}");
10345        assert!(message.contains("Remove `User=`"), "{message}");
10346    }
10347
10348    #[test]
10349    fn manager_account_spellings_match_the_record() {
10350        assert_eq!(
10351            windows_service_account("LocalSystem"),
10352            ServiceAccount::LocalSystem.as_str()
10353        );
10354        assert_eq!(windows_service_account(".\\svc-runner"), ".\\svc-runner");
10355        assert_eq!(
10356            scheduled_task_account(Some("S-1-5-21-1-2-3-1001".into())),
10357            None
10358        );
10359        assert_eq!(
10360            scheduled_task_account(Some("HOST\\op".into())),
10361            Some("HOST\\op".to_string())
10362        );
10363    }
10364
10365    #[test]
10366    fn exec_start_prefixes_are_not_part_of_the_binary() {
10367        for (value, expected) in [
10368            ("/opt/rm daemon run", "/opt/rm daemon run"),
10369            ("-/opt/rm daemon run", "/opt/rm daemon run"),
10370            ("+!/opt/rm daemon run", "/opt/rm daemon run"),
10371            ("!!/opt/rm daemon run", "/opt/rm daemon run"),
10372            ("@/opt/rm runner-manager daemon run", "/opt/rm daemon run"),
10373            ("-@\"/opt/my rm\" rm daemon", "\"/opt/my rm\" daemon"),
10374        ] {
10375            assert_eq!(systemd_exec_command_line(value), expected, "{value}");
10376        }
10377        assert_eq!(
10378            executable_from_command_line(&systemd_exec_command_line("-/opt/rm daemon")),
10379            Some(PathBuf::from("/opt/rm"))
10380        );
10381    }
10382
10383    #[test]
10384    fn restart_sec_accepts_systemd_time_spans() {
10385        for (value, expected) in [
10386            ("15", Duration::from_secs(15)),
10387            ("15s", Duration::from_secs(15)),
10388            ("1min", Duration::from_secs(60)),
10389            ("1min 30s", Duration::from_secs(90)),
10390            ("1min30s", Duration::from_secs(90)),
10391            ("2h", Duration::from_secs(7_200)),
10392            ("500ms", Duration::from_millis(500)),
10393            ("1.5s", Duration::from_millis(1_500)),
10394        ] {
10395            assert_eq!(systemd_timespan(value), Some(expected), "{value}");
10396        }
10397        for value in ["", "infinity", "soon", "5 parsecs", "s"] {
10398            assert_eq!(systemd_timespan(value), None, "{value}");
10399        }
10400    }
10401
10402    #[cfg(unix)]
10403    #[test]
10404    fn the_system_tree_is_reported_as_roots_installation_under_var_lib() {
10405        let system = ServiceDirectories::of(&AppPaths::system());
10406        assert_eq!(system.root(), Path::new("/var/lib/runner-manager"));
10407        assert_eq!(
10408            data_root_owner(&system.root(), Some(Path::new("/home/op"))).as_deref(),
10409            Some("root")
10410        );
10411    }
10412
10413    #[test]
10414    fn a_boot_service_refuses_directories_it_could_not_open() {
10415        let var = Path::new("/var/lib/runner-manager/state");
10416        assert_eq!(boot_directory_refusal(var, Some(0)), None);
10417        let refused = boot_directory_refusal(var, Some(1000)).expect("another owner");
10418        assert!(refused.contains("uid 1000"), "{refused}");
10419        assert!(boot_directory_refusal(var, None).is_some());
10420        for hidden in ["/home/op/.config/runner-manager", "/root/.local/state/rm"] {
10421            let refused =
10422                boot_directory_refusal(Path::new(hidden), Some(0)).expect("a hidden tree");
10423            assert!(refused.contains("cannot see"), "{refused}");
10424        }
10425    }
10426
10427    #[cfg(unix)]
10428    #[test]
10429    fn a_moved_tree_keeps_files_modes_and_links_but_not_the_skipped_entry() {
10430        use std::os::unix::fs::PermissionsExt as _;
10431
10432        let from = tempfile::tempdir().unwrap();
10433        let to = tempfile::tempdir().unwrap();
10434        let target = to.path().join("state");
10435        std::fs::create_dir_all(from.path().join("journal/2026")).unwrap();
10436        std::fs::write(from.path().join("journal/2026/attempt"), b"entry").unwrap();
10437        std::fs::write(from.path().join("secret"), b"x").unwrap();
10438        std::fs::set_permissions(
10439            from.path().join("secret"),
10440            std::fs::Permissions::from_mode(0o600),
10441        )
10442        .unwrap();
10443        std::os::unix::fs::symlink("journal", from.path().join("latest")).unwrap();
10444        std::fs::create_dir(from.path().join("bin")).unwrap();
10445
10446        copy_tree(from.path(), &target, Some("bin")).unwrap();
10447
10448        assert_eq!(
10449            std::fs::read(target.join("journal/2026/attempt")).unwrap(),
10450            b"entry"
10451        );
10452        let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
10453        assert_eq!(mode(&target.join("secret")), 0o600);
10454        assert_eq!(mode(&target.join("journal")), 0o700);
10455        assert_eq!(
10456            std::fs::read_link(target.join("latest")).unwrap(),
10457            Path::new("journal")
10458        );
10459        assert!(!target.join("bin").exists());
10460
10461        // An occupied target is set aside whole, not merged into or deleted.
10462        std::fs::write(target.join("stale.sqlite3-wal"), b"stale").unwrap();
10463        set_aside(&target, "20261110T000000Z").unwrap();
10464        assert!(!target.exists());
10465        let aside = to.path().join("state.before-move-20261110T000000Z");
10466        assert_eq!(
10467            std::fs::read(aside.join("stale.sqlite3-wal")).unwrap(),
10468            b"stale"
10469        );
10470        copy_tree(from.path(), &target, Some("bin")).unwrap();
10471        assert!(!target.join("stale.sqlite3-wal").exists());
10472        set_aside(&to.path().join("absent"), "x").unwrap();
10473    }
10474
10475    #[test]
10476    fn systemd_drop_ins_replace_base_service_directives() {
10477        let base = systemd_unit(&linux_plan(StartMode::Boot));
10478        let combined = format!(
10479            "# /etc/systemd/system/runner-manager.service\n{base}\n\
10480             # /etc/systemd/system/runner-manager.service.d/account.conf\n\
10481             [Service]\nUser=ivan\nRestartSec=30\nExecStart=\n\
10482             ExecStart=/opt/runner-manager daemon run\n"
10483        );
10484        let directives = ini_directives(&combined, "Service");
10485        assert_eq!(directives["User"], "ivan");
10486        assert_eq!(directives["RestartSec"], "30");
10487        assert_eq!(directives["ExecStart"], "/opt/runner-manager daemon run");
10488        assert_eq!(directives["ProtectSystem"], "strict");
10489    }
10490
10491    #[test]
10492    fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
10493        let plan = linux_plan(StartMode::Login);
10494        let rendered = systemd_unit(&plan);
10495        let widened = edited(
10496            &rendered,
10497            "CapabilityBoundingSet=\n",
10498            "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
10499        );
10500        let review = review_least_privilege(
10501            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10502            &plan,
10503        );
10504        assert!(!review.is_least_privilege(), "{review}");
10505        assert!(
10506            review
10507                .excesses()
10508                .iter()
10509                .any(|finding| finding.subject == "CapabilityBoundingSet"),
10510            "{review}"
10511        );
10512    }
10513
10514    #[test]
10515    fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
10516        let plan = linux_plan(StartMode::Boot);
10517        let rendered = systemd_unit(&plan);
10518        let widened = edited(
10519            &rendered,
10520            "[Install]",
10521            "ListenStream=127.0.0.1:9000\n\n[Install]",
10522        );
10523        let review = review_least_privilege(
10524            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10525            &plan,
10526        );
10527        assert!(
10528            !review.is_least_privilege(),
10529            "07-security.md rule 2 forbids any inbound surface: {review}"
10530        );
10531    }
10532
10533    #[test]
10534    fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
10535        let plan = linux_plan(StartMode::Boot);
10536        let rendered = systemd_unit(&plan);
10537        let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
10538        let review = review_least_privilege(
10539            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
10540            &plan,
10541        );
10542        assert!(
10543            review.is_least_privilege(),
10544            "too little authority is not an excess: {review}"
10545        );
10546        assert!(
10547            review
10548                .findings()
10549                .iter()
10550                .any(|finding| finding.kind == FindingKind::Shortfall
10551                    && finding.detail.contains("runtime")),
10552            "{review}"
10553        );
10554    }
10555
10556    #[test]
10557    fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
10558        let plan = linux_plan(StartMode::Login);
10559        let rendered = launchd_plist(&plan);
10560        let widened = edited(
10561            &rendered,
10562            "<key>ProcessType</key>",
10563            "<key>UserName</key>\n  <string>root</string>\n  <key>ProcessType</key>",
10564        );
10565        let review = review_least_privilege(
10566            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10567            &plan,
10568        );
10569        assert!(!review.is_least_privilege(), "{review}");
10570        assert!(
10571            review
10572                .excesses()
10573                .iter()
10574                .any(|finding| finding.subject == "UserName"),
10575            "{review}"
10576        );
10577    }
10578
10579    #[test]
10580    fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
10581        let plan = linux_plan(StartMode::Boot);
10582        let rendered = launchd_plist(&plan);
10583        let widened = edited(
10584            &rendered,
10585            "<key>SessionCreate</key>\n  <false/>",
10586            "<key>SessionCreate</key>\n  <true/>",
10587        );
10588        let review = review_least_privilege(
10589            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10590            &plan,
10591        );
10592        assert!(!review.is_least_privilege(), "{review}");
10593        assert!(
10594            review
10595                .excesses()
10596                .iter()
10597                .any(|finding| finding.subject == "SessionCreate"),
10598            "{review}"
10599        );
10600    }
10601
10602    #[test]
10603    fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
10604        let plan = linux_plan(StartMode::Boot);
10605        let rendered = launchd_plist(&plan);
10606        let widened = edited(
10607            &rendered,
10608            "<key>ProcessType</key>",
10609            "<key>MachServices</key>\n  <dict/>\n  <key>ProcessType</key>",
10610        );
10611        let review = review_least_privilege(
10612            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10613            &plan,
10614        );
10615        assert!(!review.is_least_privilege(), "{review}");
10616    }
10617
10618    #[test]
10619    fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
10620        let plan = windows_plan(StartMode::Login);
10621        let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
10622        let widened = edited(
10623            &rendered,
10624            "<RunLevel>LeastPrivilege</RunLevel>",
10625            "<RunLevel>HighestAvailable</RunLevel>",
10626        );
10627        let review = review_least_privilege(
10628            &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
10629            &plan,
10630        );
10631        assert!(!review.is_least_privilege(), "{review}");
10632        assert!(
10633            review
10634                .excesses()
10635                .iter()
10636                .any(|finding| finding.subject == "RunLevel"),
10637            "{review}"
10638        );
10639    }
10640
10641    #[test]
10642    fn a_task_that_would_store_a_password_is_not_least_privilege() {
10643        let plan = windows_plan(StartMode::Login);
10644        let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
10645        let widened = edited(
10646            &rendered,
10647            "<LogonType>InteractiveToken</LogonType>",
10648            "<LogonType>Password</LogonType>",
10649        );
10650        let review = review_least_privilege(
10651            &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
10652            &plan,
10653        );
10654        assert!(!review.is_least_privilege(), "{review}");
10655    }
10656
10657    #[test]
10658    fn an_interactive_windows_service_is_not_least_privilege() {
10659        let plan = windows_plan(StartMode::Boot);
10660        let rendered = windows_service_descriptor(&plan);
10661        let widened = edited(
10662            &rendered,
10663            "ServiceType=OWN_PROCESS",
10664            "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
10665        );
10666        let review = review_least_privilege(
10667            &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
10668            &plan,
10669        );
10670        assert!(!review.is_least_privilege(), "{review}");
10671        assert!(
10672            review
10673                .excesses()
10674                .iter()
10675                .any(|finding| finding.subject == "ServiceType"),
10676            "{review}"
10677        );
10678    }
10679
10680    #[test]
10681    fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
10682        let plan = windows_plan(StartMode::Boot);
10683        let rendered = windows_service_descriptor(&plan);
10684        let changed = edited(
10685            &rendered,
10686            "Account=NT AUTHORITY\\SYSTEM",
10687            "Account=NT AUTHORITY\\LocalService",
10688        );
10689        let review = review_least_privilege(
10690            &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
10691            &plan,
10692        );
10693        // LocalService is *less* privileged, so this is not an excess. It is
10694        // still wrong, because `d2`'s DACL names SY, BA and OW and nothing else:
10695        // the daemon would start and find no credential. Reporting it as a
10696        // shortfall rather than as an excess is the difference between a check
10697        // that understands the requirement and one that counts adjectives.
10698        assert!(review.is_least_privilege(), "{review}");
10699        assert!(
10700            review.findings().iter().any(|finding| {
10701                finding.kind == FindingKind::Shortfall && finding.subject == "Account"
10702            }),
10703            "{review}"
10704        );
10705    }
10706
10707    // -----------------------------------------------------------------------
10708    // The recorded absolute path, and what became of it
10709    // -----------------------------------------------------------------------
10710
10711    #[test]
10712    fn a_recorded_path_that_is_still_there_is_current() {
10713        let host = Host::new();
10714        let state = inspect_binary(&host.binary, Some(&host.binary));
10715        assert!(!state.is_error(), "{state}");
10716        assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
10717    }
10718
10719    #[test]
10720    fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
10721        let host = Host::new();
10722        // An `npm i -g` binary lives under the active Node installation's
10723        // global prefix. Switching Node versions moves the prefix, and the
10724        // recorded path stops existing while the registration survives.
10725        let recorded = host.binary.clone();
10726        let healthy = inspect_binary(&recorded, Some(&recorded));
10727        assert!(
10728            !healthy.is_error(),
10729            "the discriminator: before the binary moves, this must be healthy"
10730        );
10731
10732        std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
10733
10734        let state = inspect_binary(&recorded, Some(&recorded));
10735        assert!(state.is_error(), "{state}");
10736        assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
10737        assert!(
10738            state.to_string().contains("npm"),
10739            "the message must name the cause an operator will not otherwise connect: {state}"
10740        );
10741    }
10742
10743    #[test]
10744    fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
10745        let root = tempfile::tempdir().expect("a temporary directory");
10746        let state = inspect_binary(root.path(), None);
10747        assert!(state.is_error(), "{state}");
10748        assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
10749    }
10750
10751    #[test]
10752    fn a_registration_naming_a_different_binary_is_a_divergence() {
10753        let host = Host::new();
10754        let other = host.binary.with_file_name("something-else");
10755        let state = inspect_binary(&host.binary, Some(&other));
10756        assert!(state.is_error(), "{state}");
10757        assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
10758    }
10759
10760    #[test]
10761    fn absence_is_reported_before_divergence() {
10762        let host = Host::new();
10763        let recorded = host.binary.clone();
10764        std::fs::remove_file(&recorded).expect("removable");
10765        let other = recorded.with_file_name("something-else");
10766        // Both faults are true at once. The operator needs to hear about the
10767        // missing file, not about a disagreement between two paths of which one
10768        // does not exist.
10769        assert!(matches!(
10770            inspect_binary(&recorded, Some(&other)),
10771            BinaryPath::Missing { .. }
10772        ));
10773    }
10774
10775    // -----------------------------------------------------------------------
10776    // The record
10777    // -----------------------------------------------------------------------
10778
10779    #[test]
10780    fn the_record_round_trips_through_toml() {
10781        let host = Host::new();
10782        let plan = InstallPlan::resolve(
10783            ServiceIdentity::product(),
10784            &host.request(StartMode::Boot),
10785            ServiceDirectories::of(&host.paths),
10786        )
10787        .expect("a resolvable plan");
10788        let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
10789        let record = InstallRecord::of(&plan, &definition, Utc::now());
10790        record.write(&host.paths).expect("a writable record");
10791        let read = InstallRecord::read(&host.paths)
10792            .expect("a readable record")
10793            .expect("a record is there");
10794        assert_eq!(read, record);
10795        assert_eq!(read.binary, host.binary);
10796        assert!(read.binary.is_absolute());
10797    }
10798
10799    #[test]
10800    fn a_unit_written_by_an_older_build_is_reported_and_a_current_one_is_not() {
10801        let host = Host::new();
10802        let identity = ServiceIdentity::product();
10803        let plan = InstallPlan::resolve(
10804            identity.clone(),
10805            &host.request(StartMode::Boot),
10806            ServiceDirectories::of(&host.paths),
10807        )
10808        .expect("a resolvable plan");
10809        let installed = systemd_unit(&plan);
10810        let definition =
10811            ServiceDefinition::from_text(DefinitionKind::SystemdUnit, installed.clone());
10812        let mut record = InstallRecord::of(&plan, &definition, Utc::now());
10813        let path = PathBuf::from("/etc/systemd/system/runner-manager.service");
10814
10815        assert_eq!(
10816            systemd_definition_drift(&identity, &record, path.clone(), &installed),
10817            None,
10818            "the record must rebuild exactly the unit `service install` wrote"
10819        );
10820
10821        record.installed_by_version = "0.4.7".into();
10822        let frozen = installed.replace(
10823            "[Service]\n",
10824            "[Service]\nLoadCredential=runner-manager.user-access-token:/var/lib/runner-manager/secrets/user-access-token\n",
10825        );
10826        let drift = systemd_definition_drift(&identity, &record, path, &frozen)
10827            .expect("an outdated unit is drift");
10828        assert_eq!(drift.installed_by_version, "0.4.7");
10829        assert_eq!(drift.rendered, installed);
10830
10831        let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
10832            .with_definition_drift(Some(drift));
10833        assert!(!status.is_healthy());
10834        assert!(
10835            status
10836                .problems()
10837                .iter()
10838                .any(|problem| problem.subject == "definition" && problem.detail.contains("0.4.7")),
10839            "{status}"
10840        );
10841    }
10842
10843    #[test]
10844    fn a_rejected_credential_keeps_its_first_moment_and_makes_the_service_unhealthy() {
10845        let host = Host::new();
10846        assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
10847        let first = Utc::now() - chrono::Duration::hours(9);
10848        record_github_credential_rejected(&host.paths, first).unwrap();
10849        record_github_credential_rejected(&host.paths, Utc::now()).unwrap();
10850        let since = github_credential_rejected_since(&host.paths).unwrap();
10851        assert_eq!(since, Some(first));
10852
10853        let status = ServiceStatus::compose(
10854            ServiceIdentity::product(),
10855            None,
10856            None,
10857            None,
10858            None,
10859            &host.paths,
10860        )
10861        .with_credential_rejection(since);
10862        assert!(!status.is_healthy());
10863        assert_eq!(status.credential_rejected_since(), Some(first));
10864        assert!(status.to_string().contains("auth login"), "{status}");
10865
10866        clear_github_credential_rejection(&host.paths).unwrap();
10867        clear_github_credential_rejection(&host.paths).unwrap();
10868        assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
10869    }
10870
10871    /// A guest that started nothing for 13 days while this said `healthy`.
10872    #[test]
10873    fn blocked_launches_make_the_service_unhealthy_with_their_remedy() {
10874        use crate::launch_health::{
10875            BlockCause, Viewpoint, clear_launches_blocked, launches_blocked,
10876            record_launches_blocked,
10877        };
10878
10879        let host = Host::new();
10880        let compose = || {
10881            ServiceStatus::compose(
10882                ServiceIdentity::product(),
10883                None,
10884                None,
10885                None,
10886                None,
10887                &host.paths,
10888            )
10889            .with_launches_blocked(launches_blocked(&host.paths, Utc::now()))
10890        };
10891        assert!(
10892            !compose()
10893                .problems()
10894                .iter()
10895                .any(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
10896        );
10897        let blocked = BlockCause::FenceHeld {
10898            directory: PathBuf::from("launch-fence"),
10899            owner: None,
10900        }
10901        .blocked_since(Utc::now(), Viewpoint::Guest);
10902        record_launches_blocked(&host.paths, &blocked, Utc::now()).unwrap();
10903
10904        let status = compose();
10905        assert!(!status.is_healthy());
10906        assert_eq!(status.launches_blocked(), Some(&blocked));
10907        let problem = status
10908            .problems()
10909            .iter()
10910            .find(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
10911            .expect("reported under its own subject");
10912        assert!(problem.detail.contains("systemctl restart"), "{status}");
10913
10914        clear_launches_blocked(&host.paths).unwrap();
10915        assert_eq!(compose().launches_blocked(), None);
10916    }
10917
10918    /// The daemon's own "I cannot read my credential", which used to exist
10919    /// only in its log while `service status` said `healthy`.
10920    #[test]
10921    fn an_unreadable_credential_keeps_its_first_moment_and_names_the_exact_command() {
10922        let host = Host::new();
10923        assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
10924        let first = Utc::now() - chrono::Duration::minutes(40);
10925        record_credential_unreadable(&host.paths, first).unwrap();
10926        record_credential_unreadable(&host.paths, Utc::now()).unwrap();
10927        let since = credential_unreadable_since(&host.paths).unwrap();
10928        assert_eq!(since, Some(first), "a restart loop must not move the start");
10929
10930        let status = ServiceStatus::compose(
10931            ServiceIdentity::product(),
10932            None,
10933            None,
10934            None,
10935            None,
10936            &host.paths,
10937        )
10938        .with_credential_unreadable(since);
10939        assert!(!status.is_healthy());
10940        let problem = status
10941            .problems()
10942            .iter()
10943            .find(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT)
10944            .expect("reported under its own subject");
10945        // No record, so the default start mode, whose exact command is named.
10946        assert!(
10947            problem
10948                .detail
10949                .contains(&sign_in_instruction(StartMode::default())),
10950            "{status}"
10951        );
10952
10953        clear_credential_unreadable(&host.paths).unwrap();
10954        clear_credential_unreadable(&host.paths).unwrap();
10955        assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
10956        let healthy = ServiceStatus::compose(
10957            ServiceIdentity::product(),
10958            None,
10959            None,
10960            None,
10961            None,
10962            &host.paths,
10963        )
10964        .with_credential_unreadable(None);
10965        assert!(
10966            !healthy
10967                .problems()
10968                .iter()
10969                .any(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT),
10970            "{healthy}"
10971        );
10972    }
10973
10974    #[test]
10975    fn a_sudo_command_names_a_binary_sudo_would_not_find() {
10976        assert_eq!(sudo_program(None), "runner-manager");
10977        assert_eq!(
10978            sudo_program(Some(Path::new("/usr/local/bin/runner-manager"))),
10979            "runner-manager"
10980        );
10981        assert_eq!(
10982            sudo_program(Some(Path::new(
10983                "/home/op/.nvm/versions/node/v26.13.0/lib/node_modules/@ivan-murzak/\
10984                 runner-manager-linux-x64/bin/runner-manager"
10985            ))),
10986            "/home/op/.nvm/versions/node/v26.13.0/lib/node_modules/@ivan-murzak/\
10987             runner-manager-linux-x64/bin/runner-manager"
10988        );
10989        assert_eq!(
10990            sudo_program(Some(Path::new("/home/o'p/my bin/runner-manager"))),
10991            "'/home/o'\\''p/my bin/runner-manager'"
10992        );
10993        assert_eq!(
10994            sudo_command("service status"),
10995            "sudo runner-manager service status"
10996        );
10997    }
10998
10999    #[test]
11000    fn the_sign_in_instruction_names_the_start_mode_it_writes() {
11001        assert!(sign_in_instruction(StartMode::Login).contains("auth login --start-at login`"));
11002        assert!(sign_in_instruction(StartMode::Boot).contains("auth login --start-at boot`"));
11003        assert_eq!(
11004            sign_in_instruction(StartMode::Boot).starts_with("`sudo "),
11005            cfg!(unix),
11006            "a machine-scoped store needs root on Unix"
11007        );
11008        assert_eq!(
11009            sign_in_instruction(StartMode::Login).contains("SSH"),
11010            cfg!(target_os = "macos"),
11011            "only the macOS login keychain is out of an SSH session's reach"
11012        );
11013    }
11014
11015    /// The record is readable by the account whose directory it is in.
11016    ///
11017    /// A boot-mode `service install` runs under `sudo`, and `NamedTempFile`
11018    /// creates at `0600`, so the record landed `root`-owned and unreadable by
11019    /// the operator — `service status` then failed on their own host with
11020    /// `Permission denied`. Ownership cannot be reproduced in an unprivileged
11021    /// test, but the mode that made ownership fatal can, and it is the half
11022    /// this code controls.
11023    #[cfg(unix)]
11024    #[test]
11025    fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
11026        use std::os::unix::fs::PermissionsExt as _;
11027
11028        let host = Host::new();
11029        let plan = InstallPlan::resolve(
11030            ServiceIdentity::product(),
11031            &host.request(StartMode::Boot),
11032            ServiceDirectories::of(&host.paths),
11033        )
11034        .expect("a resolvable plan");
11035        let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
11036        InstallRecord::of(&plan, &definition, Utc::now())
11037            .write(&host.paths)
11038            .expect("a writable record");
11039
11040        let mode = std::fs::metadata(InstallRecord::path(&host.paths))
11041            .expect("the record is there")
11042            .permissions()
11043            .mode()
11044            & 0o777;
11045        assert_eq!(
11046            mode, 0o644,
11047            "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
11048             service install` wrote, and `service status` fails on their own host. It holds no \
11049             credential and sits in a 0700 directory, so 0644 discloses nothing"
11050        );
11051    }
11052
11053    /// A record written before the service ran a copy of its own must still
11054    /// load. It reads as `None`, which is the truth about it: that registration
11055    /// names the package manager's file and cannot be upgraded under itself.
11056    #[test]
11057    fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
11058        let host = Host::new();
11059        let path = InstallRecord::path(&host.paths);
11060        std::fs::write(
11061            &path,
11062            format!(
11063                "schema_version = {RECORD_SCHEMA_VERSION}
11064service_name = \"runner-manager\"
11065                 manager = \"systemd\"
11066start_mode = \"boot\"
11067account = \"root\"
11068                 binary = \"/x\"
11069arguments = []
11070restart_delay_secs = 15
11071                 restart_reset_secs = 600
11072log_file = \"/x\"
11073                 installed_at = \"2026-01-01T00:00:00Z\"
11074installed_by_version = \"0.1.0\"
11075                 [directories]
11076config = \"/a\"
11077state = \"/b\"
11078runtime = \"/c\"
11079logs = \"/d\"
11080"
11081            ),
11082        )
11083        .expect("a writable record");
11084        let read = InstallRecord::read(&host.paths)
11085            .expect("a record missing an optional field is still readable")
11086            .expect("a record is there");
11087        assert_eq!(
11088            read.source_binary, None,
11089            "the legacy layout has no source, and must not invent one"
11090        );
11091    }
11092
11093    /// The field that makes an upgrade possible survives the write.
11094    #[test]
11095    fn a_registration_remembers_the_file_it_was_copied_from() {
11096        let host = Host::new();
11097        let source = host.binary.with_file_name("npm-installed-runner-manager");
11098        std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
11099        let plan = InstallPlan::resolve(
11100            ServiceIdentity::product(),
11101            &host.request(StartMode::Boot).copied_from(&source),
11102            ServiceDirectories::of(&host.paths),
11103        )
11104        .expect("a resolvable plan");
11105        let definition = ServiceDefinition::from_text(
11106            DefinitionKind::SystemdUnit,
11107            "[Service]
11108",
11109        );
11110        let record = InstallRecord::of(&plan, &definition, Utc::now());
11111        record.write(&host.paths).expect("a writable record");
11112
11113        let read = InstallRecord::read(&host.paths)
11114            .expect("a readable record")
11115            .expect("a record is there");
11116        assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
11117        assert_ne!(
11118            read.source_binary.as_deref(),
11119            Some(read.binary.as_path()),
11120            "the whole point is that the two are different files: one the service holds open,              one the package manager is free to replace"
11121        );
11122    }
11123
11124    #[test]
11125    fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
11126        let host = Host::new();
11127        let path = InstallRecord::path(&host.paths);
11128        std::fs::write(
11129            &path,
11130            format!(
11131                "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
11132                 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
11133                 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
11134                 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
11135                 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
11136                RECORD_SCHEMA_VERSION + 1
11137            ),
11138        )
11139        .expect("a writable record");
11140        let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
11141        assert!(
11142            matches!(error, ServiceError::RecordUnreadable { .. }),
11143            "{error}"
11144        );
11145        assert!(
11146            error.to_string().contains("service uninstall"),
11147            "the message must say how to recover: {error}"
11148        );
11149    }
11150
11151    #[test]
11152    fn no_record_is_not_an_error() {
11153        let host = Host::new();
11154        assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
11155        assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
11156    }
11157
11158    // -----------------------------------------------------------------------
11159    // The last successful GitHub contact
11160    // -----------------------------------------------------------------------
11161
11162    #[test]
11163    fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
11164        let host = Host::new();
11165        assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
11166    }
11167
11168    #[test]
11169    fn the_heartbeat_round_trips_to_the_second() {
11170        let host = Host::new();
11171        let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
11172            .expect("a valid timestamp")
11173            .with_timezone(&Utc);
11174        record_github_contact(&host.paths, at).expect("a writable heartbeat");
11175        assert_eq!(
11176            last_github_contact(&host.paths).expect("readable"),
11177            Some(at)
11178        );
11179    }
11180
11181    #[test]
11182    fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
11183        let host = Host::new();
11184        std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
11185        let error = last_github_contact(&host.paths)
11186            .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
11187        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11188    }
11189
11190    /// The surface the three-hour outage did not have.
11191    ///
11192    /// A root that refuses a launch does so before any attempt row exists, and
11193    /// the daemon's log scrubs the paths out of the sentence, so this file is
11194    /// the only place the directory and the remediation can reach the operator.
11195    #[test]
11196    fn a_runner_root_refusal_round_trips_for_service_status() {
11197        let host = Host::new();
11198        let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
11199
11200        assert!(
11201            runner_root_refusals(&host.paths)
11202                .expect("readable")
11203                .is_empty(),
11204            "no record means every policy is placing runners"
11205        );
11206
11207        record_runner_root_refusal(
11208            &host.paths,
11209            "policy-a",
11210            at,
11211            "denied_by_privacy_policy",
11212            "/Volumes/NVME/runners",
11213            "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
11214        )
11215        .expect("a writable record");
11216
11217        let refusals = runner_root_refusals(&host.paths).expect("readable");
11218        assert_eq!(refusals.len(), 1);
11219        assert_eq!(refusals[0].policy, "policy-a");
11220        assert_eq!(refusals[0].at, at);
11221        assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
11222        assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
11223        assert!(
11224            refusals[0].detail.contains("/Volumes/NVME/runners")
11225                && refusals[0].detail.contains("Full Disk Access"),
11226            "the path and the remediation are the whole point of this file: {refusals:?}"
11227        );
11228    }
11229
11230    /// The regression that would put the outage straight back.
11231    ///
11232    /// A host runs several policies and they do not share a fate. When the
11233    /// record was one host-wide slot, the policy that placed a runner deleted
11234    /// the record of the policy that could not — on the same reconcile pass —
11235    /// and `service status` reported a healthy host that had started zero
11236    /// runners for an entire target.
11237    #[test]
11238    fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
11239        let host = Host::new();
11240        let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
11241        record_runner_root_refusal(
11242            &host.paths,
11243            "broken",
11244            at,
11245            "denied_by_privacy_policy",
11246            "/Volumes/NVME/runners",
11247            "detail",
11248        )
11249        .expect("a writable record");
11250        record_runner_root_refusal(
11251            &host.paths,
11252            "also-broken",
11253            at,
11254            "not_writable",
11255            "/srv/other",
11256            "detail",
11257        )
11258        .expect("a writable record");
11259
11260        // The policy with its own working root succeeds and clears only itself.
11261        clear_runner_root_refusal(&host.paths, "healthy")
11262            .expect("clearing an absent policy is fine");
11263        clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
11264
11265        let refusals = runner_root_refusals(&host.paths).expect("readable");
11266        assert_eq!(
11267            refusals
11268                .iter()
11269                .map(|r| r.policy.as_str())
11270                .collect::<Vec<_>>(),
11271            vec!["broken"],
11272            "the policy that is still refused must keep its record"
11273        );
11274    }
11275
11276    /// Clearing is what keeps a fixed host from reporting a stale fault, and the
11277    /// file goes when the last entry does.
11278    #[test]
11279    fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
11280        let host = Host::new();
11281        record_runner_root_refusal(
11282            &host.paths,
11283            "p",
11284            Utc::now(),
11285            "not_writable",
11286            "/srv/x",
11287            "detail",
11288        )
11289        .expect("a writable record");
11290
11291        clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
11292        assert!(
11293            !root_refusal_path(&host.paths).exists(),
11294            "a host with nothing refused leaves nothing behind"
11295        );
11296        clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
11297    }
11298
11299    /// Same rule as the heartbeat: absence means "every policy is placing
11300    /// runners", so a record that cannot be parsed must not be reported as one.
11301    #[test]
11302    fn a_malformed_refusal_is_an_error_and_not_silently_none() {
11303        let host = Host::new();
11304        std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
11305        let error = runner_root_refusals(&host.paths)
11306            .expect_err("an unparseable record is not the same as no record");
11307        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11308    }
11309
11310    /// The half of this feature that turns the file into a diagnosis, and the
11311    /// half that had no test: `service status` must actually read it.
11312    ///
11313    /// A **note** and not a problem, deliberately. `service status` runs as
11314    /// whoever typed it, must stay readable on a host with nothing installed,
11315    /// and drives an exit code -- and nothing an operator can type clears this
11316    /// record, so a problem would fail that exit code on a host whose root was
11317    /// fixed but which has had no queued job since, with a printed remedy
11318    /// (`service uninstall && service install`) that does not touch `state/`.
11319    #[test]
11320    fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
11321        let host = Host::new();
11322        record_runner_root_refusal(
11323            &host.paths,
11324            "policy-a",
11325            DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
11326            "denied_by_privacy_policy",
11327            "/Volumes/NVME/runners",
11328            "Grant Full Disk Access to the program that runs the service",
11329        )
11330        .expect("a writable record");
11331
11332        let status = host.operations().status().expect("a readable status");
11333        let notes = status.notes().join("\n");
11334
11335        assert!(
11336            notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
11337            "the directory and the remediation the log had to scrub must appear here: {notes}"
11338        );
11339        assert!(
11340            notes.contains("policy-a"),
11341            "the operator has to know which target placed no runner: {notes}"
11342        );
11343        assert!(
11344            !status
11345                .problems()
11346                .iter()
11347                .any(|problem| problem.subject == "runner root"),
11348            "a record nothing an operator types can clear must not drive the exit code"
11349        );
11350    }
11351
11352    // -----------------------------------------------------------------------
11353    // -----------------------------------------------------------------------
11354    // Install
11355    // -----------------------------------------------------------------------
11356
11357    #[test]
11358    fn install_records_the_absolute_binary_path_and_the_four_directories() {
11359        let host = Host::new();
11360        let installed = host
11361            .operations()
11362            .install(&host.request(StartMode::Boot))
11363            .expect("an install against the recording controls");
11364
11365        assert_eq!(installed.record.binary, host.binary);
11366        assert!(installed.record.binary.is_absolute());
11367        assert_eq!(installed.record.start_mode, StartMode::Boot);
11368        assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
11369        assert_eq!(
11370            installed.record.directories,
11371            ServiceDirectories::of(&host.paths)
11372        );
11373        assert_eq!(
11374            installed.record.log_file,
11375            host.paths.logs_dir().join(LOG_FILE_STEM)
11376        );
11377        assert_eq!(installed.record.restart_delay_secs, 15);
11378
11379        let registrations = host.controls.registrations();
11380        assert_eq!(registrations.len(), 1);
11381        assert_eq!(registrations[0].0, StartMode::Boot);
11382        assert_eq!(registrations[0].1, "runner-manager");
11383    }
11384
11385    #[test]
11386    fn install_is_refused_while_the_single_instance_lock_is_held() {
11387        let host = Host::new();
11388        // The discriminator: with the lock free, the same call succeeds.
11389        {
11390            let operations = host.operations();
11391            operations
11392                .install(&host.request(StartMode::Boot))
11393                .expect("an install with the lock free");
11394            operations.uninstall().expect("a clean slate");
11395        }
11396
11397        let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
11398            .expect("this process takes the lock first");
11399
11400        let error = host
11401            .operations()
11402            .install(&host.request(StartMode::Boot))
11403            .expect_err("a second agent must not be registered while one is running");
11404        assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
11405        assert!(
11406            error.to_string().contains("already running"),
11407            "the message must be actionable: {error}"
11408        );
11409        assert!(
11410            host.controls.registrations().is_empty(),
11411            "a refused install must register nothing"
11412        );
11413        assert_eq!(
11414            InstallRecord::read(&host.paths).expect("readable"),
11415            None,
11416            "a refused install must write no record"
11417        );
11418    }
11419
11420    /// Installing over the same start mode replaces the registration.
11421    ///
11422    /// It used to be refused, and the refusal is what broke a real host: the
11423    /// caller has already swapped the binary the service runs by the time this
11424    /// is reached — that is the documented way to upgrade by hand — so refusing
11425    /// left a daemon running an unregistered binary and, on macOS, one the
11426    /// stored credential's keychain grant did not name.
11427    #[test]
11428    fn installing_over_the_same_start_mode_replaces_the_registration() {
11429        let host = Host::new();
11430        let operations = host.operations();
11431        operations
11432            .install(&host.request(StartMode::Boot))
11433            .expect("the first install");
11434
11435        let again = operations
11436            .install(&host.request(StartMode::Boot))
11437            .expect("an install over the same mode replaces rather than refusing");
11438
11439        assert!(
11440            again.replaced_existing,
11441            "the operator is told this replaced something rather than made it"
11442        );
11443        assert_eq!(
11444            host.controls.registrations().len(),
11445            1,
11446            "replacing must not leave two registrations behind"
11447        );
11448        assert_eq!(
11449            InstallRecord::read(&host.paths)
11450                .expect("readable")
11451                .expect("a record")
11452                .start_mode,
11453            StartMode::Boot
11454        );
11455    }
11456
11457    /// The *other* mode is still refused, because changing it moves the
11458    /// registration between two service managers and changes the account and
11459    /// the secret store with it. `set_start_mode` is what does that.
11460    #[test]
11461    fn installing_over_the_other_start_mode_is_refused() {
11462        let host = Host::new();
11463        let operations = host.operations();
11464        operations
11465            .install(&host.request(StartMode::Boot))
11466            .expect("the first install");
11467
11468        let error = operations
11469            .install(&host.request(StartMode::Login))
11470            .expect_err("a mode change is not an install");
11471        assert!(
11472            matches!(
11473                error,
11474                ServiceError::AlreadyInstalled {
11475                    existing: StartMode::Boot,
11476                    requested: StartMode::Login,
11477                    ..
11478                }
11479            ),
11480            "{error}"
11481        );
11482        assert!(
11483            !error
11484                .to_string()
11485                .contains("switch the start mode in place,"),
11486            "the old remedy named a capability no command offers; the terminal UI is where \
11487             the start mode moves: {error}"
11488        );
11489        assert_eq!(host.controls.registrations().len(), 1);
11490    }
11491
11492    #[test]
11493    fn install_rolls_back_the_registration_when_record_persistence_fails() {
11494        let host = Host::new();
11495        let record_path = InstallRecord::path(&host.paths);
11496        std::fs::create_dir(&record_path).expect("a directory blocks the record file");
11497
11498        let error = host
11499            .operations()
11500            .install(&host.request(StartMode::Boot))
11501            .expect_err("record persistence must fail");
11502
11503        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11504        assert!(
11505            host.controls.registrations().is_empty(),
11506            "a failed install must not leave a live unrecorded registration"
11507        );
11508        assert!(
11509            host.controls
11510                .calls()
11511                .iter()
11512                .any(|call| call == "uninstall runner-manager (boot)"),
11513            "the registration must be explicitly rolled back: {:?}",
11514            host.controls.calls()
11515        );
11516        assert!(
11517            !host.runner_root.as_path().exists(),
11518            "the rollback must take the runner root this install created with it; a directory \
11519             prepared for a registration that does not exist is litter, and on Windows it is \
11520             litter with a security descriptor"
11521        );
11522    }
11523
11524    // -----------------------------------------------------------------------
11525    // The runner root (b2)
11526    // -----------------------------------------------------------------------
11527
11528    #[test]
11529    fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
11530        use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
11531
11532        // The mapping `prepare_runner_root` reads, asserted against the same
11533        // function the registration's own principal comes from — which is the
11534        // point of deriving it there rather than restating it.
11535        assert_eq!(
11536            ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
11537            ServiceAccount::LocalSystem
11538        );
11539        assert_eq!(
11540            ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
11541            ServiceAccount::InvokingUser
11542        );
11543
11544        let boot = default_root_sddl(&RootAdmission::LocalSystem);
11545        assert!(!grants_broad_write(&boot), "{boot}");
11546        assert!(
11547            !boot.contains("S-1-5-21"),
11548            "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
11549        );
11550
11551        // A login task runs under a *filtered* token — `RunLevel` is
11552        // `LeastPrivilege`, see `windows_scheduled_task_xml` — in which
11553        // Administrators is deny-only. Without an ace of its own the account
11554        // the task runs as would be admitted by nothing at all.
11555        let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
11556        assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
11557        assert!(!grants_broad_write(&login), "{login}");
11558    }
11559
11560    #[test]
11561    fn an_install_reports_the_runner_root_it_prepared() {
11562        let host = Host::new();
11563        let installed = host
11564            .operations()
11565            .install(&host.request(StartMode::Boot))
11566            .expect("an install");
11567        let rendered = installed.runner_root.to_string();
11568        assert!(
11569            !rendered.contains("S-1-5-21"),
11570            "the report must add no identity to the output: {rendered}"
11571        );
11572        if cfg!(windows) {
11573            assert_eq!(
11574                installed.runner_root.path(),
11575                Some(host.runner_root.as_path())
11576            );
11577            assert!(
11578                host.runner_root.as_path().is_dir(),
11579                "the directory jobs would run in has to exist once the service is registered"
11580            );
11581        } else {
11582            assert_eq!(
11583                installed.runner_root,
11584                crate::runner_root_access::RootAccessSummary::NotApplicable,
11585                "macOS and Linux keep the runtime directory they have always used"
11586            );
11587        }
11588    }
11589
11590    #[test]
11591    fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
11592        let host = Host::new();
11593        let operations = host.operations();
11594        operations
11595            .install(&host.request(StartMode::Boot))
11596            .expect("an install at boot");
11597
11598        let change = operations
11599            .set_start_mode(StartMode::Login)
11600            .expect("a switch to login");
11601
11602        assert!(change.changed);
11603        if cfg!(windows) {
11604            assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
11605            assert!(
11606                host.runner_root.as_path().is_dir(),
11607                "the switch must not remove the directory it reconciled"
11608            );
11609        }
11610        // The operator is told, because the account that may write there has
11611        // moved with the mode.
11612        assert!(
11613            change.to_string().contains("runner root"),
11614            "{}",
11615            change.to_string()
11616        );
11617    }
11618
11619    #[test]
11620    fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
11621        let host = Host::new();
11622        let operations = host.operations();
11623        operations
11624            .install(&host.request(StartMode::Boot))
11625            .expect("an install at boot");
11626
11627        let change = operations
11628            .set_start_mode(StartMode::Boot)
11629            .expect("a switch to the mode already in force");
11630
11631        assert!(!change.changed);
11632        assert_eq!(
11633            change.runner_root,
11634            crate::runner_root_access::RootAccessSummary::NotApplicable,
11635            "nothing moves, so nothing about the root's access control has to; reconciling here \
11636             would turn a no-op command into one that can fail on a permission it does not need"
11637        );
11638    }
11639
11640    #[cfg(windows)]
11641    #[test]
11642    fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
11643        let host = Host::new();
11644        host.controls
11645            .fail_next_install(StartMode::Boot, "injected registration failure");
11646
11647        let error = host
11648            .operations()
11649            .install(&host.request(StartMode::Boot))
11650            .expect_err("the manager refuses the registration");
11651
11652        assert!(matches!(error, ServiceError::Control { .. }), "{error}");
11653        assert!(
11654            !host.runner_root.as_path().exists(),
11655            "the directory was created for a registration that does not exist"
11656        );
11657    }
11658
11659    #[cfg(windows)]
11660    #[test]
11661    fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
11662        let host = Host::new();
11663        // What a directory created below `C:\` with inheritance left on looks
11664        // like, built deliberately because a per-account `%TEMP%` never
11665        // produces one by accident.
11666        crate::runner_root_access::create_with_descriptor_for_tests(
11667            host.runner_root.as_path(),
11668            "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
11669        )
11670        .expect("a deliberately open runner root");
11671        let before = crate::runner_root_access::report(host.runner_root.as_path());
11672
11673        let error = host
11674            .operations()
11675            .install(&host.request(StartMode::Boot))
11676            .expect_err("an open runner root is refused");
11677
11678        assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
11679        assert!(
11680            error.to_string().contains("nothing was registered"),
11681            "{error}"
11682        );
11683        assert!(
11684            host.controls.registrations().is_empty(),
11685            "the refusal has to come before the platform is asked to register anything: {:?}",
11686            host.controls.calls()
11687        );
11688        assert_eq!(
11689            crate::runner_root_access::report(host.runner_root.as_path()),
11690            before,
11691            "an open directory is refused rather than tightened: its contents cannot be trusted, \
11692             so adopting it would be worse than declining it"
11693        );
11694    }
11695
11696    #[cfg(windows)]
11697    #[test]
11698    fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
11699        let host = Host::new();
11700        let operations = host.operations();
11701        operations
11702            .install(&host.request(StartMode::Boot))
11703            .expect("an install");
11704        assert!(host.runner_root.as_path().is_dir());
11705
11706        operations.uninstall().expect("an uninstall");
11707
11708        assert!(
11709            host.runner_root.as_path().is_dir(),
11710            "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
11711             runner root may hold an operator's retained workspaces."
11712        );
11713    }
11714
11715    #[test]
11716    fn install_reviews_what_it_registered() {
11717        let host = Host::new();
11718        let installed = host
11719            .operations()
11720            .install(&host.request(StartMode::Boot))
11721            .expect("an install");
11722        assert!(
11723            installed.review.is_least_privilege(),
11724            "{}",
11725            installed.review
11726        );
11727        assert!(
11728            !installed.review.controls().is_empty(),
11729            "a review that confirms nothing proves nothing: {}",
11730            installed.review
11731        );
11732        assert_eq!(
11733            installed.review.kind(),
11734            host_definition_kind(StartMode::Boot),
11735            "the review must be of the definition this host's manager was given"
11736        );
11737        assert!(
11738            !installed.review.account().justification().is_empty(),
11739            "a privileged account with no stated reason is an unreviewed one"
11740        );
11741    }
11742
11743    // -----------------------------------------------------------------------
11744    // Uninstall deletes nothing else
11745    // -----------------------------------------------------------------------
11746
11747    #[test]
11748    fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
11749        let host = Host::new();
11750        let operations = host.operations();
11751        operations
11752            .install(&host.request(StartMode::Boot))
11753            .expect("an install");
11754
11755        // The files `05-infrastructure.md` says must survive. Written *after*
11756        // the install so that the snapshot below is of a host in the state an
11757        // operator's would be in.
11758        let config = host.paths.config_dir();
11759        std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
11760        std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
11761        std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
11762        std::fs::write(
11763            host.paths
11764                .state_dir()
11765                .join("packages/2.330.0/runner.tar.gz"),
11766            b"cached package",
11767        )
11768        .expect("writable");
11769        std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
11770        std::fs::write(
11771            host.paths.state_dir().join("secrets/user-access-token"),
11772            b"a stand-in for the stored credential",
11773        )
11774        .expect("writable");
11775        std::fs::write(
11776            host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
11777            b"diagnostics",
11778        )
11779        .expect("writable");
11780
11781        let roots: Vec<PathBuf> = host
11782            .paths
11783            .all()
11784            .iter()
11785            .map(|(_, path)| (*path).to_path_buf())
11786            .collect();
11787        let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
11788        let before = snapshot(&roots);
11789
11790        // Non-vacuity: a comparison of two empty maps would pass whatever
11791        // `uninstall` did.
11792        assert!(
11793            before.len() >= 6,
11794            "the fixture must actually contain the files this test is about, got {before:#?}"
11795        );
11796        let record_path = InstallRecord::path(&host.paths);
11797        assert!(
11798            before.contains_key(&record_path),
11799            "the install record must be present before uninstall"
11800        );
11801
11802        let uninstalled = operations.uninstall().expect("an uninstall");
11803        assert!(uninstalled.removed_registration);
11804        assert!(uninstalled.removed_record);
11805
11806        let after = snapshot(&roots);
11807
11808        // Exactly one thing changed, and it is the registration's own record.
11809        let mut expected = before.clone();
11810        expected.remove(&record_path);
11811        assert_eq!(
11812            after, expected,
11813            "uninstall must remove its own record and nothing else"
11814        );
11815        assert!(
11816            !record_path.exists(),
11817            "the record itself must go, or `uninstall` did nothing at all"
11818        );
11819        assert!(
11820            uninstalled
11821                .preserved
11822                .iter()
11823                .all(|path| roots.contains(&path.as_path())),
11824            "the preserved list must name the four directories: {uninstalled}"
11825        );
11826    }
11827
11828    #[test]
11829    fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
11830        let host = Host::new();
11831        let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
11832        assert!(!uninstalled.removed_registration);
11833        assert!(!uninstalled.removed_record);
11834    }
11835
11836    #[test]
11837    fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
11838        let host = Host::new();
11839        let operations = host.operations();
11840        operations
11841            .install(&host.request(StartMode::Boot))
11842            .expect("an install");
11843        std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
11844
11845        let uninstalled = operations.uninstall().expect("an uninstall");
11846        assert!(
11847            uninstalled.removed_registration,
11848            "a lost record must not strand a registration"
11849        );
11850        assert!(host.controls.registrations().is_empty());
11851    }
11852
11853    // -----------------------------------------------------------------------
11854    // Switching start mode
11855    // -----------------------------------------------------------------------
11856
11857    #[test]
11858    fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
11859        let host = Host::new();
11860        let operations = host.operations();
11861        operations
11862            .install(&host.request(StartMode::Boot))
11863            .expect("an install at boot");
11864
11865        // The discriminator. If the switch re-resolved the binary the way
11866        // `install` does, it would either fail here or silently record the test
11867        // binary instead. Removing the file makes the difference visible.
11868        std::fs::remove_file(&host.binary).expect("the installed binary goes away");
11869
11870        let change = operations
11871            .set_start_mode(StartMode::Login)
11872            .expect("a switch that does not reinstall the product");
11873        assert!(change.changed);
11874        assert_eq!(change.from, StartMode::Boot);
11875        assert_eq!(change.to, StartMode::Login);
11876        assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
11877
11878        let record = InstallRecord::read(&host.paths)
11879            .expect("readable")
11880            .expect("a record");
11881        assert_eq!(record.start_mode, StartMode::Login);
11882        assert_eq!(
11883            record.binary, host.binary,
11884            "the recorded path must survive the switch untouched"
11885        );
11886
11887        let registrations = host.controls.registrations();
11888        assert_eq!(registrations.len(), 1, "{registrations:?}");
11889        assert_eq!(registrations[0].0, StartMode::Login);
11890        assert!(
11891            registrations[0]
11892                .2
11893                .command_line
11894                .contains(&host.binary.to_string_lossy().into_owned()),
11895            "{:?}",
11896            registrations[0].2
11897        );
11898    }
11899
11900    #[test]
11901    fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
11902        let host = Host::new();
11903        let operations = host.operations();
11904        operations
11905            .install(&host.request(StartMode::Boot))
11906            .expect("an install at boot");
11907        let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
11908        host.controls
11909            .fail_next_install(StartMode::Login, "injected target failure");
11910
11911        let error = operations
11912            .set_start_mode(StartMode::Login)
11913            .expect_err("the target manager refuses the install");
11914
11915        assert!(matches!(error, ServiceError::Control { .. }), "{error}");
11916        assert_eq!(
11917            std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
11918            record_before
11919        );
11920        let registrations = host.controls.registrations();
11921        assert_eq!(registrations.len(), 1, "{registrations:?}");
11922        assert_eq!(registrations[0].0, StartMode::Boot);
11923    }
11924
11925    #[test]
11926    fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
11927        let host = Host::new();
11928        let operations = host.operations();
11929        operations
11930            .install(&host.request(StartMode::Boot))
11931            .expect("an install at boot");
11932        let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
11933        let config = host.paths.config_dir().to_path_buf();
11934        let hidden = config.with_file_name("config-hidden-by-fault");
11935        host.controls.hide_directory_after_install(
11936            StartMode::Login,
11937            config.clone(),
11938            hidden.clone(),
11939        );
11940
11941        let error = operations
11942            .set_start_mode(StartMode::Login)
11943            .expect_err("the injected filesystem fault prevents persistence");
11944
11945        std::fs::remove_file(&config).expect("remove the injected blocker");
11946        std::fs::rename(&hidden, &config).expect("restore the record directory");
11947        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11948        assert_eq!(
11949            std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
11950            record_before
11951        );
11952        let registrations = host.controls.registrations();
11953        assert_eq!(registrations.len(), 1, "{registrations:?}");
11954        assert_eq!(registrations[0].0, StartMode::Boot);
11955        assert!(
11956            host.controls
11957                .calls()
11958                .iter()
11959                .any(|call| call == "uninstall runner-manager (login)"),
11960            "the target must be rolled back: {:?}",
11961            host.controls.calls()
11962        );
11963    }
11964
11965    #[test]
11966    fn switching_to_the_mode_already_in_force_registers_nothing_again() {
11967        let host = Host::new();
11968        let operations = host.operations();
11969        operations
11970            .install(&host.request(StartMode::Boot))
11971            .expect("an install");
11972        let before = host.controls.calls().len();
11973
11974        let change = operations
11975            .set_start_mode(StartMode::Boot)
11976            .expect("a no-op switch");
11977        assert!(!change.changed);
11978        assert_eq!(
11979            host.controls.calls().len(),
11980            before,
11981            "a no-op switch must not touch the service manager"
11982        );
11983    }
11984
11985    #[test]
11986    fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
11987        let host = Host::new();
11988        let error = host
11989            .operations()
11990            .set_start_mode(StartMode::Login)
11991            .expect_err("there is nothing to switch");
11992        assert!(
11993            matches!(error, ServiceError::NotInstalled { .. }),
11994            "{error}"
11995        );
11996    }
11997
11998    // -----------------------------------------------------------------------
11999    // Status
12000    // -----------------------------------------------------------------------
12001
12002    #[test]
12003    fn status_reports_the_four_facts_journey_five_asks_for() {
12004        let host = Host::new();
12005        let operations = host.operations();
12006        operations
12007            .install(&host.request(StartMode::Boot))
12008            .expect("an install");
12009        let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
12010            .expect("a valid timestamp")
12011            .with_timezone(&Utc);
12012        record_github_contact(&host.paths, at).expect("a heartbeat");
12013
12014        let status = operations.status().expect("a status");
12015        assert_eq!(status.start_mode(), Some(StartMode::Boot));
12016        assert_eq!(
12017            status.binary().map(BinaryPath::recorded),
12018            Some(host.binary.as_path())
12019        );
12020        assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
12021        assert_eq!(status.last_github_contact(), Some(at));
12022        assert!(status.is_installed());
12023        assert!(status.is_healthy(), "{status}");
12024
12025        let printed = status.to_string();
12026        for fragment in [
12027            "start mode",
12028            "diagnostic log",
12029            "last GitHub contact",
12030            "binary",
12031        ] {
12032            assert!(printed.contains(fragment), "{printed}");
12033        }
12034    }
12035
12036    /// A record this account may not read is reported, and `status` keeps
12037    /// going.
12038    ///
12039    /// It used to end the command with `Permission denied`, which is exactly
12040    /// what an operator met on a boot-mode host: `sudo service install` wrote
12041    /// the record `0600` and `root`-owned, and `service status` — a command
12042    /// that asks no privilege of anybody — then printed nothing at all about a
12043    /// registration the service manager could describe perfectly well.
12044    ///
12045    /// Ownership cannot be reproduced without privileges; the mode that made it
12046    /// fatal can.
12047    #[cfg(unix)]
12048    #[test]
12049    fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
12050        use std::os::unix::fs::PermissionsExt as _;
12051
12052        // `root` is not subject to the mode bits and would read the record
12053        // regardless, leaving the assertions below testing nothing.
12054        // SAFETY: `geteuid` takes no argument and cannot fail.
12055        if unsafe { libc::geteuid() } == 0 {
12056            return;
12057        }
12058
12059        let host = Host::new();
12060        let operations = host.operations();
12061        operations
12062            .install(&host.request(StartMode::Boot))
12063            .expect("an install");
12064        assert!(
12065            operations.status().expect("a status").is_healthy(),
12066            "the discriminator: healthy before the record is made unreadable"
12067        );
12068
12069        let record = InstallRecord::path(&host.paths);
12070        std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
12071            .expect("the mode is applied");
12072
12073        let status = operations
12074            .status()
12075            .expect("a record this account may not read is reported, not thrown");
12076        assert!(status.is_installed(), "{status}");
12077        assert!(!status.is_healthy(), "{status}");
12078
12079        let printed = status.to_string();
12080        assert!(
12081            printed.contains("this account may not read it"),
12082            "the operator is told which of the two states this is: {printed}"
12083        );
12084        assert!(
12085            !printed.contains("there is no install record"),
12086            "a record that is there and unreadable is not a record that is missing, and the \
12087             missing one's remedy starts with `service uninstall`: {printed}"
12088        );
12089
12090        // Left readable so the fixture's own cleanup is not the thing that
12091        // fails on a host where TempDir removal walks the tree.
12092        std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
12093            .expect("the mode is restored");
12094    }
12095
12096    #[test]
12097    fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
12098        let host = Host::new();
12099        let operations = host.operations();
12100        operations
12101            .install(&host.request(StartMode::Boot))
12102            .expect("an install");
12103
12104        // The discriminator: healthy first, so a `is_healthy` that always
12105        // returned false could not pass this test.
12106        assert!(
12107            operations.status().expect("a status").is_healthy(),
12108            "the freshly installed host must be healthy"
12109        );
12110
12111        std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
12112
12113        let status = operations.status().expect("a status");
12114        assert!(!status.is_healthy(), "{status}");
12115        assert!(
12116            status
12117                .problems()
12118                .iter()
12119                .any(|problem| problem.subject == "binary"),
12120            "{status}"
12121        );
12122        assert!(status.to_string().contains("STALE"), "{status}");
12123    }
12124
12125    #[test]
12126    fn status_reports_a_registration_that_would_not_start_at_boot() {
12127        let host = Host::new();
12128        let operations = host.operations();
12129        operations
12130            .install(&host.request(StartMode::Boot))
12131            .expect("an install");
12132        assert!(operations.status().expect("a status").is_healthy());
12133
12134        host.controls.edit("runner-manager", |registration| {
12135            registration.starts_automatically = false;
12136        });
12137
12138        let status = operations.status().expect("a status");
12139        assert!(!status.is_healthy(), "{status}");
12140        assert!(
12141            status
12142                .problems()
12143                .iter()
12144                .any(|problem| problem.detail.contains("after a reboot")),
12145            "{status}"
12146        );
12147    }
12148
12149    #[test]
12150    fn install_starts_the_registration_and_a_later_stop_is_unhealthy() {
12151        let host = Host::new();
12152        let operations = host.operations();
12153        operations
12154            .install(&host.request(StartMode::Login))
12155            .expect("install and immediate start");
12156
12157        assert!(
12158            operations.status().expect("running status").is_running(),
12159            "install must not wait for the next login trigger"
12160        );
12161        operations.stop().expect("stop the registration");
12162        let status = operations.status().expect("stopped status");
12163        assert!(!status.is_healthy(), "{status}");
12164        assert!(
12165            status
12166                .problems()
12167                .iter()
12168                .any(|problem| problem.subject == "runtime"),
12169            "{status}"
12170        );
12171        let rendered = status.to_string();
12172        assert!(
12173            rendered.contains("runner-manager service install --start-at login"),
12174            "{rendered}"
12175        );
12176        assert!(
12177            !rendered.contains("runner-manager service start"),
12178            "{rendered}"
12179        );
12180    }
12181
12182    #[test]
12183    fn status_reports_a_restart_policy_something_else_edited() {
12184        let host = Host::new();
12185        let operations = host.operations();
12186        operations
12187            .install(&host.request(StartMode::Boot))
12188            .expect("an install");
12189        assert!(operations.status().expect("a status").is_healthy());
12190
12191        host.controls.edit("runner-manager", |registration| {
12192            registration.restart_delay = Some(Duration::from_secs(1));
12193        });
12194
12195        let status = operations.status().expect("a status");
12196        assert!(!status.is_healthy(), "{status}");
12197        assert!(
12198            status
12199                .problems()
12200                .iter()
12201                .any(|problem| problem.subject == "restart policy"),
12202            "{status}"
12203        );
12204    }
12205
12206    #[test]
12207    fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
12208        let host = Host::new();
12209        let operations = host.operations();
12210        operations
12211            .install(&host.request(StartMode::Boot))
12212            .expect("an install");
12213        let other = host.binary.with_file_name("someone-elses.exe");
12214        std::fs::write(&other, b"x").expect("writable");
12215
12216        host.controls.edit("runner-manager", |registration| {
12217            registration.command_line = quote_argument(&other.to_string_lossy());
12218        });
12219
12220        let status = operations.status().expect("a status");
12221        assert!(!status.is_healthy(), "{status}");
12222        assert!(
12223            matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
12224            "{status}"
12225        );
12226    }
12227
12228    #[test]
12229    fn status_reports_a_record_no_service_manager_knows_about() {
12230        let host = Host::new();
12231        let operations = host.operations();
12232        operations
12233            .install(&host.request(StartMode::Boot))
12234            .expect("an install");
12235        // Something removed the registration behind this product's back.
12236        for mode in [StartMode::Boot, StartMode::Login] {
12237            host.controls
12238                .control(mode)
12239                .expect("a control")
12240                .uninstall(&ServiceIdentity::product())
12241                .expect("removed");
12242        }
12243
12244        let status = operations.status().expect("a status");
12245        assert!(!status.is_healthy(), "{status}");
12246        assert!(
12247            status
12248                .problems()
12249                .iter()
12250                .any(|problem| problem.subject == "registration"),
12251            "{status}"
12252        );
12253    }
12254
12255    #[test]
12256    fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
12257        let host = Host::new();
12258        let status = host.operations().status().expect("a status");
12259        assert!(!status.is_installed());
12260        assert!(
12261            status.is_healthy(),
12262            "a host that never installed the service has no fault to report: {status}"
12263        );
12264        assert!(status.to_string().contains("installed"), "{status}");
12265    }
12266
12267    #[test]
12268    fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
12269        let host = Host::new();
12270        let operations = host.operations();
12271        operations
12272            .install(&host.request(StartMode::Login))
12273            .expect("an install at login");
12274        let status = operations.status().expect("a status");
12275        assert!(
12276            status
12277                .notes()
12278                .iter()
12279                .any(|note| note.contains("does not run until the operator signs in")),
12280            "05-infrastructure.md requires `service status` to say so: {status}"
12281        );
12282    }
12283
12284    /// A Mac that signs its runner account in automatically does resume, and
12285    /// `service status` used to tell it otherwise.
12286    #[test]
12287    fn a_mac_with_automatic_login_as_the_service_account_resumes_after_a_reboot() {
12288        use crate::unattended_login::{AutoLogin, UnattendedLogin};
12289        let automatic = UnattendedLogin {
12290            auto_login: AutoLogin::As("ivan".into()),
12291            filevault_on: Some(false),
12292        };
12293        assert_eq!(
12294            unattended_gap(StartMode::Login, "ivan", Some(&automatic)),
12295            None
12296        );
12297
12298        let off = UnattendedLogin {
12299            auto_login: AutoLogin::Off,
12300            filevault_on: Some(false),
12301        };
12302        let gap = unattended_gap(StartMode::Login, "ivan", Some(&off)).expect("a gap");
12303        assert!(gap.reason.contains("automatic login is off"), "{gap:?}");
12304        assert!(gap.reason.contains("Automatically log in as"), "{gap:?}");
12305
12306        let locked = UnattendedLogin {
12307            auto_login: AutoLogin::As("ivan".into()),
12308            filevault_on: Some(true),
12309        };
12310        let gap = unattended_gap(StartMode::Boot, "root", Some(&locked)).expect("a gap");
12311        assert!(gap.reason.contains("FileVault is on"), "{gap:?}");
12312
12313        // An unanswered probe is no gap: `host doctor` says what it could not
12314        // read.
12315        let unread = UnattendedLogin {
12316            auto_login: AutoLogin::As("ivan".into()),
12317            filevault_on: None,
12318        };
12319        assert_eq!(
12320            unattended_gap(StartMode::Login, "ivan", Some(&unread)),
12321            None
12322        );
12323
12324        // Where nothing can be probed, a login registration still says so.
12325        let gap = unattended_gap(StartMode::Login, "ivan", None).expect("a gap");
12326        assert!(
12327            gap.reason
12328                .contains("does not run until the operator signs in")
12329        );
12330        assert_eq!(unattended_gap(StartMode::Boot, "root", None), None);
12331    }
12332
12333    /// A daemon blocked in a file system call is still "running" to the
12334    /// service manager; its heartbeat is what says it is stuck.
12335    #[test]
12336    fn a_daemon_that_stopped_beating_makes_the_service_unhealthy() {
12337        let host = Host::new();
12338        let operations = host.operations();
12339        operations
12340            .install(&host.request(StartMode::Login))
12341            .expect("an install at login");
12342        let stalled_problem = |operations: &ServiceOperations| {
12343            operations
12344                .status()
12345                .expect("a status")
12346                .problems()
12347                .iter()
12348                .find(|problem| problem.subject == DAEMON_STALLED_SUBJECT)
12349                .map(|problem| problem.detail.clone())
12350        };
12351        crate::daemon_heartbeat::beat(&operations.paths, Utc::now()).unwrap();
12352        assert_eq!(stalled_problem(&operations), None, "beating");
12353
12354        crate::daemon_heartbeat::beat(
12355            &operations.paths,
12356            Utc::now() - chrono::Duration::minutes(20),
12357        )
12358        .unwrap();
12359        let detail = stalled_problem(&operations).expect("a stall is a problem");
12360        assert!(detail.contains("has made no progress since"), "{detail}");
12361        // The restart is the service manager's command for this login-mode
12362        // registration: the product has no `service stop` or `service start`.
12363        let restart = ServiceIdentity::product().restart_command(StartMode::Login);
12364        assert!(detail.contains(&restart), "{detail}");
12365        assert!(!detail.contains("runner-manager service stop"), "{detail}");
12366        assert!(!detail.contains("runner-manager service start"), "{detail}");
12367    }
12368
12369    /// Each platform's restart is its service manager's own command for the
12370    /// registration's start mode, never a `runner-manager service` subcommand
12371    /// that does not exist.
12372    #[test]
12373    fn the_restart_command_is_the_service_managers_own() {
12374        let identity = ServiceIdentity::product();
12375        let boot = identity.restart_command(StartMode::Boot);
12376        let login = identity.restart_command(StartMode::Login);
12377        for command in [&boot, &login] {
12378            assert!(!command.contains("runner-manager service"), "{command}");
12379            assert!(command.contains(identity.name()), "{command}");
12380        }
12381        assert_ne!(boot, login);
12382        if cfg!(windows) {
12383            assert!(
12384                boot.starts_with("Restart-Service -Name runner-manager"),
12385                "{boot}"
12386            );
12387            assert!(
12388                login.contains("schtasks /Run /TN runner-manager"),
12389                "{login}"
12390            );
12391        } else if cfg!(target_os = "macos") {
12392            assert!(
12393                boot.starts_with("sudo launchctl kickstart -k system/"),
12394                "{boot}"
12395            );
12396            assert!(login.starts_with("launchctl kickstart -k gui/"), "{login}");
12397        } else {
12398            assert_eq!(boot, "sudo systemctl restart runner-manager.service");
12399            assert_eq!(login, "systemctl --user restart runner-manager.service");
12400        }
12401    }
12402
12403    /// A daemon refusing every runner is not a healthy service.
12404    #[test]
12405    fn a_daemon_that_finds_the_host_unfit_makes_the_service_unhealthy() {
12406        let host = Host::new();
12407        let operations = host.operations();
12408        operations
12409            .install(&host.request(StartMode::Login))
12410            .expect("an install at login");
12411        let paths = operations.paths.clone();
12412        crate::host_fitness::record_host_unfit(
12413            &paths,
12414            &[crate::host_fitness::UnfitFinding {
12415                id: "host.runner_root_responsive".into(),
12416                detail: "macOS has not let the service list /Volumes/NVME/runners".into(),
12417                remedy: Some("click Allow".into()),
12418            }],
12419            Utc::now(),
12420        )
12421        .unwrap();
12422        let status = operations.status().expect("a status");
12423        assert!(!status.is_healthy(), "{status}");
12424        let problem = status
12425            .problems()
12426            .iter()
12427            .find(|problem| problem.subject == HOST_UNFIT_SUBJECT)
12428            .expect("the refusal is a problem");
12429        assert!(
12430            problem.detail.contains("/Volumes/NVME/runners"),
12431            "{problem:?}"
12432        );
12433        assert!(problem.detail.contains("Fix: click Allow"), "{problem:?}");
12434
12435        // A record nobody has re-stamped for a while is a stopped daemon's,
12436        // not a refusal in force.
12437        crate::host_fitness::clear_host_unfit(&paths).unwrap();
12438        crate::host_fitness::record_host_unfit(
12439            &paths,
12440            &[],
12441            Utc::now() - chrono::Duration::hours(2),
12442        )
12443        .unwrap();
12444        let status = operations.status().expect("a status");
12445        assert!(
12446            !status
12447                .problems()
12448                .iter()
12449                .any(|problem| problem.subject == HOST_UNFIT_SUBJECT),
12450            "{status}"
12451        );
12452    }
12453
12454    // -----------------------------------------------------------------------
12455    // Start and stop
12456    // -----------------------------------------------------------------------
12457
12458    #[test]
12459    fn start_and_stop_reach_the_domain_that_holds_the_registration() {
12460        let host = Host::new();
12461        let operations = host.operations();
12462        operations
12463            .install(&host.request(StartMode::Login))
12464            .expect("an install at login");
12465        operations.start().expect("a start");
12466        assert!(operations.status().expect("a status").is_running());
12467        assert!(operations.stop().expect("a stop"));
12468        assert!(!operations.status().expect("a status").is_running());
12469    }
12470
12471    #[test]
12472    fn starting_a_host_with_no_registration_is_refused() {
12473        let host = Host::new();
12474        let error = host.operations().start().expect_err("nothing to start");
12475        assert!(
12476            matches!(error, ServiceError::NotInstalled { .. }),
12477            "{error}"
12478        );
12479    }
12480
12481    // -----------------------------------------------------------------------
12482    // The seam with `d2`
12483    // -----------------------------------------------------------------------
12484
12485    /// Requirement 2 is *"an account that can **read the secret store**"*, and
12486    /// on Windows what an account may read is decided by a DACL in a file this
12487    /// task does not own. Asserting the account name alone would be asserting
12488    /// this module against itself; this reads `d2`'s protection back off a real
12489    /// store and checks that it admits the account this installer registers.
12490    ///
12491    /// No privileges are needed. `d2` documents that a rooted machine-scoped
12492    /// store *"is protected and encrypted exactly as the standard one is"* —
12493    /// the Windows backend picks its DACL from the scope, not from the site —
12494    /// so a store in a temporary directory carries the access control the real
12495    /// one does.
12496    #[cfg(windows)]
12497    #[test]
12498    fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
12499        use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
12500
12501        let root = tempfile::tempdir().expect("a temporary directory");
12502        let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
12503            .expect("a rooted machine-scoped store");
12504        store
12505            .store(&secrecy::SecretString::from("a stand-in for the token"))
12506            .expect("the store accepts a value");
12507        let protection = store.protection().expect("the DACL can be read back");
12508
12509        assert!(
12510            protection.description().contains(";;;SY)"),
12511            "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
12512             read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
12513            protection.description()
12514        );
12515        assert_eq!(
12516            ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
12517            ServiceAccount::LocalSystem,
12518            "and that is the account this installer registers, which is why SY is what matters"
12519        );
12520        assert!(
12521            !protection.readable_by_other_local_users(),
12522            "the same DACL must still exclude ordinary local users: {}",
12523            protection.description()
12524        );
12525
12526        // The other half of the analysis in `docs/service-account.md`, and the
12527        // reason LocalService and NetworkService are not used: the DACL names
12528        // three trustees and neither of them is among them.
12529        for rejected in [";;;LS)", ";;;NS)"] {
12530            assert!(
12531                !protection.description().contains(rejected),
12532                "if the store ever admitted {rejected}, the least-privilege analysis in \
12533                 docs/service-account.md would need redoing: {}",
12534                protection.description()
12535            );
12536        }
12537    }
12538
12539    // -- another installation's service ---------------------------------------
12540
12541    /// A request that records `host`'s own four directories on the command
12542    /// line, the way `service install` registers one.
12543    fn request_with_directories(host: &Host, mode: StartMode) -> InstallRequest {
12544        let mut arguments: Vec<OsString> = DAEMON_ARGUMENTS.iter().map(OsString::from).collect();
12545        arguments.extend(ServiceDirectories::of(&host.paths).arguments());
12546        host.request(mode).with_arguments(arguments)
12547    }
12548
12549    /// The operations a second account on the same machine gets: its own
12550    /// directories, and the machine's one service manager.
12551    fn second_account(account: &Host, machine: &Host) -> ServiceOperations {
12552        ServiceOperations::with_controls(
12553            account.paths.clone(),
12554            ServiceIdentity::product(),
12555            std::sync::Arc::new(machine.controls.clone()),
12556        )
12557        .with_runner_root(account.runner_root.clone())
12558    }
12559
12560    /// The owner's boot service, and a second account holding the record of
12561    /// an earlier login install of its own -- the layout an owner reported on
12562    /// a Windows host with two local accounts.
12563    fn a_machine_with_a_stale_second_account() -> (Host, Host) {
12564        let owner = Host::new();
12565        owner
12566            .operations()
12567            .install(&request_with_directories(&owner, StartMode::Boot))
12568            .expect("the owner's boot install");
12569        let earlier = Host::new();
12570        earlier
12571            .operations()
12572            .install(&request_with_directories(&earlier, StartMode::Login))
12573            .expect("the second account's earlier login install");
12574        assert!(
12575            InstallRecord::read(&earlier.paths).unwrap().is_some(),
12576            "the earlier install left its record behind"
12577        );
12578        (owner, earlier)
12579    }
12580
12581    /// The advice that, followed from the second account, breaks or replaces
12582    /// the owner's healthy service.
12583    const DESTRUCTIVE_ADVICE: [&str; 4] = [
12584        "service uninstall",
12585        "service install`",
12586        "service install --",
12587        "auth login",
12588    ];
12589
12590    #[test]
12591    fn a_service_installed_from_another_data_root_is_reported_as_that_installations() {
12592        let (owner, earlier) = a_machine_with_a_stale_second_account();
12593
12594        let status = second_account(&earlier, &owner).status().expect("a status");
12595
12596        let foreign = status.foreign().expect("the service is the owner's");
12597        assert_eq!(foreign.directories, ServiceDirectories::of(&owner.paths));
12598        assert_eq!(foreign.root, ServiceDirectories::of(&owner.paths).root());
12599        assert!(foreign.unreadable.is_none(), "{status}");
12600        assert!(
12601            status.is_healthy(),
12602            "the owner's service is healthy, and a stale record elsewhere is not its fault: \
12603             {status}"
12604        );
12605        for subject in [
12606            "binary",
12607            "start mode",
12608            "secret store",
12609            "record",
12610            "registration",
12611        ] {
12612            assert!(
12613                !status
12614                    .problems()
12615                    .iter()
12616                    .any(|problem| problem.subject == subject),
12617                "{subject} compared the second account's record against the owner's \
12618                 registration: {status}"
12619            );
12620        }
12621        let rendered = status.to_string();
12622        for advice in DESTRUCTIVE_ADVICE {
12623            assert!(!rendered.contains(advice), "{advice}: {rendered}");
12624        }
12625        assert!(rendered.contains("another installation's"), "{rendered}");
12626        assert!(
12627            status
12628                .notes()
12629                .iter()
12630                .any(|note| note.contains("unused local configuration")
12631                    && note.contains("host forget-local")),
12632            "{status}"
12633        );
12634        // What the owner recorded, not what the second account did.
12635        assert_eq!(status.start_mode(), Some(StartMode::Boot));
12636    }
12637
12638    #[test]
12639    fn a_foreign_service_whose_records_cannot_be_read_reports_only_what_the_manager_says() {
12640        let (owner, earlier) = a_machine_with_a_stale_second_account();
12641        // Stands in for a profile this account may not open: there is nothing
12642        // to read where the registration points.
12643        InstallRecord::remove(&owner.paths).expect("the owner's record removed");
12644
12645        let operations = second_account(&earlier, &owner);
12646        let status = operations.status().expect("a status");
12647        let foreign = status.foreign().expect("the service is the owner's");
12648        assert!(foreign.unreadable.is_some(), "{status}");
12649        assert!(
12650            status.is_healthy(),
12651            "running, and nothing it could judge: {status}"
12652        );
12653        let rendered = status.to_string();
12654        for advice in DESTRUCTIVE_ADVICE {
12655            assert!(!rendered.contains(advice), "{advice}: {rendered}");
12656        }
12657        assert!(rendered.contains("running; installed from"), "{rendered}");
12658        assert!(!rendered.contains("last GitHub contact"), "{rendered}");
12659
12660        // Stopped, it is a problem -- with a place to look, not a reinstall.
12661        owner.operations().stop().expect("stopped");
12662        let stopped = operations.status().expect("a status");
12663        assert!(!stopped.is_healthy(), "{stopped}");
12664        let rendered = stopped.to_string();
12665        for advice in DESTRUCTIVE_ADVICE {
12666            assert!(!rendered.contains(advice), "{advice}: {rendered}");
12667        }
12668    }
12669
12670    #[test]
12671    fn this_accounts_own_registration_still_reports_every_mismatch() {
12672        let host = Host::new();
12673        let operations = host.operations();
12674        operations
12675            .install(&request_with_directories(&host, StartMode::Boot))
12676            .expect("an install");
12677        let other = host.binary.with_file_name("someone-elses");
12678        std::fs::write(&other, b"x").expect("writable");
12679        // Something edited the registration, keeping its directories.
12680        host.controls.edit("runner-manager", |registration| {
12681            let mut arguments = split_command_line(&registration.command_line);
12682            arguments[0] = other.to_string_lossy().into_owned();
12683            registration.command_line = arguments
12684                .iter()
12685                .map(|argument| quote_argument(argument))
12686                .collect::<Vec<_>>()
12687                .join(" ");
12688            registration.start_mode = StartMode::Login;
12689        });
12690
12691        let status = operations.status().expect("a status");
12692        assert!(status.foreign().is_none(), "{status}");
12693        assert!(
12694            matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
12695            "{status}"
12696        );
12697        assert!(
12698            status
12699                .problems()
12700                .iter()
12701                .any(|problem| problem.subject == "start mode"),
12702            "{status}"
12703        );
12704        assert!(status.to_string().contains("service uninstall"), "{status}");
12705    }
12706
12707    #[test]
12708    fn directories_read_back_from_a_command_line_with_spaces() {
12709        let root = Path::new("/Users/someone/Library/Application Support/runner manager");
12710        let directories = ServiceDirectories {
12711            config: root.join("config"),
12712            state: root.join("data").join("state"),
12713            runtime: root.join("data").join("runtime"),
12714            logs: root.join("data").join("logs"),
12715        };
12716        let mut command_line = quote_argument("/usr/local/bin/runner-manager");
12717        for argument in ["daemon", "run"]
12718            .map(OsString::from)
12719            .into_iter()
12720            .chain(directories.arguments())
12721        {
12722            command_line.push(' ');
12723            command_line.push_str(&quote_argument(&argument.to_string_lossy()));
12724        }
12725        assert_eq!(
12726            ServiceDirectories::from_command_line(&command_line),
12727            Some(directories.clone())
12728        );
12729        assert_eq!(directories.root(), root);
12730        assert_eq!(
12731            ServiceDirectories::from_command_line("runner-manager daemon run"),
12732            None,
12733            "a registration that names no directories cannot be judged foreign"
12734        );
12735    }
12736
12737    #[test]
12738    fn the_owner_of_a_data_root_is_read_from_its_path() {
12739        let users = Path::new(if cfg!(windows) { r"C:\Users" } else { "/home" });
12740        let home = users.join("ivand");
12741        assert_eq!(
12742            data_root_owner(&users.join("yuriv").join("data"), Some(&home)).as_deref(),
12743            Some("yuriv")
12744        );
12745        assert_eq!(
12746            data_root_owner(
12747                Path::new(if cfg!(windows) { r"D:\rm" } else { "/opt/rm" }),
12748                Some(&home)
12749            ),
12750            None
12751        );
12752        if cfg!(windows) {
12753            assert_eq!(
12754                data_root_owner(
12755                    Path::new(r"C:\Windows\System32\config\systemprofile\AppData"),
12756                    Some(&home)
12757                )
12758                .as_deref(),
12759                Some("LocalSystem")
12760            );
12761        } else {
12762            assert_eq!(
12763                data_root_owner(Path::new("/root/.config/runner-manager"), Some(&home)).as_deref(),
12764                Some("root")
12765            );
12766        }
12767    }
12768
12769    #[test]
12770    fn forget_local_archives_the_unused_record_and_never_touches_the_service_root() {
12771        let (owner, earlier) = a_machine_with_a_stale_second_account();
12772        let owner_before = snapshot(&[owner._root.path()]);
12773        let config = earlier.paths.config_dir().to_path_buf();
12774        let record = std::fs::read(InstallRecord::path(&earlier.paths)).unwrap();
12775
12776        let forgotten = second_account(&earlier, &owner)
12777            .forget_local(Utc::now())
12778            .expect("archived");
12779
12780        assert_eq!(
12781            snapshot(&[owner._root.path()]),
12782            owner_before,
12783            "the owner's directories must not change by one byte"
12784        );
12785        let archive = forgotten.archive.expect("something was archived");
12786        assert!(archive.starts_with(&config), "{}", archive.display());
12787        assert_eq!(
12788            std::fs::read(archive.join(RECORD_FILE)).unwrap(),
12789            record,
12790            "moved, not rewritten or deleted"
12791        );
12792        assert!(InstallRecord::read(&earlier.paths).unwrap().is_none());
12793        assert_eq!(
12794            forgotten.service_root,
12795            Some(ServiceDirectories::of(&owner.paths).root())
12796        );
12797        // Afterwards the second account's status has no unused record to note.
12798        let status = second_account(&earlier, &owner).status().expect("status");
12799        assert!(
12800            !status
12801                .notes()
12802                .iter()
12803                .any(|note| note.contains("unused local configuration")),
12804            "{status}"
12805        );
12806    }
12807
12808    #[test]
12809    fn forget_local_refuses_a_configuration_a_service_runs_against() {
12810        let (owner, earlier) = a_machine_with_a_stale_second_account();
12811        let before = snapshot(&[owner._root.path()]);
12812        let refused = owner.operations().forget_local(Utc::now());
12813        assert!(
12814            matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
12815            "{refused:?}"
12816        );
12817        assert_eq!(snapshot(&[owner._root.path()]), before);
12818
12819        // Nor while an agent holds the second account's own lock.
12820        let _agent = HostLock::try_acquire(&earlier.paths, LockKind::SingleInstance)
12821            .expect("the agent's lock");
12822        let refused = second_account(&earlier, &owner).forget_local(Utc::now());
12823        assert!(
12824            matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
12825            "{refused:?}"
12826        );
12827        assert!(InstallRecord::read(&earlier.paths).unwrap().is_some());
12828    }
12829
12830    #[test]
12831    fn a_readable_foreign_service_keeps_its_diagnoses_and_loses_its_remedies() {
12832        let (owner, earlier) = a_machine_with_a_stale_second_account();
12833        // The owner's service has every problem whose remedy acts on the
12834        // directories of whoever runs it.
12835        owner.operations().stop().expect("stopped");
12836        record_github_credential_rejected(&owner.paths, Utc::now()).expect("rejected");
12837        record_credential_unreadable(&owner.paths, Utc::now()).expect("unreadable");
12838
12839        let status = second_account(&earlier, &owner).status().expect("a status");
12840        assert!(status.foreign().is_some_and(|foreign| !foreign.is_unread()));
12841        let subjects: Vec<_> = status.problems().iter().map(|p| p.subject).collect();
12842        for subject in [
12843            "runtime",
12844            "github credential",
12845            CREDENTIAL_UNREADABLE_SUBJECT,
12846        ] {
12847            assert!(subjects.contains(&subject), "{subject}: {status}");
12848        }
12849        let rendered = status.to_string();
12850        for advice in DESTRUCTIVE_ADVICE {
12851            assert!(!rendered.contains(advice), "{advice}: {rendered}");
12852        }
12853        for problem in status.problems() {
12854            assert!(problem.detail.contains("Act on it"), "{problem}");
12855        }
12856    }
12857
12858    #[test]
12859    fn no_command_of_this_account_changes_another_installations_service() {
12860        let (owner, earlier) = a_machine_with_a_stale_second_account();
12861        let operations = second_account(&earlier, &owner);
12862        let foreign = |result: Result<(), ServiceError>| {
12863            assert!(
12864                matches!(result, Err(ServiceError::ForeignInstallation { .. })),
12865                "{result:?}"
12866            );
12867        };
12868        foreign(
12869            operations
12870                .install(&request_with_directories(&earlier, StartMode::Boot))
12871                .map(drop),
12872        );
12873        foreign(operations.uninstall().map(drop));
12874        foreign(operations.set_start_mode(StartMode::Boot).map(drop));
12875        foreign(operations.start());
12876        foreign(operations.stop().map(drop));
12877
12878        let registration = owner
12879            .controls
12880            .control(StartMode::Boot)
12881            .unwrap()
12882            .query(&ServiceIdentity::product())
12883            .unwrap()
12884            .expect("the owner's registration is still there");
12885        assert_eq!(
12886            ServiceDirectories::from_command_line(&registration.command_line),
12887            Some(ServiceDirectories::of(&owner.paths)),
12888            "and still runs against the owner's directories"
12889        );
12890        assert!(registration.running, "and was never stopped");
12891        assert!(
12892            InstallRecord::read(&earlier.paths).unwrap().is_some(),
12893            "the refused uninstall kept this account's record"
12894        );
12895    }
12896
12897    #[test]
12898    fn forget_local_leaves_agent_directories_nested_in_the_configuration_alone() {
12899        // macOS puts state, runtime and logs inside the configuration
12900        // directory: `config_local_dir` and `data_local_dir` are one folder.
12901        let root = tempfile::tempdir().unwrap();
12902        let config = root.path().join("Application Support");
12903        let paths = AppPaths::from_directories(
12904            &config,
12905            config.join("state"),
12906            config.join("runtime"),
12907            config.join("logs"),
12908        );
12909        paths.create_all().unwrap();
12910        std::fs::write(config.join(RECORD_FILE), b"an earlier install").unwrap();
12911        std::fs::write(paths.logs_dir().join("runner-manager.log"), b"log").unwrap();
12912        let operations = ServiceOperations::with_controls(
12913            paths.clone(),
12914            ServiceIdentity::product(),
12915            std::sync::Arc::new(RecordingControls::new()),
12916        );
12917
12918        let forgotten = operations.forget_local(Utc::now()).expect("archived");
12919
12920        assert_eq!(forgotten.moved, vec![config.join(RECORD_FILE)]);
12921        for kept in [paths.state_dir(), paths.runtime_dir(), paths.logs_dir()] {
12922            assert!(kept.is_dir(), "{} was moved", kept.display());
12923        }
12924        assert!(paths.logs_dir().join("runner-manager.log").is_file());
12925    }
12926
12927    #[test]
12928    fn a_remedy_is_cut_from_every_detail_a_builder_writes() {
12929        for (detail, diagnosis) in [
12930            (
12931                "x holds an automatic registration, but the daemon is stopped. Run `runner-manager \
12932                 service install --start-at boot`; if the service manager denies replacement",
12933                "x holds an automatic registration, but the daemon is stopped",
12934            ),
12935            (
12936                "store -- MISMATCH. Run `auth login` again so the token is stored",
12937                "store -- MISMATCH",
12938            ),
12939            (
12940                "unit differs, so the daemon runs under outdated settings. Re-register it with \
12941                 `sudo runner-manager service install --start-at boot`",
12942                "unit differs, so the daemon runs under outdated settings",
12943            ),
12944            (
12945                "it starts no runner; the log has it. Sign in again: `runner-manager auth login`",
12946                "it starts no runner; the log has it",
12947            ),
12948            (
12949                "C:\\a\\runner-manager.exe -- STALE: moved. Run `service uninstall` and `service \
12950                 install`",
12951                "C:\\a\\runner-manager.exe -- STALE: moved",
12952            ),
12953            (
12954                "a new builder: run `runner-manager service install` here, or not",
12955                "the installation's own records report this",
12956            ),
12957        ] {
12958            assert_eq!(diagnosis_only(detail), diagnosis, "{detail}");
12959        }
12960    }
12961}