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    /// A boot service an older build installed under another tree was not
575    /// moved to the system tree, and nothing was changed: its host was busy,
576    /// or an operator's drop-in names the old tree.
577    #[error(
578        "this host's data stays under {} for now: {reason}",
579        from.display()
580    )]
581    MoveDeferred {
582        /// The old tree's configuration directory.
583        from: PathBuf,
584        /// Why, for the operator.
585        reason: String,
586    },
587
588    /// The single-instance lock is held, so a second agent must not be
589    /// registered.
590    ///
591    /// `05-infrastructure.md` item 1. The message is `d1`'s, verbatim, because
592    /// `d1` already worked out what to tell an operator who lost that race.
593    #[error("cannot install the service while an agent is already running on this host: {source}")]
594    LockHeld {
595        /// What `d1` said, including who holds it.
596        #[source]
597        source: Box<LockError>,
598    },
599
600    /// The lock could not be inspected at all, which is not the same as it being
601    /// free.
602    #[error(
603        "cannot tell whether an agent is already running on this host, so the service was not \
604         installed: {source}. Fix the reported problem with the state directory and try again."
605    )]
606    LockUnreadable {
607        /// The underlying failure.
608        #[source]
609        source: Box<LockError>,
610    },
611
612    /// The runner root this registration would run jobs under could not be
613    /// created, inspected, or given the access the registration's account needs.
614    ///
615    /// Reported *before* anything is registered. A registration whose workspaces
616    /// would land in a directory ordinary local users can write is a
617    /// registration that should not exist, and `04-security-recovery.md` asks
618    /// for exactly that refusal rather than a warning.
619    #[error(
620        "this registration would run jobs under the default runner root, and that root could \
621         not be prepared, so nothing was registered: {source}"
622    )]
623    RunnerRoot {
624        /// What [`crate::runner_root_access`] reported, including the remedy.
625        #[source]
626        source: Box<RootAccessError>,
627    },
628
629    /// The path of the running binary could not be resolved.
630    #[error(
631        "cannot resolve the absolute path of this executable, so there is nothing to register: \
632         {detail}. Run the installer from the installed binary rather than through a shell \
633         function or a wrapper that replaces argv[0]."
634    )]
635    BinaryPath {
636        /// What the platform reported.
637        detail: String,
638    },
639
640    /// The binary named for the registration is not there.
641    #[error(
642        "{} is not a file, so registering it would create a service that cannot start. Install \
643         the product first, then run `service install` from the installed binary.",
644        path.display()
645    )]
646    BinaryMissing {
647        /// The path that was to be registered.
648        path: PathBuf,
649    },
650
651    /// The requested restart delay is outside the bound.
652    #[error(
653        "a restart delay of {requested_secs}s is outside the supported range \
654         {min_secs}s-{max_secs}s. Below the floor the platform's own throttle overrides the \
655         value, so `service status` would report a delay that is not the one in force."
656    )]
657    RestartDelay {
658        /// What was asked for.
659        requested_secs: u64,
660        /// The floor.
661        min_secs: u64,
662        /// The ceiling.
663        max_secs: u64,
664    },
665
666    /// The failure-count reset window is not longer than the restart delay.
667    #[error(
668        "a failure-count reset window of {reset_secs}s is not longer than the {delay_secs}s \
669         restart delay, so the count would reset between every pair of restarts and no \
670         start limit could ever apply."
671    )]
672    RestartResetWindow {
673        /// What was asked for.
674        reset_secs: u64,
675        /// The delay it has to exceed.
676        delay_secs: u64,
677    },
678
679    /// A registration is already there, in the **other** start mode.
680    ///
681    /// Only the other one. A registration in the mode being asked for is
682    /// replaced rather than refused — see [`Operations::install`], and the
683    /// outage that taught us why.
684    #[error(
685        "{name} is already registered to start at {existing}, and this asks for {requested}. \
686         Changing the start mode moves the registration between two different service \
687         managers, so it is not something an install does by itself: run `service uninstall` \
688         first, or change the start mode in the terminal UI (`runner-manager tui`), which \
689         switches it in place and keeps the registration running throughout."
690    )]
691    AlreadyInstalled {
692        /// Which registration.
693        name: String,
694        /// The mode it currently carries.
695        existing: StartMode,
696        /// The mode this install asked for.
697        requested: StartMode,
698    },
699
700    /// No registration is there.
701    #[error("{name} is not registered on this host, so there is nothing to {operation}.")]
702    NotInstalled {
703        /// Which registration.
704        name: String,
705        /// What was being attempted.
706        operation: &'static str,
707    },
708
709    /// The record `install` wrote could not be read or written.
710    #[error("cannot {operation} the service record {}: {detail}", path.display())]
711    Record {
712        /// Read, write, or remove.
713        operation: &'static str,
714        /// The record file.
715        path: PathBuf,
716        /// The underlying failure.
717        detail: String,
718    },
719
720    /// The record is there but is not one this version wrote.
721    #[error(
722        "the service record {} was not written by this product, or was written by a version \
723         this one cannot read: {detail}. Run `service uninstall` and `service install` again; \
724         neither touches configuration, secrets, or the cache.",
725        path.display()
726    )]
727    RecordUnreadable {
728        /// The record file.
729        path: PathBuf,
730        /// What was wrong with it.
731        detail: String,
732    },
733
734    /// The record is there and **this account** may not read it.
735    ///
736    /// Its own variant rather than a [`ServiceError::Record`] with a
737    /// `Permission denied` in the detail, because it is the one record failure
738    /// that is not a fault in the record: `service status` reports it and
739    /// carries on, where every other read failure ends the command. See
740    /// [`Operations::status`].
741    ///
742    /// A version before this one wrote the record at `0600` through a temporary
743    /// file, so a boot-mode `sudo service install` left it owned by `root` and
744    /// unreadable by the operator whose profile it is in. `service status` then
745    /// failed on their own host, with a remedy naming the command that had just
746    /// failed.
747    #[error(
748        "the service record {} is there and this account may not read it: {detail}. It was \
749         written by whichever account installed the service -- on a boot-mode host, `sudo \
750         service install`. Running `service install` again rewrites it readable; until then \
751         only what the service manager itself reports is available.",
752        path.display()
753    )]
754    RecordNotPermitted {
755        /// The record file.
756        path: PathBuf,
757        /// What the operating system said.
758        detail: String,
759    },
760
761    /// The application-data directories could not be resolved or created.
762    #[error("cannot prepare this host's application-data directories: {source}")]
763    Paths {
764        /// The underlying failure.
765        #[source]
766        source: Box<crate::paths::PathsError>,
767    },
768
769    /// The platform's service manager refused, or could not be reached.
770    #[error("cannot {operation} {name} through {manager}: {detail}")]
771    Control {
772        /// What was being attempted.
773        operation: &'static str,
774        /// Which registration.
775        name: String,
776        /// Which service manager.
777        manager: &'static str,
778        /// What it said.
779        detail: String,
780    },
781
782    /// An operation failed and its compensating action failed too.
783    #[error(
784        "cannot {operation} {name}: {cause}. The attempted rollback also failed: {rollback}. \
785         Inspect `service status` before retrying."
786    )]
787    Rollback {
788        /// The transaction that could not be completed.
789        operation: &'static str,
790        /// Which registration was involved.
791        name: String,
792        /// The original failure.
793        cause: String,
794        /// The failure while restoring the previous state.
795        rollback: String,
796    },
797
798    /// The machine's registration is another installation's (see
799    /// [`ForeignService`]), and changing it from this account would act on it
800    /// with this account's directories.
801    #[error(
802        "this machine's {name} service was installed from {installed_by}, not from this \
803         account's data root, so this account does not {operation} it: doing so would \
804         re-register or remove that installation's service with this account's directories. \
805         Do it {act_as}. If that account no longer exists, remove the registration with the \
806         service manager itself first."
807    )]
808    ForeignInstallation {
809        /// Which registration.
810        name: String,
811        /// What was refused.
812        operation: &'static str,
813        /// [`ForeignService::installed_by`].
814        installed_by: String,
815        /// [`ForeignService::act_as`].
816        act_as: String,
817    },
818
819    /// `host forget-local` found this account's configuration in use, and
820    /// moved nothing.
821    #[error("this account's local configuration is in use, so nothing was archived: {detail}.")]
822    LocalConfigurationInUse {
823        /// What uses it.
824        detail: String,
825    },
826
827    /// The operation needs administrative rights it does not have.
828    #[error("{operation} {name} needs administrative rights: {detail}. {remedy}")]
829    NeedsElevation {
830        /// What was being attempted.
831        operation: &'static str,
832        /// Which registration.
833        name: String,
834        /// What the platform said.
835        detail: String,
836        /// How to get them, in this platform's own terms.
837        remedy: &'static str,
838    },
839}
840
841// ---------------------------------------------------------------------------
842// Directories
843// ---------------------------------------------------------------------------
844
845/// The four directories the registration was installed against.
846///
847/// Recorded rather than re-derived, because `05-infrastructure.md` item 2 speaks
848/// of the *configured* cache and runtime directories: the account the service
849/// runs under and the account that ran `service install` do not always resolve
850/// the same platform-standard locations, and a claim about what the service can
851/// write is only checkable against a specific set of paths.
852#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
853pub struct ServiceDirectories {
854    /// Non-secret TOML and the SQLite database.
855    pub config: PathBuf,
856    /// The agent lock, the attempt journal, and the runner package cache.
857    pub state: PathBuf,
858    /// Per-attempt disposable runner workspaces.
859    pub runtime: PathBuf,
860    /// Rotating redacted diagnostics.
861    pub logs: PathBuf,
862}
863
864impl ServiceDirectories {
865    /// Snapshots the four directories a resolved [`AppPaths`] names.
866    #[must_use]
867    pub fn of(paths: &AppPaths) -> Self {
868        Self {
869            config: paths.config_dir().to_path_buf(),
870            state: paths.state_dir().to_path_buf(),
871            runtime: paths.runtime_dir().to_path_buf(),
872            logs: paths.logs_dir().to_path_buf(),
873        }
874    }
875
876    /// The four, in the order `05-infrastructure.md` lists them.
877    #[must_use]
878    pub fn all(&self) -> [&Path; 4] {
879        [
880            self.config.as_path(),
881            self.state.as_path(),
882            self.runtime.as_path(),
883            self.logs.as_path(),
884        ]
885    }
886
887    /// The rotating diagnostic log's stem, which is what `service status`
888    /// reports and what `05-infrastructure.md` item 4 requires be preserved.
889    #[must_use]
890    pub fn log_file(&self) -> PathBuf {
891        self.logs.join(LOG_FILE_STEM)
892    }
893
894    /// The four as the hidden `daemon run` options a registration passes them
895    /// in, [`SERVICE_DIRECTORY_OPTIONS`] order.
896    #[must_use]
897    pub fn arguments(&self) -> [OsString; 8] {
898        let [config, state, runtime, logs] = SERVICE_DIRECTORY_OPTIONS.map(OsString::from);
899        [
900            config,
901            self.config.clone().into_os_string(),
902            state,
903            self.state.clone().into_os_string(),
904            runtime,
905            self.runtime.clone().into_os_string(),
906            logs,
907            self.logs.clone().into_os_string(),
908        ]
909    }
910
911    /// Reads the four back out of a registration's command line.
912    ///
913    /// `None` unless all four options are there: a registration made before
914    /// they existed starts the daemon against whatever its account's
915    /// platform-standard directories are, and which those are cannot be told
916    /// from here.
917    #[must_use]
918    pub fn from_command_line(command_line: &str) -> Option<Self> {
919        let arguments = split_command_line(command_line);
920        let value = |option: &str| {
921            let at = arguments.iter().position(|argument| argument == option)?;
922            arguments.get(at + 1).map(PathBuf::from)
923        };
924        let [config, state, runtime, logs] = SERVICE_DIRECTORY_OPTIONS;
925        Some(Self {
926            config: value(config)?,
927            state: value(state)?,
928            runtime: value(runtime)?,
929            logs: value(logs)?,
930        })
931    }
932
933    /// The deepest directory all four sit under: what an operator would call
934    /// the installation's data root. The configuration directory when they
935    /// share nothing, and [`crate::paths::SYSTEM_DATA_ROOT`] for the system
936    /// tree, whose logs are under `/var/log` rather than beside the rest.
937    #[must_use]
938    pub fn root(&self) -> PathBuf {
939        if *self == Self::of(&AppPaths::system()) {
940            return PathBuf::from(crate::paths::SYSTEM_DATA_ROOT);
941        }
942        let mut shared: Vec<std::path::Component<'_>> = self.config.components().collect();
943        for other in [&self.state, &self.runtime, &self.logs] {
944            let common = shared
945                .iter()
946                .zip(other.components())
947                .take_while(|(left, right)| **left == *right)
948                .count();
949            shared.truncate(common);
950        }
951        if shared.is_empty() {
952            self.config.clone()
953        } else {
954            shared.iter().collect()
955        }
956    }
957
958    /// The four as an [`AppPaths`], to read the records a daemon running
959    /// against them writes.
960    #[must_use]
961    pub fn paths(&self) -> AppPaths {
962        AppPaths::from_directories(&self.config, &self.state, &self.runtime, &self.logs)
963    }
964}
965
966/// The hidden `daemon run` options that carry the installing account's four
967/// application-data directories across to the service's account, in
968/// [`ServiceDirectories`] order. Written by `service install`, and read back
969/// by [`ServiceDirectories::from_command_line`] to tell whose data root a
970/// registration runs against.
971pub const SERVICE_DIRECTORY_OPTIONS: [&str; 4] = [
972    "--service-config-dir",
973    "--service-state-dir",
974    "--service-runtime-dir",
975    "--service-logs-dir",
976];
977
978// ---------------------------------------------------------------------------
979// The install plan
980// ---------------------------------------------------------------------------
981
982/// What `service install` was asked for, before anything has been resolved.
983///
984/// Separate from [`InstallPlan`] because a request is the operator's words and
985/// a plan is what the host actually supports: the plan carries a *resolved
986/// absolute* binary path, the account the start mode obliges, and the four
987/// directories the registration is installed against, none of which the caller
988/// supplies.
989#[derive(Debug, Clone, PartialEq, Eq)]
990pub struct InstallRequest {
991    start_mode: StartMode,
992    binary: Option<PathBuf>,
993    source_binary: Option<PathBuf>,
994    arguments: Vec<OsString>,
995    restart: RestartPolicy,
996    on_demand: bool,
997    protected: Vec<PathBuf>,
998}
999
1000impl InstallRequest {
1001    /// Register the binary that is running this call.
1002    #[must_use]
1003    pub fn new(start_mode: StartMode) -> Self {
1004        Self {
1005            start_mode,
1006            binary: None,
1007            source_binary: None,
1008            arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
1009            restart: RestartPolicy::default(),
1010            on_demand: false,
1011            protected: Vec::new(),
1012        }
1013    }
1014
1015    /// Host paths the service, its runners and their jobs may not reach at
1016    /// all. See [`ProtectedPaths`].
1017    #[must_use]
1018    pub fn protecting(mut self, paths: Vec<PathBuf>) -> Self {
1019        self.protected = paths;
1020        self
1021    }
1022
1023    /// Registers the service but does not ask the manager to start it by
1024    /// itself.
1025    ///
1026    /// Production never uses this: a boot-mode registration that does not start
1027    /// at boot is the failure `service status` reports. **The privileged
1028    /// installer tests use it for every fixture they create**, so that a
1029    /// registration which somehow escaped its cleanup guard cannot start with
1030    /// the owner's machine on the next reboot. A leaked service is bad; a
1031    /// leaked service that runs is worse, and the difference costs one flag.
1032    #[must_use]
1033    pub const fn started_on_demand(mut self) -> Self {
1034        self.on_demand = true;
1035        self
1036    }
1037
1038    /// Register a named binary instead of the running one.
1039    ///
1040    /// The privileged installer tests use this to register a fixture service
1041    /// host. Production does not: `05-infrastructure.md` item 6 is specifically
1042    /// about the path of *the running binary*, and letting an operator name a
1043    /// different one would defeat the stale-path detection it asks for.
1044    #[must_use]
1045    pub fn for_binary(mut self, binary: impl Into<PathBuf>) -> Self {
1046        self.binary = Some(binary.into());
1047        self
1048    }
1049
1050    /// Where the registered binary was copied from.
1051    ///
1052    /// # Why a registration has a source at all
1053    ///
1054    /// A service registered directly against a package manager's own file can
1055    /// never be upgraded on Windows, because the running service holds that
1056    /// file open and `npm i -g` cannot replace it. What it does instead is
1057    /// worse than failing: it rewrites the package metadata, reports success,
1058    /// and leaves the old executable in place — so the operator is told the new
1059    /// version is installed while the old one keeps running. That was observed,
1060    /// twice in a row, before this existed.
1061    ///
1062    /// So the service runs a copy the product owns, and this records where that
1063    /// copy came from. The source is what a package manager updates, what
1064    /// [`inspect_binary`] watches for item 6's stale-path case, and what the
1065    /// daemon compares its own version against to know an upgrade is waiting.
1066    #[must_use]
1067    pub fn copied_from(mut self, source: impl Into<PathBuf>) -> Self {
1068        self.source_binary = Some(source.into());
1069        self
1070    }
1071
1072    /// Replace the registered arguments. Defaults to [`DAEMON_ARGUMENTS`].
1073    #[must_use]
1074    pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1075    where
1076        I: IntoIterator<Item = S>,
1077        S: Into<OsString>,
1078    {
1079        self.arguments = arguments.into_iter().map(Into::into).collect();
1080        self
1081    }
1082
1083    /// Replace the restart-on-failure policy. Defaults to
1084    /// [`RestartPolicy::default`].
1085    #[must_use]
1086    pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1087        self.restart = restart;
1088        self
1089    }
1090
1091    /// The start mode asked for.
1092    #[must_use]
1093    pub const fn start_mode(&self) -> StartMode {
1094        self.start_mode
1095    }
1096}
1097
1098/// Everything a backend needs, resolved and validated.
1099///
1100/// Constructing one is where `05-infrastructure.md` item 6 is actually
1101/// satisfied: the binary path is made absolute and is confirmed to be a file
1102/// *before* anything is registered, so a registration that would fail at boot
1103/// is refused at install time instead.
1104#[derive(Debug, Clone, PartialEq, Eq)]
1105pub struct InstallPlan {
1106    identity: ServiceIdentity,
1107    start_mode: StartMode,
1108    binary: PathBuf,
1109    source_binary: Option<PathBuf>,
1110    arguments: Vec<OsString>,
1111    account: ServiceAccount,
1112    restart: RestartPolicy,
1113    directories: ServiceDirectories,
1114    secret_guard: Option<PathBuf>,
1115    on_demand: bool,
1116    protected: Vec<PathBuf>,
1117}
1118
1119impl InstallPlan {
1120    /// Resolves a request against this host.
1121    ///
1122    /// # Errors
1123    ///
1124    /// [`ServiceError::BinaryPath`] when the running executable cannot be
1125    /// located, and [`ServiceError::BinaryMissing`] when the path that would be
1126    /// registered is not a file.
1127    pub fn resolve(
1128        identity: ServiceIdentity,
1129        request: &InstallRequest,
1130        directories: ServiceDirectories,
1131    ) -> Result<Self, ServiceError> {
1132        let binary = match &request.binary {
1133            Some(named) => absolute(named)?,
1134            None => running_executable()?,
1135        };
1136        if !binary.is_file() {
1137            return Err(ServiceError::BinaryMissing { path: binary });
1138        }
1139        // `d2` publishes the guard file the store lives in so the systemd
1140        // sandbox can admit only its parent for atomic token rotation. A store
1141        // that cannot be resolved is not fatal here; it merely cannot receive
1142        // that narrow writable-path exception.
1143        let secret_guard = crate::secrets::PlatformSecretStore::for_start_mode(request.start_mode)
1144            .ok()
1145            .map(|store| store.guard());
1146        Ok(Self {
1147            identity,
1148            start_mode: request.start_mode,
1149            binary,
1150            source_binary: request.source_binary.clone(),
1151            arguments: request.arguments.clone(),
1152            account: ServiceAccount::for_start_mode(request.start_mode),
1153            restart: request.restart,
1154            directories,
1155            secret_guard,
1156            on_demand: request.on_demand,
1157            protected: request.protected.clone(),
1158        })
1159    }
1160
1161    /// Builds a plan from values a caller already holds, without touching the
1162    /// filesystem.
1163    ///
1164    /// This is what makes the renderers testable on a host that has none of the
1165    /// paths involved: a Windows developer can render the systemd unit for
1166    /// `/opt/runner-manager/bin/runner-manager` and assert every line of it.
1167    #[must_use]
1168    pub fn unchecked(
1169        identity: ServiceIdentity,
1170        start_mode: StartMode,
1171        binary: impl Into<PathBuf>,
1172        directories: ServiceDirectories,
1173    ) -> Self {
1174        Self {
1175            identity,
1176            start_mode,
1177            binary: binary.into(),
1178            source_binary: None,
1179            arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
1180            account: ServiceAccount::for_start_mode(start_mode),
1181            restart: RestartPolicy::default(),
1182            directories,
1183            secret_guard: None,
1184            on_demand: false,
1185            protected: Vec::new(),
1186        }
1187    }
1188
1189    /// The same plan with `paths` made inaccessible. See [`ProtectedPaths`].
1190    #[must_use]
1191    pub fn with_protected_paths(mut self, paths: Vec<PathBuf>) -> Self {
1192        self.protected = paths;
1193        self
1194    }
1195
1196    /// The host paths the service and its jobs may not reach.
1197    #[must_use]
1198    pub fn protected_paths(&self) -> &[PathBuf] {
1199        &self.protected
1200    }
1201
1202    /// Registers without arming the manager's automatic start. See
1203    /// [`InstallRequest::started_on_demand`].
1204    #[must_use]
1205    pub const fn started_on_demand(mut self) -> Self {
1206        self.on_demand = true;
1207        self
1208    }
1209
1210    /// Whether the manager was asked not to start this by itself.
1211    #[must_use]
1212    pub const fn is_on_demand(&self) -> bool {
1213        self.on_demand
1214    }
1215
1216    /// Names the file the secret store lives in, so systemd can permit atomic
1217    /// replacement of it under `ProtectSystem=strict`.
1218    ///
1219    /// [`InstallPlan::resolve`] fills this from `d2`; a test names it directly
1220    /// so that a Windows or macOS CI leg can assert the Linux unit's writable
1221    /// path for a store that platform does not have.
1222    #[must_use]
1223    pub fn with_secret_guard(mut self, guard: impl Into<PathBuf>) -> Self {
1224        self.secret_guard = Some(guard.into());
1225        self
1226    }
1227
1228    /// The file the machine-scoped secret store lives in, when one resolved.
1229    #[must_use]
1230    pub fn secret_guard(&self) -> Option<&Path> {
1231        self.secret_guard.as_deref()
1232    }
1233
1234    /// Overrides the restart policy on an already-built plan.
1235    #[must_use]
1236    pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1237        self.restart = restart;
1238        self
1239    }
1240
1241    /// Overrides the registered arguments on an already-built plan.
1242    #[must_use]
1243    pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1244    where
1245        I: IntoIterator<Item = S>,
1246        S: Into<OsString>,
1247    {
1248        self.arguments = arguments.into_iter().map(Into::into).collect();
1249        self
1250    }
1251
1252    /// What the operating system calls this registration.
1253    #[must_use]
1254    pub const fn identity(&self) -> &ServiceIdentity {
1255        &self.identity
1256    }
1257
1258    /// Boot or login.
1259    #[must_use]
1260    pub const fn start_mode(&self) -> StartMode {
1261        self.start_mode
1262    }
1263
1264    /// The resolved absolute path of the binary to register.
1265    #[must_use]
1266    pub fn binary(&self) -> &Path {
1267        &self.binary
1268    }
1269
1270    /// Where [`Self::binary`] was copied from, when it is a copy.
1271    ///
1272    /// `None` is the legacy layout: the registration names the file a package
1273    /// manager owns, and cannot be upgraded while it runs. See
1274    /// [`InstallRequest::copied_from`].
1275    #[must_use]
1276    pub fn source_binary(&self) -> Option<&Path> {
1277        self.source_binary.as_deref()
1278    }
1279
1280    /// The arguments it is registered with.
1281    #[must_use]
1282    pub fn arguments(&self) -> &[OsString] {
1283        &self.arguments
1284    }
1285
1286    /// The account it runs under.
1287    #[must_use]
1288    pub const fn account(&self) -> &ServiceAccount {
1289        &self.account
1290    }
1291
1292    /// The restart-on-failure policy.
1293    #[must_use]
1294    pub const fn restart(&self) -> RestartPolicy {
1295        self.restart
1296    }
1297
1298    /// The four directories it was installed against.
1299    #[must_use]
1300    pub const fn directories(&self) -> &ServiceDirectories {
1301        &self.directories
1302    }
1303
1304    /// The command line as one string, quoted the way each platform expects.
1305    #[must_use]
1306    pub fn command_line(&self) -> String {
1307        let mut out = quote_argument(&self.binary.to_string_lossy());
1308        for argument in &self.arguments {
1309            out.push(' ');
1310            out.push_str(&quote_argument(&argument.to_string_lossy()));
1311        }
1312        out
1313    }
1314}
1315
1316/// Undoes a runner-root change, and says what could not be undone.
1317///
1318/// `None` when the rollback was complete — either because there was nothing to
1319/// undo, or because the directory this operation created was removed again and
1320/// the descriptor it replaced was written back. `Some` is the "report any
1321/// non-reversible existing directory state explicitly" half of the requirement,
1322/// and the caller folds it into the failure it was already reporting.
1323fn retained_runner_root(change: &RootAccessChange) -> Option<String> {
1324    let reversal = change.revert();
1325    matches!(reversal, Reversal::Retained { .. }).then(|| reversal.to_string())
1326}
1327
1328/// The error a failed step returns once both of its rollbacks have been
1329/// attempted.
1330///
1331/// `retained` is [`retained_runner_root`]'s answer and `rollback` is what
1332/// undoing the *registration* reported — whatever it reports on success, which
1333/// a failure has no use for. Both arrive already evaluated, because each may be
1334/// attempted exactly once and the order is the caller's to choose.
1335///
1336/// A complete rollback leaves `cause` exactly as it was: the operator's problem
1337/// is what failed, not the tidying up afterwards. An incomplete one is promoted
1338/// to [`ServiceError::Rollback`], which is the variant that exists to say "this
1339/// failed *and* something is left behind" — and it names everything that did.
1340fn rolled_back<T>(
1341    retained: Option<String>,
1342    rollback: Result<T, ServiceError>,
1343    operation: &'static str,
1344    identity: &ServiceIdentity,
1345    cause: ServiceError,
1346) -> ServiceError {
1347    let left_behind = match (rollback.err(), retained) {
1348        (Some(rollback), Some(retained)) => Some(format!("{rollback}; {retained}")),
1349        (Some(rollback), None) => Some(rollback.to_string()),
1350        (None, retained) => retained,
1351    };
1352    match left_behind {
1353        Some(rollback) => ServiceError::Rollback {
1354            operation,
1355            name: identity.name().to_string(),
1356            cause: cause.to_string(),
1357            rollback,
1358        },
1359        None => cause,
1360    }
1361}
1362
1363/// [`rolled_back`] for a failure with nothing registered yet, where the runner
1364/// root is the only thing there is to undo.
1365fn undo_runner_root(
1366    change: &RootAccessChange,
1367    operation: &'static str,
1368    identity: &ServiceIdentity,
1369    cause: ServiceError,
1370) -> ServiceError {
1371    rolled_back(
1372        retained_runner_root(change),
1373        Ok(()),
1374        operation,
1375        identity,
1376        cause,
1377    )
1378}
1379
1380/// Makes a path absolute without resolving symlinks.
1381///
1382/// Symbolic links are deliberately left alone. A Homebrew installation puts a
1383/// stable link at `/opt/homebrew/bin/runner-manager` and moves the file it
1384/// points at on every upgrade; recording the link's target would make an
1385/// ordinary `brew upgrade` look exactly like the npm failure this module exists
1386/// to detect, and recording the link records the thing the operator installed.
1387///
1388/// Linux is the one platform where this is not fully in the caller's hands:
1389/// `std::env::current_exe` reads `/proc/self/exe`, which the kernel has already
1390/// resolved. There the recorded path is the real file rather than the shim —
1391/// which is still correct for the npm case, because a real file under a Node
1392/// prefix disappears with the prefix.
1393fn absolute(path: &Path) -> Result<PathBuf, ServiceError> {
1394    std::path::absolute(path).map_err(|error| ServiceError::BinaryPath {
1395        detail: format!("{} could not be made absolute: {error}", path.display()),
1396    })
1397}
1398
1399/// The absolute path of the executable running this call.
1400fn running_executable() -> Result<PathBuf, ServiceError> {
1401    let raw = std::env::current_exe().map_err(|error| ServiceError::BinaryPath {
1402        detail: error.to_string(),
1403    })?;
1404    absolute(&raw)
1405}
1406
1407/// Quotes one command-line argument when it needs it.
1408///
1409/// The rule is Windows', because Windows is the platform where a command line is
1410/// a single string the callee re-splits, and because `windows-service` applies
1411/// exactly this rule when it builds `lpBinaryPathName`. The Unix backends embed
1412/// arguments in a plist array and in a systemd `ExecStart=`, both of which
1413/// accept the same quoting, so one rule serves all three rather than three
1414/// nearly-identical ones.
1415pub(crate) fn quote_argument(argument: &str) -> String {
1416    if !argument.is_empty() && !argument.contains([' ', '"', '\t', '\n']) {
1417        return argument.to_string();
1418    }
1419    let mut out = String::with_capacity(argument.len() + 2);
1420    out.push('"');
1421    let mut backslashes = 0usize;
1422    for c in argument.chars() {
1423        match c {
1424            '\\' => {
1425                backslashes += 1;
1426                out.push('\\');
1427            }
1428            '"' => {
1429                // Every backslash immediately before a quote must be doubled,
1430                // and the quote itself escaped.
1431                for _ in 0..=backslashes {
1432                    out.push('\\');
1433                }
1434                out.push('"');
1435                backslashes = 0;
1436            }
1437            other => {
1438                backslashes = 0;
1439                out.push(other);
1440            }
1441        }
1442    }
1443    // Trailing backslashes would otherwise escape the closing quote.
1444    for _ in 0..backslashes {
1445        out.push('\\');
1446    }
1447    out.push('"');
1448    out
1449}
1450
1451/// Reads the executable back out of a command line quoted by
1452/// [`quote_argument`].
1453///
1454/// `service status` needs this because the Windows Service Control Manager
1455/// stores the binary and its arguments as one string and hands the whole thing
1456/// back from `QueryServiceConfigW`. Comparing a record against a registration
1457/// means splitting that string the same way Windows itself does.
1458///
1459/// Returns `None` for an empty or whitespace-only command line, which is the
1460/// only input with no first argument to find.
1461#[must_use]
1462pub fn executable_from_command_line(command_line: &str) -> Option<PathBuf> {
1463    split_command_line(command_line)
1464        .into_iter()
1465        .next()
1466        .filter(|executable| !executable.is_empty())
1467        .map(PathBuf::from)
1468}
1469
1470/// Splits a command line quoted by [`quote_argument`] back into its
1471/// arguments, by the rule Windows itself applies: whitespace separates
1472/// arguments outside quotes, `2n` backslashes before a quote are `n`
1473/// backslashes and a quote that opens or closes, `2n+1` are `n` backslashes
1474/// and a literal quote, and every other backslash is literal.
1475#[must_use]
1476pub fn split_command_line(command_line: &str) -> Vec<String> {
1477    let mut arguments = Vec::new();
1478    let mut current = String::new();
1479    let mut started = false;
1480    let mut quoted = false;
1481    let mut backslashes = 0usize;
1482    for c in command_line.chars() {
1483        match c {
1484            '\\' => {
1485                backslashes += 1;
1486                started = true;
1487            }
1488            '"' => {
1489                current.extend(std::iter::repeat_n('\\', backslashes / 2));
1490                if backslashes.is_multiple_of(2) {
1491                    quoted = !quoted;
1492                } else {
1493                    current.push('"');
1494                }
1495                backslashes = 0;
1496                started = true;
1497            }
1498            ' ' | '\t' if !quoted => {
1499                current.extend(std::iter::repeat_n('\\', backslashes));
1500                backslashes = 0;
1501                if started {
1502                    arguments.push(std::mem::take(&mut current));
1503                    started = false;
1504                }
1505            }
1506            other => {
1507                current.extend(std::iter::repeat_n('\\', backslashes));
1508                backslashes = 0;
1509                current.push(other);
1510                started = true;
1511            }
1512        }
1513    }
1514    current.extend(std::iter::repeat_n('\\', backslashes));
1515    if started {
1516        arguments.push(current);
1517    }
1518    arguments
1519}
1520
1521// ---------------------------------------------------------------------------
1522// Rendered definitions
1523// ---------------------------------------------------------------------------
1524
1525/// Which of the four things a platform reads.
1526#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1527pub enum DefinitionKind {
1528    /// The parameters `CreateServiceW` is called with, rendered as a canonical
1529    /// descriptor so that they can be reviewed and asserted like the other
1530    /// three. The Service Control Manager has no file.
1531    WindowsService,
1532    /// A Task Scheduler XML document, for `--start-at login` on Windows.
1533    WindowsScheduledTask,
1534    /// A launchd property list — a LaunchDaemon at boot, a LaunchAgent at login.
1535    LaunchdPlist,
1536    /// A systemd unit — a system unit at boot, a user unit at login.
1537    SystemdUnit,
1538}
1539
1540impl DefinitionKind {
1541    /// What to call the thing that reads it, in an operator-facing message.
1542    #[must_use]
1543    pub const fn manager(self) -> &'static str {
1544        match self {
1545            Self::WindowsService => "the Windows Service Control Manager",
1546            Self::WindowsScheduledTask => "Windows Task Scheduler",
1547            Self::LaunchdPlist => "launchd",
1548            Self::SystemdUnit => "systemd",
1549        }
1550    }
1551}
1552
1553impl fmt::Display for DefinitionKind {
1554    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1555        f.write_str(self.manager())
1556    }
1557}
1558
1559/// One platform's definition of the registration, as text.
1560///
1561/// Producing this is pure: no `cfg`, no privileges, no filesystem. That is what
1562/// lets every leg of the CI matrix assert every platform's definition, instead
1563/// of each leg asserting only its own and the other two being reviewed by
1564/// reading.
1565#[derive(Debug, Clone, PartialEq, Eq)]
1566pub struct ServiceDefinition {
1567    kind: DefinitionKind,
1568    text: String,
1569    install_path: Option<PathBuf>,
1570}
1571
1572impl ServiceDefinition {
1573    /// Which platform this is for.
1574    #[must_use]
1575    pub const fn kind(&self) -> DefinitionKind {
1576        self.kind
1577    }
1578
1579    /// The definition itself.
1580    #[must_use]
1581    pub fn text(&self) -> &str {
1582        &self.text
1583    }
1584
1585    /// Where the platform expects the file, when the platform reads a file.
1586    ///
1587    /// `None` for [`DefinitionKind::WindowsService`], whose "definition" is a
1588    /// set of arguments to `CreateServiceW`, and for a Task Scheduler document,
1589    /// which is handed to `schtasks` from a temporary file and is not stored
1590    /// where it was written.
1591    #[must_use]
1592    pub fn install_path(&self) -> Option<&Path> {
1593        self.install_path.as_deref()
1594    }
1595
1596    /// The definition this host's own service manager would be given.
1597    ///
1598    /// The value-level twin of [`host_definition_kind`]. Rendering needs no
1599    /// privileges and no service manager, so anything that wants to *see* what
1600    /// an install would register — `f3`'s `service install --dry-run`, a
1601    /// support bundle, [`RecordingControls`] — can have it without registering
1602    /// anything.
1603    ///
1604    /// # Errors
1605    ///
1606    /// [`ServiceError::Control`] when a Windows logon-triggered registration is
1607    /// asked for and the session reports no account to register it for.
1608    pub fn for_host(plan: &InstallPlan) -> Result<Self, ServiceError> {
1609        Ok(match host_definition_kind(plan.start_mode()) {
1610            DefinitionKind::WindowsService => Self::windows_service(plan),
1611            DefinitionKind::WindowsScheduledTask => {
1612                Self::windows_scheduled_task(plan, &TaskPrincipal::current()?)
1613            }
1614            DefinitionKind::LaunchdPlist => Self::launchd(plan, host_home().as_deref()),
1615            DefinitionKind::SystemdUnit => Self::systemd(plan, host_home().as_deref()),
1616        })
1617    }
1618
1619    /// The Windows service parameters, as a canonical descriptor.
1620    #[must_use]
1621    pub fn windows_service(plan: &InstallPlan) -> Self {
1622        Self {
1623            kind: DefinitionKind::WindowsService,
1624            text: windows_service_descriptor(plan),
1625            install_path: None,
1626        }
1627    }
1628
1629    /// A Task Scheduler document for `--start-at login`.
1630    #[must_use]
1631    pub fn windows_scheduled_task(plan: &InstallPlan, principal: &TaskPrincipal) -> Self {
1632        Self {
1633            kind: DefinitionKind::WindowsScheduledTask,
1634            text: windows_scheduled_task_xml(plan, principal),
1635            install_path: None,
1636        }
1637    }
1638
1639    /// A launchd property list.
1640    ///
1641    /// `home` is only consulted for `--start-at login`, where the file belongs
1642    /// in the operator's own `~/Library/LaunchAgents`.
1643    #[must_use]
1644    pub fn launchd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1645        let file = format!("{}.plist", plan.identity().launchd_label());
1646        let install_path = match plan.start_mode() {
1647            StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
1648            StartMode::Login => home.map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file)),
1649        };
1650        Self {
1651            kind: DefinitionKind::LaunchdPlist,
1652            text: launchd_plist(plan),
1653            install_path,
1654        }
1655    }
1656
1657    /// Wraps text this module did not render.
1658    ///
1659    /// Two callers need it and both matter. A test builds a deliberately
1660    /// widened definition and watches [`review_least_privilege`] reject it —
1661    /// without which the review would be a function nobody had ever seen fail.
1662    /// And `service status` can review the definition **actually on disk**,
1663    /// which on Linux and macOS an operator is free to edit after installation.
1664    #[must_use]
1665    pub fn from_text(kind: DefinitionKind, text: impl Into<String>) -> Self {
1666        Self {
1667            kind,
1668            text: text.into(),
1669            install_path: None,
1670        }
1671    }
1672
1673    /// A systemd unit.
1674    ///
1675    /// `home` is only consulted for `--start-at login`, where the unit belongs
1676    /// in the operator's own `~/.config/systemd/user`.
1677    #[must_use]
1678    pub fn systemd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1679        let file = plan.identity().systemd_unit();
1680        let install_path = match plan.start_mode() {
1681            StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
1682            StartMode::Login => home.map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file)),
1683        };
1684        Self {
1685            kind: DefinitionKind::SystemdUnit,
1686            text: systemd_unit(plan),
1687            install_path,
1688        }
1689    }
1690}
1691
1692/// Where a LaunchDaemon lives.
1693pub const LAUNCH_DAEMONS_DIR: &str = "/Library/LaunchDaemons";
1694/// Where a LaunchAgent lives, under the operator's home directory.
1695pub const LAUNCH_AGENTS_SUBDIR: &str = "Library/LaunchAgents";
1696/// Where a systemd system unit lives.
1697pub const SYSTEMD_SYSTEM_DIR: &str = "/etc/systemd/system";
1698/// Where a systemd user unit lives, under the operator's home directory.
1699pub const SYSTEMD_USER_SUBDIR: &str = ".config/systemd/user";
1700
1701/// The product's own page, cited by every definition so an operator who finds
1702/// one on a host can find out what put it there.
1703const DOCUMENTATION: &str = "https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI";
1704
1705/// How many consecutive failures the platform tolerates before it stops
1706/// retrying.
1707///
1708/// The companion to [`RestartPolicy::reset_after`]: the delay bounds how *fast*
1709/// a restart may come, and this bounds how *many* come before the host gives
1710/// up and leaves the failure visible instead of hiding it behind an endless
1711/// retry.
1712pub const START_LIMIT_BURST: u32 = 5;
1713
1714// -- systemd -----------------------------------------------------------------
1715
1716/// Renders the systemd unit for this plan.
1717///
1718/// # Why the sandbox is this tight, and what it costs
1719///
1720/// `05-infrastructure.md` item 2 ends *"and write its configured cache and
1721/// runtime directories — and no more"*. `ProtectSystem=strict` plus an explicit
1722/// `ReadWritePaths` is what "and no more" means on Linux: everything outside the
1723/// recorded application-data directories and the credential store directory
1724/// is read-only to this unit. The latter must be writable because refresh-token
1725/// rotation atomically replaces the credential file.
1726///
1727/// **The runner inherits it.** The agent spawns the GitHub Actions runner as a
1728/// child, so a workflow running on this host also runs inside this sandbox: it
1729/// cannot write outside those directories and its private `/tmp`, and
1730/// `NoNewPrivileges=yes` means it cannot `sudo`. `07-security.md` assumes a
1731/// hostile workflow may run here, so that is the intended direction — but it is
1732/// a real behavioural limit and `docs/service-account.md` states it where an
1733/// operator will find it.
1734#[must_use]
1735pub fn systemd_unit(plan: &InstallPlan) -> String {
1736    let identity = plan.identity();
1737    let restart = plan.restart();
1738    let directories = plan.directories();
1739
1740    let mut out = String::new();
1741    out.push_str("[Unit]\n");
1742    out.push_str(&format!("Description={}\n", identity.display_name()));
1743    out.push_str(&format!("Documentation={DOCUMENTATION}\n"));
1744    out.push_str("After=network-online.target\n");
1745    out.push_str("Wants=network-online.target\n");
1746    // In `[Unit]` rather than `[Service]`: systemd moved these in v229 and only
1747    // accepts them here without a deprecation warning.
1748    out.push_str(&format!(
1749        "StartLimitIntervalSec={}\n",
1750        restart.reset_after().as_secs()
1751    ));
1752    out.push_str(&format!("StartLimitBurst={START_LIMIT_BURST}\n"));
1753
1754    out.push_str("\n[Service]\n");
1755    out.push_str("Type=simple\n");
1756    // A runner is a child of the daemon. In particular, systemd must not
1757    // signal it merely because a cooperative daemon handover replaces the
1758    // manager process; startup recovery supervises any survivor instead.
1759    out.push_str("KillMode=process\n");
1760    out.push_str(&format!("ExecStart={}\n", plan.command_line()));
1761    out.push_str(&format!(
1762        "WorkingDirectory={}\n",
1763        directories.state.display()
1764    ));
1765    out.push_str(&format!("SyslogIdentifier={identity}\n"));
1766    out.push_str("Restart=on-failure\n");
1767    out.push_str(&format!("RestartSec={}\n", restart.delay().as_secs()));
1768
1769    out.push_str("\n# Least privilege. See docs/service-account.md.\n");
1770    for directive in systemd_hardening(plan.start_mode()) {
1771        out.push_str(directive);
1772        out.push('\n');
1773    }
1774    let quoted = |paths: &[String]| {
1775        paths
1776            .iter()
1777            .map(|path| quote_argument(path))
1778            .collect::<Vec<_>>()
1779            .join(" ")
1780    };
1781    let permitted = permitted_paths(DefinitionKind::SystemdUnit, plan);
1782    out.push_str(&format!("ReadWritePaths={}\n", quoted(&permitted)));
1783    // `ProtectHome=tmpfs` hides the home trees, and only `BindPaths=` brings
1784    // a directory back (see `DefinitionDrift::writable`). New boot units keep
1785    // theirs outside them; one an older build put under `/root` (a WSL host
1786    // set up before 0.4.57) keeps working this way.
1787    if plan.start_mode() == StartMode::Boot {
1788        let under_home: Vec<String> = permitted
1789            .iter()
1790            .filter(|path| home_root_of(Path::new(path.as_str())).is_some())
1791            .cloned()
1792            .collect();
1793        if !under_home.is_empty() {
1794            out.push_str(&format!("BindPaths={}\n", quoted(&under_home)));
1795        }
1796    }
1797    if !plan.protected_paths().is_empty() {
1798        out.push_str(&format!(
1799            "InaccessiblePaths={}\n",
1800            inaccessible_entries(plan).join(" ")
1801        ));
1802    }
1803
1804    out.push_str("\n[Install]\n");
1805    out.push_str(match plan.start_mode() {
1806        StartMode::Boot => "WantedBy=multi-user.target\n",
1807        StartMode::Login => "WantedBy=default.target\n",
1808    });
1809    out
1810}
1811
1812/// The hardening directives every unit carries, in the order they are rendered.
1813///
1814/// One list, used by [`systemd_hardening`], so that [`review_least_privilege`]
1815/// is written against the same directives the renderer emits: a directive
1816/// dropped from the unit is one the review then reports as missing, rather
1817/// than one that silently stops being checked.
1818///
1819/// The capability bounding set listed here is a login unit's: empty. A boot
1820/// unit's is [`BOOT_CAPABILITIES`]; see [`systemd_hardening`].
1821///
1822/// # Why not `RestrictSUIDSGID=yes`
1823///
1824/// Its seccomp filter cannot inspect `openat2`'s flags, so it answers every
1825/// `openat2` with `ENOSYS`, expecting a fallback to `openat`. GNU tar 1.35
1826/// extracts through `openat2(..., RESOLVE_BENEATH)` and has none, so every
1827/// extraction in a job failed with "Cannot open: Function not implemented" --
1828/// and the runner unpacks every action it downloads with `tar`. Measured on
1829/// Ubuntu 26.04 with systemd 259. What the directive guarded against is
1830/// covered without it: `NoNewPrivileges=yes` makes a set-user-ID bit inert for
1831/// everything this unit starts, and the only places it can write are `root`'s
1832/// own `0700` directories and its private `/tmp`, which no other account can
1833/// reach to execute a file left there.
1834const SYSTEMD_HARDENING: [&str; 12] = [
1835    "NoNewPrivileges=yes",
1836    "CapabilityBoundingSet=",
1837    "AmbientCapabilities=",
1838    "PrivateTmp=yes",
1839    "PrivateDevices=yes",
1840    "ProtectSystem=strict",
1841    "ProtectKernelTunables=yes",
1842    "ProtectKernelModules=yes",
1843    "ProtectControlGroups=yes",
1844    "RestrictNamespaces=yes",
1845    "RestrictRealtime=yes",
1846    "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1847];
1848
1849/// The capabilities a boot unit's daemon keeps, all for one purpose: running
1850/// its runners as accounts of a [`crate::runner_account::RunnerPool`] rather than as
1851/// itself.
1852///
1853/// - `CAP_SETUID`, `CAP_SETGID`: to start a runner as that account.
1854/// - `CAP_KILL`: to signal the runner's processes, which are no longer its own.
1855/// - `CAP_CHOWN`: to hand the runner its attempt directory and caches.
1856/// - `CAP_DAC_OVERRIDE`, `CAP_FOWNER`: to clean up what the runner left, and
1857///   to prune caches, though the runner's account owns those files.
1858///
1859/// None reaches a job. Moving from `root` to another uid clears the permitted
1860/// and effective sets, the ambient set is empty, and `NoNewPrivileges=yes`
1861/// stops a set-user-ID or file-capability binary from granting any back.
1862pub const BOOT_CAPABILITIES: &str = boot_capabilities!();
1863
1864/// The one spelling of [`BOOT_CAPABILITIES`], so that the directive the unit
1865/// carries and the list the review checks it against cannot drift apart.
1866macro_rules! boot_capabilities {
1867    () => {
1868        "CAP_CHOWN CAP_DAC_OVERRIDE CAP_FOWNER CAP_KILL CAP_SETGID CAP_SETUID"
1869    };
1870}
1871use boot_capabilities;
1872
1873/// The hardening directives a unit started in `mode` carries.
1874///
1875/// A boot unit keeps [`BOOT_CAPABILITIES`] for its daemon, and hides every
1876/// home directory behind an empty `tmpfs`: its
1877/// data lives outside them, and a workflow running as `root` has no business
1878/// reading the files there that DAC leaves world-readable. `tmpfs` rather than
1879/// `yes`, because tools such as `git` treat an *unreadable* `$HOME/.gitconfig`
1880/// as fatal and an absent one as nothing.
1881fn systemd_hardening(mode: StartMode) -> Vec<&'static str> {
1882    let mut directives = SYSTEMD_HARDENING.to_vec();
1883    if mode == StartMode::Boot {
1884        if let Some(directive) = directives
1885            .iter_mut()
1886            .find(|directive| **directive == "CapabilityBoundingSet=")
1887        {
1888            *directive = concat!("CapabilityBoundingSet=", boot_capabilities!());
1889        }
1890        directives.push("ProtectHome=tmpfs");
1891    }
1892    directives
1893}
1894
1895/// `InaccessiblePaths=` entries for `plan`'s protected paths, each prefixed
1896/// with `-` so that a path that does not exist yet is not an error.
1897fn inaccessible_entries(plan: &InstallPlan) -> Vec<String> {
1898    plan.protected_paths()
1899        .iter()
1900        .map(|path| quote_argument(&format!("-{}", path.to_string_lossy())))
1901        .collect()
1902}
1903
1904/// Host paths no runner or job may reach, which the operator names with
1905/// `host protect-path`.
1906///
1907/// On Linux the systemd unit lists them in `InaccessiblePaths=`, so inside the
1908/// unit the kernel replaces each with a node of mode `000`: no runner or job,
1909/// in any pool account, reaches it, whatever its own permissions. Measured on
1910/// Ubuntu 26.04: `rm-runner-1` reads such a file on the host and gets
1911/// `Permission denied` inside the unit. The
1912/// list is read when the service is registered, so a change takes effect when
1913/// it is registered again. Windows and macOS have no equivalent the installer
1914/// can set, so there it is refused rather than written and silently ignored.
1915#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
1916#[serde(deny_unknown_fields)]
1917pub struct ProtectedPaths {
1918    #[serde(default)]
1919    pub paths: Vec<PathBuf>,
1920}
1921
1922impl ProtectedPaths {
1923    /// The file in the configuration directory.
1924    pub const FILE: &'static str = "protected-paths.json";
1925
1926    /// Whether this platform can enforce protected paths.
1927    #[must_use]
1928    pub const fn supported() -> bool {
1929        cfg!(all(unix, not(target_os = "macos")))
1930    }
1931
1932    /// The list in `config_dir`, empty when there is none.
1933    ///
1934    /// # Errors
1935    ///
1936    /// When the file cannot be read or does not parse.
1937    pub fn load(config_dir: &Path) -> std::io::Result<Self> {
1938        crate::settings_file::load(&config_dir.join(Self::FILE))
1939    }
1940
1941    /// Writes the list into `config_dir`.
1942    ///
1943    /// # Errors
1944    ///
1945    /// When the file cannot be written.
1946    pub fn save(&self, config_dir: &Path) -> std::io::Result<()> {
1947        crate::settings_file::save(&config_dir.join(Self::FILE), self)
1948    }
1949
1950    /// Why `path` cannot be protected, or `None` when it can.
1951    ///
1952    /// The paths every job needs to run at all, and the service's own
1953    /// directories, are refused: hiding them would stop every runner rather
1954    /// than keep jobs out of something.
1955    #[must_use]
1956    pub fn refusal(path: &Path, own: &[PathBuf]) -> Option<String> {
1957        const SYSTEM: [&str; 16] = [
1958            "/", "/bin", "/boot", "/dev", "/etc", "/lib", "/lib32", "/lib64", "/opt", "/proc",
1959            "/run", "/sbin", "/sys", "/tmp", "/usr", "/var",
1960        ];
1961        if !path.is_absolute()
1962            || path
1963                .components()
1964                .any(|component| matches!(component, std::path::Component::ParentDir))
1965        {
1966            return Some(format!(
1967                "{} is not an absolute path without `..`",
1968                path.display()
1969            ));
1970        }
1971        if SYSTEM.iter().any(|system| path == Path::new(system)) {
1972            return Some(format!(
1973                "{} is one every job needs to run; protect a directory inside it instead",
1974                path.display()
1975            ));
1976        }
1977        if let Some(needed) = own
1978            .iter()
1979            .find(|needed| needed.starts_with(path) || path.starts_with(needed))
1980        {
1981            return Some(format!(
1982                "{} holds or lies inside {}, which the service itself uses",
1983                path.display(),
1984                needed.display()
1985            ));
1986        }
1987        None
1988    }
1989}
1990
1991/// The trees `ProtectHome=` hides, which a boot unit's directories must
1992/// therefore lie outside, or be bound back into ([`systemd_unit`]).
1993const SYSTEMD_HOME_ROOTS: [&str; 3] = ["/home", "/root", "/run/user"];
1994
1995/// The home tree `path` lies under, which `ProtectHome=` hides.
1996fn home_root_of(path: &Path) -> Option<&'static str> {
1997    SYSTEMD_HOME_ROOTS
1998        .iter()
1999        .copied()
2000        .find(|root| path.starts_with(root))
2001}
2002
2003/// The first drop-in in `directory` whose text names one of `trees`, leaving
2004/// out the ones runner-manager writes itself (they say `Managed by
2005/// runner-manager`).
2006#[cfg(any(test, target_os = "linux"))]
2007fn operator_drop_in_naming(directory: &Path, trees: &[&PathBuf]) -> Option<PathBuf> {
2008    let mut drop_ins: Vec<PathBuf> = std::fs::read_dir(directory)
2009        .ok()?
2010        .flatten()
2011        .map(|entry| entry.path())
2012        .filter(|path| path.extension() == Some("conf".as_ref()))
2013        .collect();
2014    drop_ins.sort();
2015    drop_ins.into_iter().find(|path| {
2016        std::fs::read_to_string(path).is_ok_and(|text| {
2017            !text.contains("Managed by runner-manager")
2018                && trees
2019                    .iter()
2020                    .any(|tree| text.contains(tree.to_string_lossy().as_ref()))
2021        })
2022    })
2023}
2024
2025/// Renames a non-empty directory at `path` to `<name>.before-move-<stamp>`
2026/// beside it, leaving nothing at `path`.
2027#[cfg(any(all(test, unix), target_os = "linux"))]
2028fn set_aside(path: &Path, stamp: &str) -> std::io::Result<()> {
2029    let occupied = match std::fs::read_dir(path) {
2030        Ok(mut entries) => entries.next().is_some(),
2031        Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
2032        Err(error) => return Err(error),
2033    };
2034    if occupied {
2035        let name = path.file_name().map_or_else(
2036            || std::borrow::Cow::Borrowed("directory"),
2037            |name| name.to_string_lossy(),
2038        );
2039        std::fs::rename(
2040            path,
2041            path.with_file_name(format!("{name}.before-move-{stamp}")),
2042        )?;
2043    }
2044    Ok(())
2045}
2046
2047/// Copies the tree at `from` into `to`, except a top-level entry named `skip`.
2048/// Directories are created `0700`, files keep their mode, and symbolic links
2049/// are copied as links rather than followed.
2050#[cfg(any(all(test, unix), target_os = "linux"))]
2051fn copy_tree(from: &Path, to: &Path, skip: Option<&str>) -> std::io::Result<()> {
2052    use std::os::unix::fs::DirBuilderExt as _;
2053
2054    std::fs::DirBuilder::new()
2055        .recursive(true)
2056        .mode(0o700)
2057        .create(to)?;
2058    for entry in std::fs::read_dir(from)? {
2059        let entry = entry?;
2060        if skip.is_some_and(|skip| entry.file_name() == skip) {
2061            continue;
2062        }
2063        let source = entry.path();
2064        let target = to.join(entry.file_name());
2065        let kind = entry.file_type()?;
2066        if kind.is_symlink() {
2067            std::os::unix::fs::symlink(std::fs::read_link(&source)?, &target)?;
2068        } else if kind.is_dir() {
2069            copy_tree(&source, &target, None)?;
2070        } else {
2071            std::fs::copy(&source, &target)?;
2072        }
2073    }
2074    Ok(())
2075}
2076
2077/// Why a boot unit could not use `path`, owned by `owner`, or `None` when it
2078/// can.
2079///
2080/// A boot unit runs as `root` with no capability at all, so a directory
2081/// another account owns is one it cannot open, and one under a home tree is
2082/// one its `ProtectHome=` hides.
2083#[cfg(any(test, all(unix, not(target_os = "macos"))))]
2084fn boot_directory_refusal(path: &Path, owner: Option<u32>) -> Option<String> {
2085    if let Some(root) = home_root_of(path) {
2086        return Some(format!(
2087            "{} is under {root}, which a boot service cannot see",
2088            path.display()
2089        ));
2090    }
2091    match owner {
2092        Some(0) => None,
2093        Some(uid) => Some(format!(
2094            "{} belongs to uid {uid}, and a boot service runs as root with no capability \
2095             to open another account's directory",
2096            path.display()
2097        )),
2098        None => Some(format!("{} cannot be inspected", path.display())),
2099    }
2100}
2101
2102// -- launchd -----------------------------------------------------------------
2103
2104/// The `ProcessType` every rendered plist carries.
2105///
2106/// # Why not `Background`
2107///
2108/// launchd applies a job's `ProcessType` to the whole process tree it starts,
2109/// not only to the daemon. Under `Background` every runner the daemon launched
2110/// ran at base priority 4, on efficiency cores with throttled I/O, so each job
2111/// took 4 to 20 times as long as on a classic runner.
2112///
2113/// A child cannot leave it. This is the process type launchd chose at spawn
2114/// (`spawn type = background (5)` in `launchctl print`), not the
2115/// `PRIO_DARWIN_BG` flag `setpriority` controls: measured on macOS 27, a child
2116/// of a `Background` agent reads `getpriority(PRIO_DARWIN_PROCESS) == 0`, and
2117/// `setpriority(PRIO_DARWIN_PROCESS, 0, 0)` returns success and leaves it at
2118/// 4, before `exec` and after it alike. 0.4.31 tried exactly that.
2119///
2120/// The measured alternatives, as the base priority of a runner the agent
2121/// starts: `Background` 4, `Adaptive` 4, `Standard` (the default when the key
2122/// is absent) 20, `Interactive` 31 (the same as a classic runner, and the value
2123/// GitHub's own runner service template uses). The daemon itself mostly waits,
2124/// so it costs nothing to run it at that priority too.
2125pub const LAUNCHD_PROCESS_TYPE: &str = "Interactive";
2126
2127/// Renders the launchd property list for this plan.
2128///
2129/// `KeepAlive` is a dictionary rather than `<true/>` on purpose: the
2130/// requirement is *restart on failure*, and a bare `KeepAlive` also restarts a
2131/// job that exited cleanly, which would turn a deliberate `service stop` into a
2132/// fight with launchd.
2133#[must_use]
2134pub fn launchd_plist(plan: &InstallPlan) -> String {
2135    let identity = plan.identity();
2136    let directories = plan.directories();
2137    let mut out = String::new();
2138    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
2139    out.push_str(
2140        "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
2141         \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
2142    );
2143    out.push_str("<plist version=\"1.0\">\n<dict>\n");
2144    out.push_str(&plist_string("Label", &identity.launchd_label()));
2145
2146    out.push_str("  <key>ProgramArguments</key>\n  <array>\n");
2147    out.push_str(&format!(
2148        "    <string>{}</string>\n",
2149        xml_escape(&plan.binary().to_string_lossy())
2150    ));
2151    for argument in plan.arguments() {
2152        out.push_str(&format!(
2153            "    <string>{}</string>\n",
2154            xml_escape(&argument.to_string_lossy())
2155        ));
2156    }
2157    out.push_str("  </array>\n");
2158
2159    out.push_str("  <key>RunAtLoad</key>\n  <true/>\n");
2160    out.push_str("  <key>KeepAlive</key>\n  <dict>\n");
2161    out.push_str("    <key>SuccessfulExit</key>\n    <false/>\n");
2162    out.push_str("  </dict>\n");
2163    out.push_str(&format!(
2164        "  <key>ThrottleInterval</key>\n  <integer>{}</integer>\n",
2165        plan.restart().delay().as_secs()
2166    ));
2167    out.push_str(&plist_string("ProcessType", LAUNCHD_PROCESS_TYPE));
2168    out.push_str(&plist_string(
2169        "WorkingDirectory",
2170        &directories.state.to_string_lossy(),
2171    ));
2172    out.push_str(&plist_string(
2173        "StandardOutPath",
2174        &directories
2175            .logs
2176            .join("runner-manager.launchd.out.log")
2177            .to_string_lossy(),
2178    ));
2179    out.push_str(&plist_string(
2180        "StandardErrorPath",
2181        &directories
2182            .logs
2183            .join("runner-manager.launchd.err.log")
2184            .to_string_lossy(),
2185    ));
2186
2187    match plan.start_mode() {
2188        StartMode::Boot => {
2189            // A LaunchDaemon runs as root unless told otherwise, and here it
2190            // must: the System Keychain's master key is root-only.
2191            out.push_str(&plist_string(
2192                "UserName",
2193                ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
2194                    .as_str(),
2195            ));
2196            // A daemon has no user session and must not be given one.
2197            out.push_str("  <key>SessionCreate</key>\n  <false/>\n");
2198        }
2199        StartMode::Login => {
2200            // A LaunchAgent already runs as the operator. Naming a `UserName`
2201            // here would be asking launchd for an account switch a login-mode
2202            // registration has no reason to want.
2203        }
2204    }
2205
2206    out.push_str("</dict>\n</plist>\n");
2207    out
2208}
2209
2210/// One `<key>`/`<string>` pair, indented as the rest of the plist is.
2211fn plist_string(key: &str, value: &str) -> String {
2212    format!(
2213        "  <key>{}</key>\n  <string>{}</string>\n",
2214        xml_escape(key),
2215        xml_escape(value)
2216    )
2217}
2218
2219// -- Windows Task Scheduler --------------------------------------------------
2220
2221/// The account a Task Scheduler task runs as.
2222///
2223/// Task Scheduler, unlike the other three managers, needs the principal spelled
2224/// out in the document. Isolating the one impure step keeps
2225/// [`windows_scheduled_task_xml`] a pure function that every CI leg can assert.
2226#[derive(Debug, Clone, PartialEq, Eq)]
2227pub struct TaskPrincipal {
2228    user_id: String,
2229}
2230
2231impl TaskPrincipal {
2232    /// The account running this call, as `DOMAIN\user`.
2233    ///
2234    /// # Errors
2235    ///
2236    /// [`ServiceError::BinaryPath`] is not the right shape here, so this
2237    /// reports [`ServiceError::Control`] naming what the environment failed to
2238    /// say.
2239    pub fn current() -> Result<Self, ServiceError> {
2240        let user = std::env::var("USERNAME")
2241            .ok()
2242            .filter(|value| !value.trim().is_empty());
2243        let Some(user) = user else {
2244            return Err(ServiceError::Control {
2245                operation: "identify the account for",
2246                name: SERVICE_NAME.to_string(),
2247                manager: "Windows Task Scheduler",
2248                detail: "this session reports no %USERNAME%, so there is no principal to \
2249                         register a logon-triggered task for"
2250                    .to_string(),
2251            });
2252        };
2253        let domain = std::env::var("USERDOMAIN")
2254            .ok()
2255            .filter(|value| !value.trim().is_empty());
2256        Ok(Self {
2257            user_id: match domain {
2258                Some(domain) => format!("{domain}\\{user}"),
2259                None => user,
2260            },
2261        })
2262    }
2263
2264    /// A named principal, for tests and for a caller that already knows.
2265    #[must_use]
2266    pub fn named(user_id: impl Into<String>) -> Self {
2267        Self {
2268            user_id: user_id.into(),
2269        }
2270    }
2271
2272    /// `DOMAIN\user`.
2273    #[must_use]
2274    pub fn user_id(&self) -> &str {
2275        &self.user_id
2276    }
2277}
2278
2279/// Renders the Task Scheduler document for a `--start-at login` registration.
2280///
2281/// `RunLevel` is `LeastPrivilege`, which is the whole of Windows' answer to
2282/// item 2 in this domain: the task runs with the operator's ordinary filtered
2283/// token and never with an elevated one, whatever the operator's group
2284/// membership.
2285#[must_use]
2286pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
2287    let identity = plan.identity();
2288    let user = xml_escape(principal.user_id());
2289    let arguments = plan
2290        .arguments()
2291        .iter()
2292        .map(|argument| quote_argument(&argument.to_string_lossy()))
2293        .collect::<Vec<_>>()
2294        .join(" ");
2295    let mut out = String::new();
2296    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
2297    out.push_str(
2298        "<Task version=\"1.4\" \
2299         xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
2300    );
2301    out.push_str("  <RegistrationInfo>\n");
2302    out.push_str(&format!(
2303        "    <Description>{}</Description>\n",
2304        xml_escape(identity.description())
2305    ));
2306    out.push_str(&format!(
2307        "    <URI>\\{}</URI>\n",
2308        xml_escape(identity.name())
2309    ));
2310    out.push_str("  </RegistrationInfo>\n");
2311
2312    out.push_str("  <Triggers>\n    <LogonTrigger>\n");
2313    out.push_str("      <Enabled>true</Enabled>\n");
2314    out.push_str(&format!("      <UserId>{user}</UserId>\n"));
2315    out.push_str("    </LogonTrigger>\n  </Triggers>\n");
2316
2317    out.push_str("  <Principals>\n    <Principal id=\"Author\">\n");
2318    out.push_str(&format!("      <UserId>{user}</UserId>\n"));
2319    out.push_str("      <LogonType>InteractiveToken</LogonType>\n");
2320    out.push_str("      <RunLevel>LeastPrivilege</RunLevel>\n");
2321    out.push_str("    </Principal>\n  </Principals>\n");
2322
2323    out.push_str("  <Settings>\n");
2324    // One agent per host is `d1`'s lock; saying so here means Task Scheduler
2325    // refuses the second start rather than starting a process that then loses
2326    // the race and exits.
2327    out.push_str("    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
2328    out.push_str("    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
2329    out.push_str("    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
2330    out.push_str("    <AllowHardTerminate>true</AllowHardTerminate>\n");
2331    out.push_str("    <StartWhenAvailable>true</StartWhenAvailable>\n");
2332    out.push_str("    <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
2333    out.push_str("    <IdleSettings>\n");
2334    out.push_str("      <StopOnIdleEnd>false</StopOnIdleEnd>\n");
2335    out.push_str("      <RestartOnIdle>false</RestartOnIdle>\n");
2336    out.push_str("    </IdleSettings>\n");
2337    out.push_str("    <AllowStartOnDemand>true</AllowStartOnDemand>\n");
2338    out.push_str("    <Enabled>true</Enabled>\n");
2339    out.push_str("    <Hidden>false</Hidden>\n");
2340    out.push_str("    <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
2341    out.push_str("    <WakeToRun>false</WakeToRun>\n");
2342    // A daemon has no natural end, so any limit here would be a scheduled kill.
2343    out.push_str("    <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
2344    out.push_str("    <Priority>7</Priority>\n");
2345    out.push_str("    <RestartOnFailure>\n");
2346    out.push_str(&format!(
2347        "      <Interval>{}</Interval>\n",
2348        iso8601_minutes(
2349            plan.restart()
2350                .effective_delay(DefinitionKind::WindowsScheduledTask)
2351        )
2352    ));
2353    out.push_str(&format!("      <Count>{START_LIMIT_BURST}</Count>\n"));
2354    out.push_str("    </RestartOnFailure>\n");
2355    out.push_str("  </Settings>\n");
2356
2357    out.push_str("  <Actions Context=\"Author\">\n    <Exec>\n");
2358    out.push_str(&format!(
2359        "      <Command>{}</Command>\n",
2360        xml_escape(&plan.binary().to_string_lossy())
2361    ));
2362    if !arguments.is_empty() {
2363        out.push_str(&format!(
2364            "      <Arguments>{}</Arguments>\n",
2365            xml_escape(&arguments)
2366        ));
2367    }
2368    out.push_str(&format!(
2369        "      <WorkingDirectory>{}</WorkingDirectory>\n",
2370        xml_escape(&plan.directories().state.to_string_lossy())
2371    ));
2372    out.push_str("    </Exec>\n  </Actions>\n");
2373    out.push_str("</Task>\n");
2374    out
2375}
2376
2377/// `PT<n>M`, which is the only shape Task Scheduler accepts for a restart
2378/// interval. See [`RestartPolicy::effective_delay`].
2379fn iso8601_minutes(duration: Duration) -> String {
2380    format!("PT{}M", duration.as_secs() / 60)
2381}
2382
2383/// Escapes the five XML entities. Applied to every value that reaches a plist
2384/// or a task document, because a Windows account name may legitimately contain
2385/// `&` and a path may contain `<`.
2386///
2387/// `pub(crate)` rather than private because [`crate::wsl::task`] renders a
2388/// second kind of Task Scheduler document and must escape it the same way; two
2389/// escapers in one crate is exactly how one of them ends up subtly different.
2390pub(crate) fn xml_escape(value: &str) -> String {
2391    let mut out = String::with_capacity(value.len());
2392    for c in value.chars() {
2393        match c {
2394            '&' => out.push_str("&amp;"),
2395            '<' => out.push_str("&lt;"),
2396            '>' => out.push_str("&gt;"),
2397            '"' => out.push_str("&quot;"),
2398            '\'' => out.push_str("&apos;"),
2399            other => out.push(other),
2400        }
2401    }
2402    out
2403}
2404
2405/// The inverse of [`xml_escape`], for reading a definition back off disk.
2406///
2407/// `&amp;` is replaced last, which is the whole subtlety: replacing it first
2408/// would turn a literal `&amp;amp;` into `&` in two passes instead of `&amp;`
2409/// in one.
2410fn xml_unescape(value: &str) -> String {
2411    value
2412        .replace("&lt;", "<")
2413        .replace("&gt;", ">")
2414        .replace("&quot;", "\"")
2415        .replace("&apos;", "'")
2416        .replace("&amp;", "&")
2417}
2418
2419// -- Windows Service Control Manager -----------------------------------------
2420
2421/// How `CreateServiceW` should be called, in a form that can be reviewed,
2422/// asserted, and printed on any platform.
2423///
2424/// The Windows backend builds its `ServiceInfo` from **this** value rather than
2425/// from the plan, so the descriptor a Linux CI leg asserts and the parameters a
2426/// Windows host registers cannot drift apart.
2427#[derive(Debug, Clone, PartialEq, Eq)]
2428pub struct WindowsServiceSpec {
2429    /// The service name.
2430    pub name: String,
2431    /// What `services.msc` shows.
2432    pub display_name: String,
2433    /// The one-line description.
2434    pub description: String,
2435    /// `true` for `SERVICE_AUTO_START`, `false` for `SERVICE_DEMAND_START`.
2436    pub automatic_start: bool,
2437    /// The account, or `None` for `LocalSystem`, which is what
2438    /// `CreateServiceW` takes a null `lpServiceStartName` to mean.
2439    pub account: Option<String>,
2440    /// The full launch command, quoted.
2441    pub command_line: String,
2442    /// The restart-on-failure policy.
2443    pub restart: RestartPolicy,
2444}
2445
2446/// Derives the Windows service parameters from a plan.
2447#[must_use]
2448pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
2449    WindowsServiceSpec {
2450        name: plan.identity().name().to_string(),
2451        display_name: plan.identity().display_name().to_string(),
2452        description: plan.identity().description().to_string(),
2453        // Only a boot-mode registration is a service at all; a login-mode one
2454        // is a scheduled task. So an automatic start is the only kind here, and
2455        // the field exists because the privileged tests register an on-demand
2456        // fixture rather than one that starts with the test machine.
2457        automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
2458        account: match ServiceAccount::for_definition(
2459            DefinitionKind::WindowsService,
2460            plan.start_mode(),
2461        ) {
2462            // `None` means LocalSystem to `CreateServiceW`, and naming it
2463            // explicitly would be one more string to spell right.
2464            ServiceAccount::LocalSystem => None,
2465            other => Some(other.as_str().to_string()),
2466        },
2467        command_line: plan.command_line(),
2468        restart: plan.restart(),
2469    }
2470}
2471
2472/// Renders the descriptor [`review_least_privilege`] reads and `service status`
2473/// can print.
2474#[must_use]
2475fn windows_service_descriptor(plan: &InstallPlan) -> String {
2476    let spec = windows_service_spec(plan);
2477    let mut out = String::new();
2478    out.push_str("[windows-service]\n");
2479    out.push_str(&format!("Name={}\n", spec.name));
2480    out.push_str(&format!("DisplayName={}\n", spec.display_name));
2481    out.push_str(&format!("Description={}\n", spec.description));
2482    // OWN_PROCESS and never INTERACTIVE_PROCESS: an interactive service would
2483    // put a process this product controls on the operator's desktop, which is
2484    // both deprecated by Windows and more than the requirement asks for.
2485    out.push_str("ServiceType=OWN_PROCESS\n");
2486    out.push_str(&format!(
2487        "StartType={}\n",
2488        if spec.automatic_start {
2489            "AutoStart"
2490        } else {
2491            "OnDemand"
2492        }
2493    ));
2494    out.push_str("ErrorControl=Normal\n");
2495    out.push_str(&format!(
2496        "Account={}\n",
2497        spec.account
2498            .as_deref()
2499            .unwrap_or(ServiceAccount::LocalSystem.as_str())
2500    ));
2501    out.push_str(&format!("CommandLine={}\n", spec.command_line));
2502    out.push_str(&format!(
2503        "FailureActionRestartDelaySecs={}\n",
2504        spec.restart.delay().as_secs()
2505    ));
2506    out.push_str(&format!(
2507        "FailureActionsResetPeriodSecs={}\n",
2508        spec.restart.reset_after().as_secs()
2509    ));
2510    // Without this flag the Service Control Manager applies failure actions
2511    // only to a crash, and a daemon that exits non-zero after failing to reach
2512    // GitHub is not a crash.
2513    out.push_str("FailureActionsOnNonCrashFailures=true\n");
2514    out.push_str(&format!(
2515        "ReadWritePaths={}\n",
2516        plan.directories()
2517            .all()
2518            .iter()
2519            .map(|path| quote_argument(&path.to_string_lossy()))
2520            .collect::<Vec<_>>()
2521            .join(" ")
2522    ));
2523    out
2524}
2525
2526// ---------------------------------------------------------------------------
2527// The least-privilege review
2528// ---------------------------------------------------------------------------
2529
2530/// Whether a finding is about too much authority or too little.
2531#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2532pub enum FindingKind {
2533    /// The definition grants more than `05-infrastructure.md` item 2 asks for.
2534    /// This is what makes a review fail.
2535    Excess,
2536    /// The definition does not grant something the daemon needs, or does not
2537    /// state something the review cannot verify without.
2538    Shortfall,
2539}
2540
2541impl fmt::Display for FindingKind {
2542    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2543        f.write_str(match self {
2544            Self::Excess => "excess",
2545            Self::Shortfall => "shortfall",
2546        })
2547    }
2548}
2549
2550/// One thing the review has to say about a definition.
2551#[derive(Debug, Clone, PartialEq, Eq)]
2552pub struct PrivilegeFinding {
2553    /// Excess or shortfall.
2554    pub kind: FindingKind,
2555    /// The directive, key, or element it is about.
2556    pub subject: String,
2557    /// What is wrong with it, in terms an operator can act on.
2558    pub detail: String,
2559}
2560
2561impl fmt::Display for PrivilegeFinding {
2562    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2563        write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2564    }
2565}
2566
2567/// What a definition actually grants, measured against the requirement.
2568///
2569/// `05-infrastructure.md` item 2 and `07-security.md`'s release gate
2570/// (*"Service account permissions are documented and verified least
2571/// privilege"*) are the same requirement stated twice, and this is the
2572/// verification half. The documentation half is `docs/service-account.md`.
2573///
2574/// The review reads the **rendered text**, not the plan it came from. That is
2575/// deliberate and it is the only version of this check worth having: a review
2576/// that re-derived its expectations from the same values the renderer used
2577/// would agree with the renderer by construction and could never fail. Reading
2578/// the text means a hand-edited unit on a real host is reviewed as it is, and
2579/// means a test can widen a definition and watch this say so.
2580#[derive(Debug, Clone, PartialEq, Eq)]
2581pub struct PrivilegeReview {
2582    kind: DefinitionKind,
2583    account: ServiceAccount,
2584    controls: Vec<String>,
2585    findings: Vec<PrivilegeFinding>,
2586}
2587
2588impl PrivilegeReview {
2589    /// Whether the definition grants nothing beyond the requirement.
2590    #[must_use]
2591    pub fn is_least_privilege(&self) -> bool {
2592        !self
2593            .findings
2594            .iter()
2595            .any(|finding| finding.kind == FindingKind::Excess)
2596    }
2597
2598    /// Everything the review found, excesses and shortfalls together.
2599    #[must_use]
2600    pub fn findings(&self) -> &[PrivilegeFinding] {
2601        &self.findings
2602    }
2603
2604    /// Only the excesses — the findings that make [`Self::is_least_privilege`]
2605    /// false.
2606    #[must_use]
2607    pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2608        self.findings
2609            .iter()
2610            .filter(|finding| finding.kind == FindingKind::Excess)
2611            .collect()
2612    }
2613
2614    /// The controls the definition was confirmed to carry.
2615    ///
2616    /// Present so that a passing review says *what it checked* rather than only
2617    /// that it passed. A check that reports nothing when it succeeds is
2618    /// indistinguishable from a check that did not run.
2619    #[must_use]
2620    pub fn controls(&self) -> &[String] {
2621        &self.controls
2622    }
2623
2624    /// The account the registration runs under, and why it is the minimum.
2625    #[must_use]
2626    pub const fn account(&self) -> &ServiceAccount {
2627        &self.account
2628    }
2629
2630    /// Which platform's definition was reviewed.
2631    #[must_use]
2632    pub const fn kind(&self) -> DefinitionKind {
2633        self.kind
2634    }
2635}
2636
2637impl fmt::Display for PrivilegeReview {
2638    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2639        writeln!(
2640            f,
2641            "{} runs as {} ({})",
2642            self.kind,
2643            self.account,
2644            self.account.justification()
2645        )?;
2646        for control in &self.controls {
2647            writeln!(f, "  confirmed  {control}")?;
2648        }
2649        for finding in &self.findings {
2650            writeln!(f, "  {finding}")?;
2651        }
2652        if self.is_least_privilege() {
2653            write!(f, "  verdict    least privilege")
2654        } else {
2655            write!(
2656                f,
2657                "  verdict    NOT least privilege: {} excess(es)",
2658                self.excesses().len()
2659            )
2660        }
2661    }
2662}
2663
2664/// Reads a definition back and reports what it grants.
2665///
2666/// `plan` supplies only the two things the text cannot: the four directories
2667/// the registration is *allowed* to write, and the start mode, which decides
2668/// which account is the minimum.
2669#[must_use]
2670pub fn review_least_privilege(
2671    definition: &ServiceDefinition,
2672    plan: &InstallPlan,
2673) -> PrivilegeReview {
2674    let mut controls = Vec::new();
2675    let mut findings = Vec::new();
2676    match definition.kind() {
2677        DefinitionKind::SystemdUnit => {
2678            review_systemd(definition.text(), plan, &mut controls, &mut findings);
2679        }
2680        DefinitionKind::LaunchdPlist => {
2681            review_launchd(definition.text(), plan, &mut controls, &mut findings);
2682        }
2683        DefinitionKind::WindowsScheduledTask => {
2684            review_scheduled_task(definition.text(), &mut controls, &mut findings);
2685        }
2686        DefinitionKind::WindowsService => {
2687            review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2688        }
2689    }
2690    PrivilegeReview {
2691        kind: definition.kind(),
2692        // The definition's account, not the plan's: a Linux CI leg reviewing the
2693        // Windows descriptor must report `LocalSystem`, which is the account
2694        // that descriptor registers, and not `root`, which is the account this
2695        // leg's own host would use.
2696        account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2697        controls,
2698        findings,
2699    }
2700}
2701
2702/// Every writable path a definition may name, as the strings it names them by.
2703fn permitted_paths(kind: DefinitionKind, plan: &InstallPlan) -> Vec<String> {
2704    let mut permitted = plan
2705        .directories()
2706        .all()
2707        .iter()
2708        .map(|path| path.to_string_lossy().into_owned())
2709        .collect::<Vec<_>>();
2710    if kind == DefinitionKind::SystemdUnit
2711        && let Some(secret_directory) = plan.secret_guard().and_then(Path::parent)
2712    {
2713        permitted.push(secret_directory.to_string_lossy().into_owned());
2714    }
2715    permitted
2716}
2717
2718/// Whether two path spellings name the same place on **this host's**
2719/// filesystem. Case-insensitive on Windows, exact elsewhere.
2720///
2721/// For a rendered definition use [`same_path_for`] instead: which comparison is
2722/// right there follows the platform the definition is *for*, and every leg of
2723/// the CI matrix reviews all four.
2724fn same_path_text(left: &str, right: &str) -> bool {
2725    if cfg!(windows) {
2726        left.eq_ignore_ascii_case(right)
2727    } else {
2728        left == right
2729    }
2730}
2731
2732/// Whether two path spellings name the same place on the platform a given
2733/// definition targets.
2734fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
2735    match kind {
2736        DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
2737            left.eq_ignore_ascii_case(right)
2738        }
2739        DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
2740    }
2741}
2742
2743/// Checks a `ReadWritePaths`-style list against the permitted directories.
2744fn review_writable_paths(
2745    kind: DefinitionKind,
2746    subject: &str,
2747    listed: &[String],
2748    plan: &InstallPlan,
2749    controls: &mut Vec<String>,
2750    findings: &mut Vec<PrivilegeFinding>,
2751) {
2752    let permitted = permitted_paths(kind, plan);
2753    for entry in listed {
2754        if !permitted
2755            .iter()
2756            .any(|allowed| same_path_for(kind, allowed, entry))
2757        {
2758            findings.push(PrivilegeFinding {
2759                kind: FindingKind::Excess,
2760                subject: subject.to_string(),
2761                detail: format!(
2762                    "{entry} is writable but is not one of this registration's required paths"
2763                ),
2764            });
2765        }
2766    }
2767    for allowed in &permitted {
2768        if !listed
2769            .iter()
2770            .any(|entry| same_path_for(kind, allowed, entry))
2771        {
2772            findings.push(PrivilegeFinding {
2773                kind: FindingKind::Shortfall,
2774                subject: subject.to_string(),
2775                detail: format!(
2776                    "{allowed} is one of this registration's directories but is not writable, \
2777                     so the daemon cannot use it"
2778                ),
2779            });
2780        }
2781    }
2782    if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
2783        controls.push(format!(
2784            "{subject} names exactly the required application-data and credential paths"
2785        ));
2786    }
2787}
2788
2789/// Compares a unit's capability bounding set with the one it should keep, as
2790/// sets: a capability it lacks is a shortfall, one it adds is an excess.
2791fn review_capabilities(
2792    expected: &str,
2793    wanted: &str,
2794    actual: &str,
2795    controls: &mut Vec<String>,
2796    findings: &mut Vec<PrivilegeFinding>,
2797) {
2798    let subject = "CapabilityBoundingSet";
2799    // `~` inverts the list, keeping every capability *except* those named.
2800    if actual.trim_start().starts_with('~') {
2801        findings.push(PrivilegeFinding {
2802            kind: FindingKind::Excess,
2803            subject: subject.to_string(),
2804            detail: format!("is `{actual}`, which keeps every capability it does not name"),
2805        });
2806        return;
2807    }
2808    let wanted: BTreeSet<&str> = wanted.split_whitespace().collect();
2809    let actual: BTreeSet<&str> = actual.split_whitespace().collect();
2810    let extra = actual.difference(&wanted).copied().collect::<Vec<_>>();
2811    let missing = wanted.difference(&actual).copied().collect::<Vec<_>>();
2812    if !extra.is_empty() {
2813        findings.push(PrivilegeFinding {
2814            kind: FindingKind::Excess,
2815            subject: subject.to_string(),
2816            detail: format!(
2817                "keeps {}, which the requirement does not ask for",
2818                extra.join(" ")
2819            ),
2820        });
2821    }
2822    if !missing.is_empty() {
2823        findings.push(PrivilegeFinding {
2824            kind: FindingKind::Shortfall,
2825            subject: subject.to_string(),
2826            detail: format!("drops {}, which the daemon needs", missing.join(" ")),
2827        });
2828    }
2829    if extra.is_empty() && missing.is_empty() {
2830        controls.push(expected.to_string());
2831    }
2832}
2833
2834/// The one inbound-surface rule, applied to whichever platform names it.
2835///
2836/// `07-security.md` handling rule 2: *"The product exposes no inbound HTTP,
2837/// socket, or RPC surface anywhere, in any command."* A definition that asks the
2838/// service manager to open a socket or publish a Mach service on the daemon's
2839/// behalf would create exactly that surface without a line of product code
2840/// changing, which is why it is checked here and not only in review.
2841fn review_inbound_surface(
2842    text: &str,
2843    markers: &[(&str, &str)],
2844    controls: &mut Vec<String>,
2845    findings: &mut Vec<PrivilegeFinding>,
2846) {
2847    let mut clean = true;
2848    for (marker, detail) in markers {
2849        if text.contains(marker) {
2850            clean = false;
2851            findings.push(PrivilegeFinding {
2852                kind: FindingKind::Excess,
2853                subject: (*marker).to_string(),
2854                detail: (*detail).to_string(),
2855            });
2856        }
2857    }
2858    if clean {
2859        controls.push(
2860            "no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
2861        );
2862    }
2863}
2864
2865fn review_systemd(
2866    text: &str,
2867    plan: &InstallPlan,
2868    controls: &mut Vec<String>,
2869    findings: &mut Vec<PrivilegeFinding>,
2870) {
2871    let directives = ini_directives(text, "Service");
2872    for expected in systemd_hardening(plan.start_mode()) {
2873        let (key, value) = expected
2874            .split_once('=')
2875            .expect("every hardening directive is written as key=value");
2876        match directives.get(key) {
2877            Some(actual) if key == "CapabilityBoundingSet" => {
2878                review_capabilities(expected, value, actual, controls, findings);
2879            }
2880            Some(actual) if actual == value => controls.push((*expected).to_string()),
2881            Some(actual) => findings.push(PrivilegeFinding {
2882                kind: FindingKind::Excess,
2883                subject: key.to_string(),
2884                detail: format!(
2885                    "is `{actual}`, not `{value}`, so the unit keeps authority the \
2886                                 requirement does not ask for"
2887                ),
2888            }),
2889            None => findings.push(PrivilegeFinding {
2890                kind: FindingKind::Excess,
2891                subject: key.to_string(),
2892                detail: format!(
2893                    "is absent, so the unit inherits systemd's default rather than `{value}`"
2894                ),
2895            }),
2896        }
2897    }
2898
2899    match directives.get("ReadWritePaths") {
2900        Some(value) => {
2901            let listed = split_quoted(value);
2902            review_writable_paths(
2903                DefinitionKind::SystemdUnit,
2904                "ReadWritePaths",
2905                &listed,
2906                plan,
2907                controls,
2908                findings,
2909            );
2910        }
2911        None => findings.push(PrivilegeFinding {
2912            kind: FindingKind::Shortfall,
2913            subject: "ReadWritePaths".to_string(),
2914            detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
2915                .to_string(),
2916        }),
2917    }
2918
2919    // `BindPaths=` reopens what `ProtectHome=tmpfs` hides, read-write, and can
2920    // mount any host path anywhere (`source:destination`): it may name only the
2921    // unit's own directories, each bound onto itself.
2922    if let Some(value) = directives.get("BindPaths") {
2923        let permitted = permitted_paths(DefinitionKind::SystemdUnit, plan);
2924        for entry in split_quoted(value) {
2925            if !permitted
2926                .iter()
2927                .any(|allowed| same_path_for(DefinitionKind::SystemdUnit, allowed, &entry))
2928            {
2929                findings.push(PrivilegeFinding {
2930                    kind: FindingKind::Excess,
2931                    subject: "BindPaths".to_string(),
2932                    detail: format!(
2933                        "{entry} is bound into the unit but is not one of this registration's \
2934                         required paths"
2935                    ),
2936                });
2937            }
2938        }
2939    }
2940
2941    // Each path the operator protected must stay inaccessible: a unit that no
2942    // longer lists one has let every job back into it.
2943    if !plan.protected_paths().is_empty() {
2944        let listed = directives
2945            .get("InaccessiblePaths")
2946            .map(|value| split_quoted(value))
2947            .unwrap_or_default();
2948        let missing = plan
2949            .protected_paths()
2950            .iter()
2951            .filter(|path| {
2952                let entry = format!("-{}", path.to_string_lossy());
2953                !listed.contains(&entry)
2954            })
2955            .map(|path| path.display().to_string())
2956            .collect::<Vec<_>>();
2957        if missing.is_empty() {
2958            controls.push("InaccessiblePaths keeps every protected path from every job".into());
2959        } else {
2960            findings.push(PrivilegeFinding {
2961                kind: FindingKind::Excess,
2962                subject: "InaccessiblePaths".to_string(),
2963                detail: format!("no longer hides {}", missing.join(", ")),
2964            });
2965        }
2966    }
2967
2968    // `User=` on a system unit is an escalation only in the other direction, so
2969    // what is checked is the pair systemd actually treats as privileged.
2970    if directives.contains_key("PrivateUsers")
2971        && directives.get("PrivateUsers") == Some(&"no".to_string())
2972    {
2973        findings.push(PrivilegeFinding {
2974            kind: FindingKind::Excess,
2975            subject: "PrivateUsers".to_string(),
2976            detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
2977                .to_string(),
2978        });
2979    }
2980
2981    review_inbound_surface(
2982        text,
2983        &[
2984            (
2985                "ListenStream=",
2986                "asks systemd to open a listening socket for this service, which \
2987                 07-security.md rule 2 forbids the product to have",
2988            ),
2989            (
2990                "ListenDatagram=",
2991                "asks systemd to open a listening socket for this service, which \
2992                 07-security.md rule 2 forbids the product to have",
2993            ),
2994        ],
2995        controls,
2996        findings,
2997    );
2998}
2999
3000fn review_launchd(
3001    text: &str,
3002    plan: &InstallPlan,
3003    controls: &mut Vec<String>,
3004    findings: &mut Vec<PrivilegeFinding>,
3005) {
3006    // A scheduling class is not an authority, so no value here is an excess.
3007    // Anything but `Interactive` is a shortfall: launchd applies it to every
3008    // runner the daemon starts. See `LAUNCHD_PROCESS_TYPE`.
3009    match plist_string_value(text, "ProcessType") {
3010        Some(value) if value == LAUNCHD_PROCESS_TYPE => {
3011            controls.push(format!("ProcessType={LAUNCHD_PROCESS_TYPE}"));
3012        }
3013        found => findings.push(PrivilegeFinding {
3014            kind: FindingKind::Shortfall,
3015            subject: "ProcessType".to_string(),
3016            detail: format!(
3017                "is {}, and launchd applies it to every runner the daemon starts, so their jobs \
3018                 run below normal priority. The daemon rewrites it to `{LAUNCHD_PROCESS_TYPE}` \
3019                 when it next starts with no runner; `service status` says how to restart it",
3020                found.map_or_else(
3021                    || "absent (launchd's `Standard`)".to_string(),
3022                    |value| format!("`{value}`")
3023                )
3024            ),
3025        }),
3026    }
3027
3028    match plan.start_mode() {
3029        StartMode::Boot => {
3030            if plist_bool_value(text, "SessionCreate") == Some(true) {
3031                findings.push(PrivilegeFinding {
3032                    kind: FindingKind::Excess,
3033                    subject: "SessionCreate".to_string(),
3034                    detail: "asks launchd to create a security session for a job that runs \
3035                             outside every login session and has no use for one"
3036                        .to_string(),
3037                });
3038            } else {
3039                controls.push("SessionCreate is not requested".to_string());
3040            }
3041            match plist_string_value(text, "UserName").as_deref() {
3042                Some("root") => {
3043                    controls.push("UserName=root, stated rather than inherited".to_string())
3044                }
3045                Some(other) => findings.push(PrivilegeFinding {
3046                    kind: FindingKind::Shortfall,
3047                    subject: "UserName".to_string(),
3048                    detail: format!(
3049                        "is `{other}`, which cannot unlock the System Keychain: \
3050                         /var/db/SystemKey is root-only, so the daemon would start and then \
3051                         find no credential"
3052                    ),
3053                }),
3054                None => findings.push(PrivilegeFinding {
3055                    kind: FindingKind::Shortfall,
3056                    subject: "UserName".to_string(),
3057                    detail: "is absent, so the account is launchd's implicit default and this \
3058                             review cannot confirm it"
3059                        .to_string(),
3060                }),
3061            }
3062        }
3063        StartMode::Login => {
3064            if let Some(named) = plist_string_value(text, "UserName") {
3065                findings.push(PrivilegeFinding {
3066                    kind: FindingKind::Excess,
3067                    subject: "UserName".to_string(),
3068                    detail: format!(
3069                        "names `{named}` in a LaunchAgent, which already runs as the operator; \
3070                         naming an account here asks launchd for a switch a login-mode \
3071                         registration has no reason to want"
3072                    ),
3073                });
3074            } else {
3075                controls
3076                    .push("no UserName: the agent runs as the operator and no other".to_string());
3077            }
3078        }
3079    }
3080
3081    review_inbound_surface(
3082        text,
3083        &[
3084            (
3085                "<key>Sockets</key>",
3086                "asks launchd to open a socket for this job, which 07-security.md rule 2 \
3087                 forbids the product to have",
3088            ),
3089            (
3090                "<key>MachServices</key>",
3091                "publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
3092                 forbids the product to have",
3093            ),
3094        ],
3095        controls,
3096        findings,
3097    );
3098}
3099
3100fn review_scheduled_task(
3101    text: &str,
3102    controls: &mut Vec<String>,
3103    findings: &mut Vec<PrivilegeFinding>,
3104) {
3105    match xml_value(text, "RunLevel").as_deref() {
3106        Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
3107        Some(other) => findings.push(PrivilegeFinding {
3108            kind: FindingKind::Excess,
3109            subject: "RunLevel".to_string(),
3110            detail: format!(
3111                "is `{other}`, so the task runs with an elevated token whenever the operator is \
3112                 an administrator"
3113            ),
3114        }),
3115        None => findings.push(PrivilegeFinding {
3116            kind: FindingKind::Excess,
3117            subject: "RunLevel".to_string(),
3118            detail: "is absent, so Task Scheduler decides the token rather than the definition"
3119                .to_string(),
3120        }),
3121    }
3122
3123    match xml_value(text, "LogonType").as_deref() {
3124        Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
3125        Some(other) => findings.push(PrivilegeFinding {
3126            kind: FindingKind::Excess,
3127            subject: "LogonType".to_string(),
3128            detail: format!(
3129                "is `{other}`, which asks Windows to store or synthesise a credential for this \
3130                 task; an interactive token needs neither"
3131            ),
3132        }),
3133        None => findings.push(PrivilegeFinding {
3134            kind: FindingKind::Shortfall,
3135            subject: "LogonType".to_string(),
3136            detail: "is absent, so this review cannot confirm that no credential is stored"
3137                .to_string(),
3138        }),
3139    }
3140}
3141
3142fn review_windows_service(
3143    text: &str,
3144    plan: &InstallPlan,
3145    controls: &mut Vec<String>,
3146    findings: &mut Vec<PrivilegeFinding>,
3147) {
3148    let directives = ini_directives(text, "windows-service");
3149
3150    match directives.get("ServiceType").map(String::as_str) {
3151        Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
3152        Some(other) => findings.push(PrivilegeFinding {
3153            kind: FindingKind::Excess,
3154            subject: "ServiceType".to_string(),
3155            detail: format!(
3156                "is `{other}`; an interactive or shared-process service reaches further than a \
3157                 daemon that only talks to GitHub over HTTPS"
3158            ),
3159        }),
3160        None => findings.push(PrivilegeFinding {
3161            kind: FindingKind::Shortfall,
3162            subject: "ServiceType".to_string(),
3163            detail: "is absent, so this review cannot confirm the service is not interactive"
3164                .to_string(),
3165        }),
3166    }
3167
3168    // The account is not a free choice on Windows: `d2`'s DACL decides it. What
3169    // the review can check is that the definition names the one account that
3170    // DACL admits, and no broader one.
3171    match directives.get("Account").map(String::as_str) {
3172        Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
3173            controls.push(format!(
3174                "Account={account}: the only stock account the machine-scoped store's DACL \
3175                 (SY, BA, OW) admits"
3176            ));
3177        }
3178        Some(other) => findings.push(PrivilegeFinding {
3179            kind: FindingKind::Shortfall,
3180            subject: "Account".to_string(),
3181            detail: format!(
3182                "is `{other}`, which the machine-scoped store's DACL does not name, so the \
3183                 daemon would start and then find no credential. Widening that DACL is not this \
3184                 registration's to do: an ACE reaching `{other}` would also reach every other \
3185                 service running under it"
3186            ),
3187        }),
3188        None => findings.push(PrivilegeFinding {
3189            kind: FindingKind::Shortfall,
3190            subject: "Account".to_string(),
3191            detail: "is absent, so this review cannot confirm which account was registered"
3192                .to_string(),
3193        }),
3194    }
3195
3196    match directives.get("ReadWritePaths") {
3197        Some(value) => {
3198            let listed = split_quoted(value);
3199            review_writable_paths(
3200                DefinitionKind::WindowsService,
3201                "ReadWritePaths",
3202                &listed,
3203                plan,
3204                controls,
3205                findings,
3206            );
3207        }
3208        None => findings.push(PrivilegeFinding {
3209            kind: FindingKind::Shortfall,
3210            subject: "ReadWritePaths".to_string(),
3211            detail: "is absent, so the directories the service was installed against are not \
3212                     recorded"
3213                .to_string(),
3214        }),
3215    }
3216}
3217
3218// -- parsing helpers ---------------------------------------------------------
3219
3220/// Reads `key=value` lines out of one `[section]` of an INI-shaped document.
3221///
3222/// systemd's unit format and this module's Windows descriptor are both this
3223/// shape. A repeated key takes its last value, which is systemd's own rule for
3224/// every directive here.
3225fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
3226    let mut out = BTreeMap::new();
3227    let mut inside = false;
3228    for line in text.lines() {
3229        let line = line.trim();
3230        if line.starts_with('[') && line.ends_with(']') {
3231            inside = &line[1..line.len() - 1] == section;
3232            continue;
3233        }
3234        if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
3235            continue;
3236        }
3237        if let Some((key, value)) = line.split_once('=') {
3238            out.insert(key.trim().to_string(), value.trim().to_string());
3239        }
3240    }
3241    out
3242}
3243
3244/// The command line in a systemd `ExecStart=`, without the prefixes systemd
3245/// reads off its front (`-`, `@`, `:`, `+`, `!`, `!!`).
3246///
3247/// A drop-in may carry any of them, and left in place they become part of the
3248/// executable's path. With `@` the second word is the `argv[0]` handed to the
3249/// process rather than an argument, so it goes as well.
3250#[cfg(any(test, all(unix, not(target_os = "macos"))))]
3251fn systemd_exec_command_line(value: &str) -> String {
3252    let value = value.trim();
3253    let command = value.trim_start_matches(['-', '@', ':', '+', '!']);
3254    if !value[..value.len() - command.len()].contains('@') {
3255        return command.to_string();
3256    }
3257    let mut words = split_command_line(command);
3258    if words.len() > 1 {
3259        words.remove(1);
3260    }
3261    words
3262        .iter()
3263        .map(|word| quote_argument(word))
3264        .collect::<Vec<_>>()
3265        .join(" ")
3266}
3267
3268/// Parses a systemd time span such as `15`, `15s`, `500ms`, `1min 30s` or
3269/// `2h`. A bare number is seconds. `None` for `infinity`, an empty value, or
3270/// anything systemd would not accept either.
3271#[cfg(any(test, all(unix, not(target_os = "macos"))))]
3272fn systemd_timespan(value: &str) -> Option<Duration> {
3273    let mut rest = value.trim();
3274    if rest.is_empty() || rest == "infinity" {
3275        return None;
3276    }
3277    let mut seconds = 0f64;
3278    while !rest.is_empty() {
3279        let digits = rest
3280            .find(|c: char| !(c.is_ascii_digit() || c == '.'))
3281            .unwrap_or(rest.len());
3282        if digits == 0 {
3283            return None;
3284        }
3285        let number: f64 = rest[..digits].parse().ok()?;
3286        rest = rest[digits..].trim_start();
3287        let letters = rest
3288            .find(|c: char| !c.is_alphabetic())
3289            .unwrap_or(rest.len());
3290        let scale = match &rest[..letters] {
3291            "" | "s" | "sec" | "second" | "seconds" => 1.0,
3292            "us" | "usec" | "\u{3bc}s" | "\u{b5}s" => 1e-6,
3293            "ms" | "msec" => 1e-3,
3294            "m" | "min" | "minute" | "minutes" => 60.0,
3295            "h" | "hr" | "hour" | "hours" => 3_600.0,
3296            "d" | "day" | "days" => 86_400.0,
3297            "w" | "week" | "weeks" => 604_800.0,
3298            "M" | "month" | "months" => 2_629_800.0,
3299            "y" | "year" | "years" => 31_557_600.0,
3300            _ => return None,
3301        };
3302        seconds += number * scale;
3303        rest = rest[letters..].trim_start();
3304    }
3305    Duration::try_from_secs_f64(seconds).ok()
3306}
3307
3308/// Splits a whitespace-separated list that may contain arguments quoted by
3309/// [`quote_argument`].
3310fn split_quoted(value: &str) -> Vec<String> {
3311    let mut out = Vec::new();
3312    let mut rest = value.trim();
3313    while !rest.is_empty() {
3314        if rest.starts_with('"') {
3315            // `executable_from_command_line` already implements Windows' quoting
3316            // rules; reuse it rather than write a second, subtly different one.
3317            if let Some(parsed) = executable_from_command_line(rest) {
3318                out.push(parsed.to_string_lossy().into_owned());
3319            }
3320            // Advance past the closing quote.
3321            let mut depth = 0usize;
3322            let mut end = rest.len();
3323            for (index, c) in rest.char_indices() {
3324                match c {
3325                    '\\' => depth += 1,
3326                    '"' => {
3327                        if depth.is_multiple_of(2) && index > 0 {
3328                            end = index + 1;
3329                            break;
3330                        }
3331                        depth = 0;
3332                    }
3333                    _ => depth = 0,
3334                }
3335            }
3336            rest = rest[end..].trim_start();
3337        } else {
3338            let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
3339            out.push(rest[..end].to_string());
3340            rest = rest[end..].trim_start();
3341        }
3342    }
3343    out
3344}
3345
3346/// The text between the **first** `<tag>` and its `</tag>`, unescaped, for the
3347/// small, known documents this module renders. Not a general XML parser and
3348/// does not pretend to be one.
3349///
3350/// "First" matters for a document that repeats a tag: a Task Scheduler task has
3351/// an `<Enabled>` in its trigger *and* one in its settings, so a caller after
3352/// the second one passes the slice that starts at `<Settings>`.
3353///
3354/// `pub(crate)` rather than private because [`crate::wsl::task`] reads back a
3355/// second kind of Task Scheduler document; one reader for both is the same
3356/// argument [`xml_escape`] makes for one escaper.
3357pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
3358    let open = format!("<{tag}>");
3359    let close = format!("</{tag}>");
3360    let start = text.find(&open)? + open.len();
3361    let end = text[start..].find(&close)? + start;
3362    Some(xml_unescape(text[start..end].trim()))
3363}
3364
3365/// Whether Task Scheduler will launch a login task automatically.
3366///
3367/// Its `/Query /XML` output normalises away values that equal the schema
3368/// default. In particular, an enabled task commonly has no `<Enabled>` node at
3369/// all, so only an explicit `false` means disabled.
3370#[cfg(any(windows, test))]
3371fn windows_login_task_starts_automatically(document: &str) -> bool {
3372    document.contains("<LogonTrigger>")
3373        && xml_value(document, "Enabled").as_deref() != Some("false")
3374}
3375
3376/// The `<string>` that follows `<key>key</key>` in a property list.
3377fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
3378    let marker = format!("<key>{key}</key>");
3379    let start = text.find(&marker)? + marker.len();
3380    Some(text[start..].trim_start())
3381}
3382
3383pub fn plist_string_value(text: &str, key: &str) -> Option<String> {
3384    let rest = plist_value_after_key(text, key)?;
3385    if !rest.starts_with("<string>") {
3386        return None;
3387    }
3388    xml_value(rest, "string")
3389}
3390
3391fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
3392    let rest = plist_value_after_key(text, key)?;
3393    if rest.starts_with("<true/>") {
3394        Some(true)
3395    } else if rest.starts_with("<false/>") {
3396        Some(false)
3397    } else {
3398        None
3399    }
3400}
3401
3402// ---------------------------------------------------------------------------
3403// The install record
3404// ---------------------------------------------------------------------------
3405
3406/// The version of [`InstallRecord`] on disk.
3407///
3408/// Bumped when a record written by an older version can no longer be read.
3409/// `service status` reports an unreadable record as a problem with a remedy
3410/// rather than failing, because the remedy — uninstall and install again —
3411/// touches no configuration, no secret and no cache.
3412pub const RECORD_SCHEMA_VERSION: u32 = 1;
3413
3414/// What `service install` wrote down, and what `service status` reads back.
3415///
3416/// This is the *record* half of `05-infrastructure.md` item 6. The service
3417/// manager also knows the binary path, and the two are compared: a registration
3418/// whose command line no longer matches the record is a registration something
3419/// else has edited, and saying so is more useful than picking one of them to
3420/// believe.
3421///
3422/// It holds no credential. The nearest thing is the *location* of the secret
3423/// store, which `d2` publishes for `host show` to print, and even that is
3424/// derived rather than stored.
3425#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3426pub struct InstallRecord {
3427    /// [`RECORD_SCHEMA_VERSION`] at the time of writing.
3428    pub schema_version: u32,
3429    /// What the operating system calls the registration.
3430    pub service_name: String,
3431    /// Which service manager holds it.
3432    pub manager: String,
3433    /// Boot or login.
3434    pub start_mode: StartMode,
3435    /// The account it runs under.
3436    pub account: ServiceAccount,
3437    /// **The resolved absolute path of the binary at install time.** Item 6.
3438    pub binary: PathBuf,
3439    /// Where [`Self::binary`] was copied from, when it is a copy.
3440    ///
3441    /// `#[serde(default)]` rather than a schema bump: a record written before
3442    /// this existed is still readable, and reads as `None`. That is not a
3443    /// silent downgrade — `None` means the registration names a package
3444    /// manager's own file, which is the layout that cannot be upgraded while
3445    /// the service runs, and `service status` says so by name.
3446    #[serde(default)]
3447    pub source_binary: Option<PathBuf>,
3448    /// The arguments it was registered with.
3449    pub arguments: Vec<String>,
3450    /// The restart-on-failure delay.
3451    pub restart_delay_secs: u64,
3452    /// How long it must run before the failure count resets.
3453    pub restart_reset_secs: u64,
3454    /// The rotating diagnostic log's stem. Item 4.
3455    pub log_file: PathBuf,
3456    /// Whether the manager was asked not to start this by itself. Always
3457    /// `false` for a registration an operator made; see
3458    /// [`InstallRequest::started_on_demand`].
3459    ///
3460    /// `#[serde(default)]` so that a record missing the field is rejected by
3461    /// the schema check below, with its remedy, rather than by a parse error
3462    /// that names a field an operator has never heard of.
3463    #[serde(default)]
3464    pub starts_on_demand: bool,
3465    /// Where the platform's definition file was written, when there is one.
3466    pub definition_path: Option<PathBuf>,
3467    /// The host paths the registration made inaccessible. See
3468    /// [`ProtectedPaths`]; empty in a record written before they existed.
3469    #[serde(default)]
3470    pub protected_paths: Vec<PathBuf>,
3471    /// When the registration was made.
3472    pub installed_at: DateTime<Utc>,
3473    /// Which build of the product made it.
3474    pub installed_by_version: String,
3475    // A TOML table has to follow every scalar at its level, so this field is
3476    // last by necessity rather than by taste.
3477    /// The four directories the registration was installed against. Item 2's
3478    /// *"configured cache and runtime directories"*.
3479    pub directories: ServiceDirectories,
3480}
3481
3482impl InstallRecord {
3483    /// Where the record lives, given this host's directories.
3484    #[must_use]
3485    pub fn path(paths: &AppPaths) -> PathBuf {
3486        paths.config_dir().join(RECORD_FILE)
3487    }
3488
3489    /// Builds the record for a plan that has just been applied.
3490    #[must_use]
3491    pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
3492        Self {
3493            schema_version: RECORD_SCHEMA_VERSION,
3494            service_name: plan.identity().name().to_string(),
3495            manager: definition.kind().manager().to_string(),
3496            start_mode: plan.start_mode(),
3497            account: plan.account().clone(),
3498            binary: plan.binary().to_path_buf(),
3499            arguments: plan
3500                .arguments()
3501                .iter()
3502                .map(|argument| argument.to_string_lossy().into_owned())
3503                .collect(),
3504            restart_delay_secs: plan.restart().delay().as_secs(),
3505            restart_reset_secs: plan.restart().reset_after().as_secs(),
3506            starts_on_demand: plan.is_on_demand(),
3507            source_binary: plan.source_binary().map(Path::to_path_buf),
3508            log_file: plan.directories().log_file(),
3509            definition_path: definition.install_path().map(Path::to_path_buf),
3510            protected_paths: plan.protected_paths().to_vec(),
3511            installed_at: at,
3512            installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
3513            directories: plan.directories().clone(),
3514        }
3515    }
3516
3517    /// The plan this record describes, as this build would register it.
3518    ///
3519    /// The directories, binary, arguments and restart policy are the recorded
3520    /// ones; everything else -- hardening, the secret store's guard -- is what
3521    /// this build renders. That difference is the point: it is how a
3522    /// registration written by an older build is compared against, and
3523    /// repaired to, the current definition.
3524    #[must_use]
3525    pub fn plan(&self, identity: ServiceIdentity) -> InstallPlan {
3526        let plan = InstallPlan::unchecked(
3527            identity,
3528            self.start_mode,
3529            self.binary.clone(),
3530            self.directories.clone(),
3531        )
3532        .with_arguments(self.arguments.clone())
3533        .with_restart(self.restart())
3534        .with_protected_paths(self.protected_paths.clone());
3535        let plan = if self.starts_on_demand {
3536            plan.started_on_demand()
3537        } else {
3538            plan
3539        };
3540        match crate::secrets::PlatformSecretStore::for_start_mode(self.start_mode) {
3541            Ok(store) => plan.with_secret_guard(store.guard()),
3542            Err(_) => plan,
3543        }
3544    }
3545
3546    /// The restart policy this record describes, or [`RestartPolicy::default`]
3547    /// when the recorded numbers are outside the supported range — which can
3548    /// only happen to a record something else has edited.
3549    #[must_use]
3550    pub fn restart(&self) -> RestartPolicy {
3551        RestartPolicy::new(
3552            Duration::from_secs(self.restart_delay_secs),
3553            Duration::from_secs(self.restart_reset_secs),
3554        )
3555        .unwrap_or_default()
3556    }
3557
3558    /// Reads the record, or reports that there is none.
3559    ///
3560    /// `Ok(None)` means no registration was made by this product on this host.
3561    ///
3562    /// # Errors
3563    ///
3564    /// [`ServiceError::Record`] when the file exists and cannot be read,
3565    /// [`ServiceError::RecordNotPermitted`] when it exists and this account may
3566    /// not read it, and [`ServiceError::RecordUnreadable`] when it is not a
3567    /// record this version understands.
3568    pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
3569        let path = Self::path(paths);
3570        let text = match std::fs::read_to_string(&path) {
3571            Ok(text) => text,
3572            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3573            // Told apart from every other read failure because `status` carries
3574            // on through this one alone. See `ServiceError::RecordNotPermitted`.
3575            Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
3576                return Err(ServiceError::RecordNotPermitted {
3577                    path,
3578                    detail: error.to_string(),
3579                });
3580            }
3581            Err(error) => {
3582                return Err(ServiceError::Record {
3583                    operation: "read",
3584                    path,
3585                    detail: error.to_string(),
3586                });
3587            }
3588        };
3589        let record: Self =
3590            toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
3591                path: path.clone(),
3592                detail: error.to_string(),
3593            })?;
3594        if record.schema_version != RECORD_SCHEMA_VERSION {
3595            return Err(ServiceError::RecordUnreadable {
3596                path,
3597                detail: format!(
3598                    "it declares schema version {} and this build reads version {}",
3599                    record.schema_version, RECORD_SCHEMA_VERSION
3600                ),
3601            });
3602        }
3603        Ok(Some(record))
3604    }
3605
3606    /// Writes the record, replacing whatever was there.
3607    ///
3608    /// # Errors
3609    ///
3610    /// [`ServiceError::Record`].
3611    pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
3612        use std::io::Write as _;
3613
3614        let path = Self::path(paths);
3615        let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
3616            operation: "encode",
3617            path: path.clone(),
3618            detail: error.to_string(),
3619        })?;
3620        if let Some(parent) = path.parent() {
3621            std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
3622                operation: "write",
3623                path: path.clone(),
3624                detail: error.to_string(),
3625            })?;
3626        }
3627        let parent = path.parent().ok_or_else(|| ServiceError::Record {
3628            operation: "write",
3629            path: path.clone(),
3630            detail: "the record path has no parent directory".to_string(),
3631        })?;
3632        let mut temporary =
3633            tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
3634                operation: "write",
3635                path: path.clone(),
3636                detail: error.to_string(),
3637            })?;
3638        temporary
3639            .write_all(text.as_bytes())
3640            .and_then(|()| temporary.as_file().sync_all())
3641            .map_err(|error| ServiceError::Record {
3642                operation: "write",
3643                path: path.clone(),
3644                detail: error.to_string(),
3645            })?;
3646        // ------------------------------------------------------------------
3647        // THE RECORD IS READABLE BY THE ACCOUNT WHOSE DIRECTORY IT IS IN.
3648        // ------------------------------------------------------------------
3649        // `NamedTempFile` creates at `0600`, which is right for a temporary
3650        // file and wrong for this one. A boot-mode `service install` runs under
3651        // `sudo`, so `0600` made the record `root`-owned and unreadable by the
3652        // operator — and `service status`, which asks no privilege of anybody,
3653        // failed on their own host with `Permission denied` and a remedy that
3654        // named the command that had just failed.
3655        //
3656        // `0644` does not widen anything: `RECORD_FILE`'s own documentation is
3657        // that this is *"non-secret TOML"* holding no credential, and
3658        // `AppPaths::create_all` puts it in a `0700` directory, so no other
3659        // local account can reach it whatever its own mode says. It is the same
3660        // arrangement the rotating diagnostics already have.
3661        #[cfg(unix)]
3662        {
3663            use std::os::unix::fs::PermissionsExt as _;
3664
3665            temporary
3666                .as_file()
3667                .set_permissions(std::fs::Permissions::from_mode(0o644))
3668                .map_err(|error| ServiceError::Record {
3669                    operation: "write",
3670                    path: path.clone(),
3671                    detail: error.to_string(),
3672                })?;
3673        }
3674        temporary
3675            .persist(&path)
3676            .map(|_| ())
3677            .map_err(|error| ServiceError::Record {
3678                operation: "write",
3679                path,
3680                detail: error.error.to_string(),
3681            })
3682    }
3683
3684    /// Removes the record. Returns whether there was one.
3685    ///
3686    /// # Errors
3687    ///
3688    /// [`ServiceError::Record`].
3689    pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
3690        let path = Self::path(paths);
3691        match std::fs::remove_file(&path) {
3692            Ok(()) => Ok(true),
3693            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
3694            Err(error) => Err(ServiceError::Record {
3695                operation: "remove",
3696                path,
3697                detail: error.to_string(),
3698            }),
3699        }
3700    }
3701}
3702
3703// ---------------------------------------------------------------------------
3704// The last successful GitHub contact
3705// ---------------------------------------------------------------------------
3706
3707/// The heartbeat file's own schema version.
3708const CONTACT_SCHEMA_VERSION: u32 = 1;
3709
3710#[derive(Debug, Clone, Serialize, Deserialize)]
3711struct ContactRecord {
3712    schema_version: u32,
3713    last_success: DateTime<Utc>,
3714}
3715
3716/// Records that GitHub was reached successfully, for `service status` to
3717/// report.
3718///
3719/// # The contract, for `f3` and for the agent
3720///
3721/// Journey 5 step 4 requires `service status` to report *"the last successful
3722/// GitHub contact"*, and `service status` runs in the operator's terminal while
3723/// the daemon runs in a service. They share no memory, so the fact has to be on
3724/// disk, and this is the file. **The daemon calls this after each successful
3725/// GitHub call; nothing else writes it.**
3726///
3727/// It is a single timestamp and deliberately not a log: an operator asking this
3728/// question wants to know whether the agent is alive and reaching GitHub *now*,
3729/// and a value that is minutes old answers it. Writing it is a whole-file
3730/// replace through a temporary in the same directory, so a status command can
3731/// never read a half-written timestamp.
3732///
3733/// # Errors
3734///
3735/// [`ServiceError::Record`] when `state/` cannot be written.
3736pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3737    write_state_record(
3738        &contact_path(paths),
3739        &ContactRecord {
3740            schema_version: CONTACT_SCHEMA_VERSION,
3741            last_success: at,
3742        },
3743    )
3744}
3745
3746/// Reads the last successful GitHub contact, or reports that none was recorded.
3747///
3748/// `Ok(None)` is the honest answer on a host whose agent has never run, and it
3749/// is what `service status` prints rather than a zero timestamp.
3750///
3751/// # Errors
3752///
3753/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3754/// A malformed heartbeat is reported rather than treated as absence: absence
3755/// means *"the agent has never reached GitHub"*, and reporting a parse failure
3756/// as that would be a wrong answer to the question Journey 5 asks.
3757pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3758    Ok(read_state_record::<ContactRecord>(&contact_path(paths))?.map(|record| record.last_success))
3759}
3760
3761/// Where the heartbeat lives.
3762#[must_use]
3763pub fn contact_path(paths: &AppPaths) -> PathBuf {
3764    paths.state_dir().join(CONTACT_FILE)
3765}
3766
3767/// Writes a small TOML record under `state/`, through a temporary in the same
3768/// directory and a rename, so a status command never reads half of one.
3769pub(crate) fn write_state_record(path: &Path, record: &impl Serialize) -> Result<(), ServiceError> {
3770    let failed = |detail: String| ServiceError::Record {
3771        operation: "write",
3772        path: path.to_path_buf(),
3773        detail,
3774    };
3775    let text = toml::to_string_pretty(record).map_err(|error| failed(error.to_string()))?;
3776    let directory = path.parent().unwrap_or_else(|| Path::new("."));
3777    std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3778    let temporary = path.with_extension("toml.new");
3779    std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3780    std::fs::rename(&temporary, path).map_err(|error| failed(error.to_string()))
3781}
3782
3783/// Reads a record [`write_state_record`] wrote. `Ok(None)` when there is none;
3784/// one that exists and does not parse is an error, never absence.
3785pub(crate) fn read_state_record<T: serde::de::DeserializeOwned>(
3786    path: &Path,
3787) -> Result<Option<T>, ServiceError> {
3788    let failed = |detail: String| ServiceError::Record {
3789        operation: "read",
3790        path: path.to_path_buf(),
3791        detail,
3792    };
3793    let text = match std::fs::read_to_string(path) {
3794        Ok(text) => text,
3795        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3796        Err(error) => return Err(failed(error.to_string())),
3797    };
3798    toml::from_str(&text)
3799        .map(Some)
3800        .map_err(|error| failed(error.to_string()))
3801}
3802
3803/// Removes a record; one that is not there is already removed.
3804pub(crate) fn remove_state_record(path: &Path) -> Result<(), ServiceError> {
3805    match std::fs::remove_file(path) {
3806        Ok(()) => Ok(()),
3807        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3808        Err(error) => Err(ServiceError::Record {
3809            operation: "remove",
3810            path: path.to_path_buf(),
3811            detail: error.to_string(),
3812        }),
3813    }
3814}
3815
3816// ---------------------------------------------------------------------------
3817// The credential GitHub rejected
3818// ---------------------------------------------------------------------------
3819
3820#[derive(Debug, Serialize, Deserialize)]
3821#[serde(deny_unknown_fields)]
3822struct CredentialRejectionRecord {
3823    schema_version: u32,
3824    rejected_since: DateTime<Utc>,
3825}
3826
3827/// Records that GitHub rejected the daemon's credential, keeping the first
3828/// moment of an ongoing rejection.
3829///
3830/// # Why a file of its own
3831///
3832/// A rejected credential used to be visible only in the daemon's log: the
3833/// contact heartbeat simply stopped moving, `service status` still printed
3834/// `healthy`, and the TUI -- which authenticates with its own, perhaps freshly
3835/// renewed, copy -- showed nothing at all while the service started no runner
3836/// for hours. Separate from [`CONTACT_FILE`] so that an older build reading
3837/// that file is not handed a field it refuses.
3838///
3839/// # Errors
3840///
3841/// [`ServiceError::Record`] when `state/` cannot be written.
3842pub fn record_github_credential_rejected(
3843    paths: &AppPaths,
3844    at: DateTime<Utc>,
3845) -> Result<(), ServiceError> {
3846    if github_credential_rejected_since(paths)
3847        .ok()
3848        .flatten()
3849        .is_some()
3850    {
3851        return Ok(());
3852    }
3853    write_state_record(
3854        &credential_rejection_path(paths),
3855        &CredentialRejectionRecord {
3856            schema_version: CONTACT_SCHEMA_VERSION,
3857            rejected_since: at,
3858        },
3859    )
3860}
3861
3862/// Clears a recorded rejection once GitHub accepts the credential again.
3863///
3864/// # Errors
3865///
3866/// [`ServiceError::Record`] when the record exists and cannot be removed.
3867pub fn clear_github_credential_rejection(paths: &AppPaths) -> Result<(), ServiceError> {
3868    remove_state_record(&credential_rejection_path(paths))
3869}
3870
3871/// Since when GitHub has been rejecting the daemon's credential, if it is.
3872///
3873/// # Errors
3874///
3875/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3876pub fn github_credential_rejected_since(
3877    paths: &AppPaths,
3878) -> Result<Option<DateTime<Utc>>, ServiceError> {
3879    Ok(
3880        read_state_record::<CredentialRejectionRecord>(&credential_rejection_path(paths))?
3881            .map(|record| record.rejected_since),
3882    )
3883}
3884
3885/// Where the rejection record lives.
3886#[must_use]
3887pub fn credential_rejection_path(paths: &AppPaths) -> PathBuf {
3888    paths.state_dir().join(CREDENTIAL_REJECTION_FILE)
3889}
3890
3891#[derive(Debug, Serialize, Deserialize)]
3892#[serde(deny_unknown_fields)]
3893struct CredentialUnreadableRecord {
3894    schema_version: u32,
3895    unreadable_since: DateTime<Utc>,
3896}
3897
3898/// Records that the daemon could not read its stored credential, keeping the
3899/// first moment of an ongoing failure.
3900///
3901/// # Why a file of its own
3902///
3903/// A daemon that cannot read its credential exits, and the service manager
3904/// starts it again a few seconds later, for ever. `service status` reads the
3905/// registration, which is fine, and the heartbeat, which merely stops moving --
3906/// so it said `healthy` while a macOS host that had just been upgraded was
3907/// locked out of its own keychain item and started no runner. The reason was
3908/// only in the daemon's log. The daemon is the one process whose view of the
3909/// store matters, and it shares no memory with `service status`, so it says
3910/// so on disk; [`clear_credential_unreadable`] removes it as soon as a read
3911/// succeeds.
3912///
3913/// # Errors
3914///
3915/// [`ServiceError::Record`] when `state/` cannot be written.
3916pub fn record_credential_unreadable(
3917    paths: &AppPaths,
3918    at: DateTime<Utc>,
3919) -> Result<(), ServiceError> {
3920    if credential_unreadable_since(paths).ok().flatten().is_some() {
3921        return Ok(());
3922    }
3923    write_state_record(
3924        &credential_unreadable_path(paths),
3925        &CredentialUnreadableRecord {
3926            schema_version: CONTACT_SCHEMA_VERSION,
3927            unreadable_since: at,
3928        },
3929    )
3930}
3931
3932/// Clears a recorded failure once the daemon reads its credential again.
3933///
3934/// # Errors
3935///
3936/// [`ServiceError::Record`] when the record exists and cannot be removed.
3937pub fn clear_credential_unreadable(paths: &AppPaths) -> Result<(), ServiceError> {
3938    remove_state_record(&credential_unreadable_path(paths))
3939}
3940
3941/// Since when the daemon has been unable to read its stored credential, if it
3942/// has.
3943///
3944/// # Errors
3945///
3946/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3947pub fn credential_unreadable_since(
3948    paths: &AppPaths,
3949) -> Result<Option<DateTime<Utc>>, ServiceError> {
3950    Ok(
3951        read_state_record::<CredentialUnreadableRecord>(&credential_unreadable_path(paths))?
3952            .map(|record| record.unreadable_since),
3953    )
3954}
3955
3956/// Where the unreadable-credential record lives.
3957#[must_use]
3958pub fn credential_unreadable_path(paths: &AppPaths) -> PathBuf {
3959    paths.state_dir().join(CREDENTIAL_UNREADABLE_FILE)
3960}
3961
3962/// How to sign this host in again for `mode`: the exact command, in
3963/// backticks, and where to run it when that matters.
3964///
3965/// A machine-scoped store needs privilege: `sudo` on Unix, an elevated
3966/// terminal on Windows. A login-mode store on macOS is the login keychain,
3967/// which is unlocked for the desktop session only, so an SSH session cannot
3968/// write it.
3969#[must_use]
3970pub fn sign_in_instruction(mode: StartMode) -> String {
3971    let (command, place) = match mode {
3972        StartMode::Boot if cfg!(unix) => (sudo_command("auth login --start-at boot"), ""),
3973        StartMode::Boot => (
3974            "runner-manager auth login --start-at boot".to_owned(),
3975            " from an elevated terminal",
3976        ),
3977        StartMode::Login => (
3978            "runner-manager auth login --start-at login".to_owned(),
3979            MACOS_LOGIN_PLACE,
3980        ),
3981    };
3982    format!("`{command}`{place}")
3983}
3984
3985/// `sudo`, then this program, then `arguments`: a command an operator can
3986/// paste.
3987///
3988/// `sudo` searches only its own `secure_path`, which an npm or nvm
3989/// installation is not on, and the npm command is a Node shim that root's
3990/// `PATH` could not run either. So the command names this executable -- the
3991/// native binary, even behind that shim -- unless it already lives on
3992/// `secure_path`, where the bare name reads better and works.
3993#[must_use]
3994pub fn sudo_command(arguments: &str) -> String {
3995    // A test binary is not the product; its own path would only make every
3996    // remedy a test compares depend on where the build directory is.
3997    let executable = if cfg!(test) {
3998        None
3999    } else {
4000        std::env::current_exe().ok()
4001    };
4002    format!("sudo {} {arguments}", sudo_program(executable.as_deref()))
4003}
4004
4005/// How a `sudo` command names `executable`. See [`sudo_command`].
4006fn sudo_program(executable: Option<&Path>) -> String {
4007    /// Ubuntu's and Debian's `secure_path`, which other distributions'
4008    /// defaults are a subset of.
4009    const SECURE_PATH: [&str; 7] = [
4010        "/usr/local/sbin",
4011        "/usr/local/bin",
4012        "/usr/sbin",
4013        "/usr/bin",
4014        "/sbin",
4015        "/bin",
4016        "/snap/bin",
4017    ];
4018    let Some(executable) = executable else {
4019        return "runner-manager".to_owned();
4020    };
4021    if executable
4022        .parent()
4023        .is_some_and(|directory| SECURE_PATH.iter().any(|path| directory == Path::new(path)))
4024    {
4025        return "runner-manager".to_owned();
4026    }
4027    let text = executable.to_string_lossy();
4028    if text
4029        .chars()
4030        .all(|c| c.is_ascii_alphanumeric() || "/._-+@".contains(c))
4031    {
4032        text.into_owned()
4033    } else {
4034        format!("'{}'", text.replace('\'', "'\\''"))
4035    }
4036}
4037
4038/// Where a login-mode sign-in has to happen on macOS, as a phrase to follow
4039/// the command; nothing elsewhere.
4040pub const MACOS_LOGIN_PLACE: &str = if cfg!(target_os = "macos") {
4041    " in a Terminal in this Mac's desktop session (an SSH session cannot unlock the login \
4042     keychain)"
4043} else {
4044    ""
4045};
4046
4047// ---------------------------------------------------------------------------
4048// A definition written by an older build
4049// ---------------------------------------------------------------------------
4050
4051/// An installed service definition that differs from what this build renders
4052/// for the same recorded installation.
4053///
4054/// # Why this exists
4055///
4056/// An upgrade replaces the binary a registration runs and nothing else. A
4057/// systemd unit written by 0.4.7 still carried `LoadCredential=`, which hands
4058/// the daemon a copy of the credential frozen at service start, and lacked
4059/// write access to the credential store. Every release after the fix kept
4060/// running under that unit: the daemon could neither renew the token nor see
4061/// one renewed by anybody else, and stopped starting runners the moment the
4062/// frozen access token expired.
4063#[derive(Debug, Clone, PartialEq, Eq)]
4064pub struct DefinitionDrift {
4065    /// The definition file on disk.
4066    pub path: PathBuf,
4067    /// The build that wrote it, as its install record says.
4068    pub installed_by_version: String,
4069    /// What this build renders in its place.
4070    pub rendered: String,
4071    /// The start mode it was installed for.
4072    pub start_mode: StartMode,
4073    /// Which manager reads the definition.
4074    pub manager: DefinitionKind,
4075    /// The directories the rendered definition lets the service write. A
4076    /// systemd unit fails before its first instruction (`226/NAMESPACE`) when
4077    /// one it lists is missing, or is under a home tree its
4078    /// `ProtectHome=tmpfs` hides without a `BindPaths=` for it. So whoever
4079    /// writes `rendered` creates them first: a host an older build installed
4080    /// may never have made one.
4081    pub writable: Vec<PathBuf>,
4082}
4083
4084impl DefinitionDrift {
4085    /// Creates every missing directory in [`Self::writable`]: the directory
4086    /// itself `0700`, its parents (`~/.local` and its like, not this
4087    /// program's to tighten) as the umask says.
4088    ///
4089    /// # Errors
4090    ///
4091    /// The first directory that could not be created.
4092    pub fn create_writable(&self) -> std::io::Result<()> {
4093        create_writable_directories(&self.writable)
4094    }
4095}
4096
4097/// Creates every missing directory in `directories`: the directory itself
4098/// `0700`, whatever the umask, and its parents as the umask says.
4099///
4100/// # Errors
4101///
4102/// The first that could not be created, or a path that holds something
4103/// other than a directory: a file or a dangling link there fails a unit as
4104/// surely as nothing at all.
4105pub fn create_writable_directories(directories: &[PathBuf]) -> std::io::Result<()> {
4106    for directory in directories {
4107        if let Some(parent) = directory.parent() {
4108            std::fs::create_dir_all(parent)?;
4109        }
4110        match std::fs::create_dir(directory) {
4111            Ok(()) => {
4112                #[cfg(unix)]
4113                {
4114                    use std::os::unix::fs::PermissionsExt as _;
4115                    std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700))?;
4116                }
4117            }
4118            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
4119                if !directory.is_dir() {
4120                    return Err(std::io::Error::new(
4121                        std::io::ErrorKind::AlreadyExists,
4122                        format!("{} exists and is not a directory", directory.display()),
4123                    ));
4124                }
4125            }
4126            Err(error) => return Err(error),
4127        }
4128    }
4129    Ok(())
4130}
4131
4132/// Compares the installed definition against this build's rendering.
4133///
4134/// A systemd unit is compared byte for byte. A launchd plist is compared on
4135/// `ProcessType` alone (see [`launchd_definition_drift`]). `None` for every
4136/// other manager, for a definition that cannot be read, and for one that
4137/// already matches. The file is read through `controls`, so a double never
4138/// compares against a real host's definition.
4139fn definition_drift(
4140    controls: &dyn ControlFactory,
4141    identity: &ServiceIdentity,
4142    record: &InstallRecord,
4143) -> Option<DefinitionDrift> {
4144    let compare = match host_definition_kind(record.start_mode) {
4145        DefinitionKind::SystemdUnit => systemd_definition_drift,
4146        DefinitionKind::LaunchdPlist => launchd_definition_drift,
4147        DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => return None,
4148    };
4149    let path = record.definition_path.clone()?;
4150    let installed = controls.installed_definition(&path)?;
4151    compare(identity, record, path, &installed)
4152}
4153
4154/// A plist whose `ProcessType` is not [`LAUNCHD_PROCESS_TYPE`], with the plist
4155/// this build renders in its place.
4156///
4157/// Only that one key, unlike the systemd comparison. The daemon repairs this
4158/// drift by itself (`daemon.rs`, `converge_launchd_definition`), so a
4159/// whole-file comparison would make it rewrite and reload over every edit an
4160/// operator made to the plist. A plist that does carry the wrong value is
4161/// replaced whole, as `service install` would replace it.
4162fn launchd_definition_drift(
4163    identity: &ServiceIdentity,
4164    record: &InstallRecord,
4165    path: PathBuf,
4166    installed: &str,
4167) -> Option<DefinitionDrift> {
4168    (plist_string_value(installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE)).then(
4169        || DefinitionDrift {
4170            path,
4171            installed_by_version: record.installed_by_version.clone(),
4172            rendered: launchd_plist(&record.plan(identity.clone())),
4173            start_mode: record.start_mode,
4174            manager: DefinitionKind::LaunchdPlist,
4175            writable: Vec::new(),
4176        },
4177    )
4178}
4179
4180fn systemd_definition_drift(
4181    identity: &ServiceIdentity,
4182    record: &InstallRecord,
4183    path: PathBuf,
4184    installed: &str,
4185) -> Option<DefinitionDrift> {
4186    let plan = record.plan(identity.clone());
4187    let rendered = systemd_unit(&plan);
4188    (installed != rendered).then(|| DefinitionDrift {
4189        path,
4190        installed_by_version: record.installed_by_version.clone(),
4191        rendered,
4192        start_mode: record.start_mode,
4193        manager: DefinitionKind::SystemdUnit,
4194        writable: permitted_paths(DefinitionKind::SystemdUnit, &plan)
4195            .into_iter()
4196            .map(PathBuf::from)
4197            .collect(),
4198    })
4199}
4200
4201// ---------------------------------------------------------------------------
4202// The last runner-root refusal
4203// ---------------------------------------------------------------------------
4204
4205const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
4206
4207#[derive(Debug, Clone, Serialize, Deserialize)]
4208struct RootRefusalFile {
4209    schema_version: u32,
4210    /// Keyed by policy, because the fact is per-policy and not per-host.
4211    #[serde(default)]
4212    refusals: BTreeMap<String, RootRefusalEntry>,
4213}
4214
4215#[derive(Debug, Clone, Serialize, Deserialize)]
4216struct RootRefusalEntry {
4217    at: DateTime<Utc>,
4218    kind: String,
4219    root: String,
4220    detail: String,
4221}
4222
4223/// One policy's last runner-root refusal, as `service status` reports it.
4224#[derive(Debug, Clone, PartialEq, Eq)]
4225pub struct RunnerRootRefusal {
4226    /// The policy that placed no runner.
4227    pub policy: String,
4228    /// When it was last refused.
4229    pub at: DateTime<Utc>,
4230    /// The closed-vocabulary cause, as `RunnerRootError::kind` names it.
4231    pub kind: String,
4232    /// The root it was refused.
4233    pub root: String,
4234    /// The refusal in full, including the remediation. Not redacted: this file
4235    /// is read by a command in the operator's terminal, not by the log sink.
4236    pub detail: String,
4237}
4238
4239/// Records that one policy could not use its runner root.
4240///
4241/// # The contract, and why a file
4242///
4243/// A launch the runner root refuses fails before any attempt row exists, so the
4244/// journal cannot carry it and no screen can show it. The daemon's own log
4245/// cannot either: it redacts every field it does not allow-list and scrubs
4246/// anything path-shaped out of the ones it does, which is most of a
4247/// `RunnerRootError`. So the sentence an operator needs — the directory and the
4248/// remediation, verbatim — has nowhere to go, and the failure reads
4249/// `runner_start_failed reason=other` once per poll for as long as it lasts.
4250/// That is not hypothetical: it cost three hours.
4251///
4252/// **Keyed by policy, and that is load-bearing.** A host runs several policies
4253/// and they do not share a fate: `allocate_persistent_slot` tolerates an
4254/// unresolvable host default, so a repository with its own persistent root
4255/// places runners happily while an ephemeral policy on a withheld volume places
4256/// none. A single host-wide slot would have the working policy clear the broken
4257/// one's record on the same reconcile pass, and `service status` would report a
4258/// healthy host that had started zero runners for an entire target.
4259///
4260/// **The daemon writes this when a root refuses a launch and clears the policy's
4261/// entry when one succeeds; nothing else writes it.** It keeps the last refusal
4262/// per policy rather than a history, for the reason [`record_github_contact`] is
4263/// a single timestamp: the operator is asking whether runners can be placed
4264/// *now*.
4265///
4266/// Written whole through a temporary in the same directory, so a status command
4267/// cannot read half a record.
4268///
4269/// # Errors
4270///
4271/// [`ServiceError::Record`] when `state/` cannot be read or written.
4272pub fn record_runner_root_refusal(
4273    paths: &AppPaths,
4274    policy: &str,
4275    at: DateTime<Utc>,
4276    kind: &str,
4277    root: &str,
4278    detail: &str,
4279) -> Result<(), ServiceError> {
4280    let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
4281        schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
4282        refusals: BTreeMap::new(),
4283    });
4284    file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
4285    file.refusals.insert(
4286        policy.to_owned(),
4287        RootRefusalEntry {
4288            at,
4289            kind: kind.to_owned(),
4290            root: root.to_owned(),
4291            detail: detail.to_owned(),
4292        },
4293    );
4294    write_refusal_file(paths, &file)
4295}
4296
4297/// Clears one policy's entry, because that policy placed a runner.
4298///
4299/// Removes the file once the last entry goes, so a host that is working leaves
4300/// nothing behind for a later `service status` to report. A policy with no entry
4301/// is not an error: this is called on every successful placement, and almost
4302/// every one of those follows another success.
4303///
4304/// # Errors
4305///
4306/// [`ServiceError::Record`] when `state/` cannot be read or written.
4307pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
4308    let Some(mut file) = read_refusal_file(paths)? else {
4309        return Ok(());
4310    };
4311    if file.refusals.remove(policy).is_none() {
4312        return Ok(());
4313    }
4314    if file.refusals.is_empty() {
4315        let path = root_refusal_path(paths);
4316        return match std::fs::remove_file(&path) {
4317            Ok(()) => Ok(()),
4318            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
4319            Err(error) => Err(ServiceError::Record {
4320                operation: "remove",
4321                path,
4322                detail: error.to_string(),
4323            }),
4324        };
4325    }
4326    write_refusal_file(paths, &file)
4327}
4328
4329/// Every policy whose runner root refused it and has not since succeeded.
4330///
4331/// Ordered by policy, so two runs of `service status` on an unchanged host
4332/// print the same thing in the same order.
4333///
4334/// # Errors
4335///
4336/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
4337/// Reported rather than treated as absence, for the reason
4338/// [`last_github_contact`] gives: absence means "every policy is placing
4339/// runners", and answering a parse failure with that would be a wrong answer.
4340pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
4341    Ok(read_refusal_file(paths)?
4342        .map(|file| {
4343            file.refusals
4344                .into_iter()
4345                .map(|(policy, entry)| RunnerRootRefusal {
4346                    policy,
4347                    at: entry.at,
4348                    kind: entry.kind,
4349                    root: entry.root,
4350                    detail: entry.detail,
4351                })
4352                .collect()
4353        })
4354        .unwrap_or_default())
4355}
4356
4357fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
4358    read_state_record(&root_refusal_path(paths))
4359}
4360
4361fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
4362    write_state_record(&root_refusal_path(paths), file)
4363}
4364
4365/// Where the refusals live.
4366#[must_use]
4367pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
4368    paths.state_dir().join(ROOT_REFUSAL_FILE)
4369}
4370
4371// ---------------------------------------------------------------------------
4372// The recorded binary path
4373// ---------------------------------------------------------------------------
4374
4375/// What became of the absolute path `install` recorded.
4376///
4377/// `05-infrastructure.md` item 6 in one type. Three of the four variants are
4378/// errors, and `service status` reports them as errors rather than as health —
4379/// which is the whole point, because the npm case produces a service that looks
4380/// installed, is registered, and cannot start.
4381#[derive(Debug, Clone, PartialEq, Eq)]
4382pub enum BinaryPath {
4383    /// The recorded path is a file, and the service manager still starts it.
4384    Current {
4385        /// The path.
4386        path: PathBuf,
4387    },
4388    /// **Nothing is at the recorded path.**
4389    ///
4390    /// This is the npm upgrade: `npm i -g @ivan-murzak/runner-manager` puts the binary under
4391    /// the active Node installation's global prefix, and switching Node versions
4392    /// with `nvm`, `fnm`, or `volta` moves that prefix. The service is still
4393    /// registered, still set to start at boot, and starts nothing.
4394    Missing {
4395        /// The path the record names.
4396        recorded: PathBuf,
4397    },
4398    /// Something is at the recorded path, but it is not a file the service
4399    /// manager could start.
4400    NotExecutable {
4401        /// The path the record names.
4402        recorded: PathBuf,
4403        /// What is there instead.
4404        detail: String,
4405    },
4406    /// The record and the service manager name different binaries.
4407    Diverged {
4408        /// What the record says.
4409        recorded: PathBuf,
4410        /// What the service manager is registered to start.
4411        registered: PathBuf,
4412    },
4413}
4414
4415impl BinaryPath {
4416    /// Whether this is a state `service status` must report as an error.
4417    #[must_use]
4418    pub const fn is_error(&self) -> bool {
4419        !matches!(self, Self::Current { .. })
4420    }
4421
4422    /// The path the record names, whatever state it is in.
4423    #[must_use]
4424    pub fn recorded(&self) -> &Path {
4425        match self {
4426            Self::Current { path } => path,
4427            Self::Missing { recorded }
4428            | Self::NotExecutable { recorded, .. }
4429            | Self::Diverged { recorded, .. } => recorded,
4430        }
4431    }
4432}
4433
4434impl fmt::Display for BinaryPath {
4435    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4436        match self {
4437            Self::Current { path } => write!(f, "{}", path.display()),
4438            Self::Missing { recorded } => write!(
4439                f,
4440                "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
4441                 package manager that moved the binary is the usual cause; an `npm i -g` \
4442                 installation moves with the active Node version. Run `service install` again \
4443                 from the binary that is now installed.",
4444                recorded.display()
4445            ),
4446            Self::NotExecutable { recorded, detail } => write!(
4447                f,
4448                "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
4449                 from the installed binary.",
4450                recorded.display()
4451            ),
4452            Self::Diverged {
4453                recorded,
4454                registered,
4455            } => write!(
4456                f,
4457                "{} -- STALE: the service manager is registered to start {} instead. Something \
4458                 has edited the registration since it was installed. Run `service uninstall` and \
4459                 `service install`; neither touches configuration, secrets, or the cache.",
4460                recorded.display(),
4461                registered.display()
4462            ),
4463        }
4464    }
4465}
4466
4467/// Decides what became of a recorded path.
4468///
4469/// `registered` is what the service manager says it will start, when the
4470/// platform can be asked. Order matters and is deliberate: **absence is checked
4471/// first**, because a missing binary is the failure item 6 exists for and an
4472/// operator hearing "the registration disagrees with the record" about a file
4473/// that is not there would be sent to the wrong problem.
4474#[must_use]
4475pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
4476    match std::fs::metadata(recorded) {
4477        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
4478            return BinaryPath::Missing {
4479                recorded: recorded.to_path_buf(),
4480            };
4481        }
4482        Err(error) => {
4483            return BinaryPath::NotExecutable {
4484                recorded: recorded.to_path_buf(),
4485                detail: format!("it cannot be inspected ({error})"),
4486            };
4487        }
4488        Ok(metadata) if !metadata.is_file() => {
4489            return BinaryPath::NotExecutable {
4490                recorded: recorded.to_path_buf(),
4491                detail: "what is there is not a file".to_string(),
4492            };
4493        }
4494        Ok(_) => {}
4495    }
4496    if let Some(registered) = registered
4497        && !same_path_text(&recorded.to_string_lossy(), &registered.to_string_lossy())
4498    {
4499        return BinaryPath::Diverged {
4500            recorded: recorded.to_path_buf(),
4501            registered: registered.to_path_buf(),
4502        };
4503    }
4504    BinaryPath::Current {
4505        path: recorded.to_path_buf(),
4506    }
4507}
4508
4509// ---------------------------------------------------------------------------
4510// The control seam
4511// ---------------------------------------------------------------------------
4512
4513/// What a service manager says about a registration it holds.
4514#[derive(Debug, Clone, PartialEq, Eq)]
4515pub struct Registration {
4516    /// Which manager holds it, which is also what decides the start mode:
4517    /// a Windows service is a boot registration and a Task Scheduler task is a
4518    /// login one; a LaunchDaemon and a system unit are boot, a LaunchAgent and
4519    /// a user unit are login.
4520    pub manager: DefinitionKind,
4521    /// The start mode the *domain* it was found in implies.
4522    pub start_mode: StartMode,
4523    /// The full command line, as the manager stores it.
4524    pub command_line: String,
4525    /// The account it runs under, spelled as [`ServiceAccount`] spells it, when
4526    /// the manager names a real account.
4527    ///
4528    /// `None` where the manager reports only a placeholder: a login
4529    /// registration that runs as whoever signs in, or a Task Scheduler `UserId`
4530    /// that is a raw SID. Status then reports the install record's account.
4531    pub account: Option<String>,
4532    /// Whether it is running now.
4533    pub running: bool,
4534    /// Whether it will start by itself.
4535    ///
4536    /// Distinct from [`Registration::start_mode`]: a Windows service can be
4537    /// registered in the boot domain and still be set to `demand` start, which
4538    /// is a service that exists, looks installed, and does not come back after
4539    /// a reboot.
4540    pub starts_automatically: bool,
4541    /// The restart-on-failure delay the manager reports, when it reports one.
4542    ///
4543    /// The *delay* rather than the whole [`RestartPolicy`], because that is the
4544    /// half every one of the four managers can be asked for. The failure-count
4545    /// reset window has no representation at all in Task Scheduler or launchd,
4546    /// so a `RestartPolicy` read back from them would carry one number the
4547    /// manager reported and one this module invented — and a comparison against
4548    /// an invented number is a comparison that cannot fail.
4549    pub restart_delay: Option<Duration>,
4550}
4551
4552impl Registration {
4553    /// The executable the manager will start, parsed out of the command line.
4554    #[must_use]
4555    pub fn binary(&self) -> Option<PathBuf> {
4556        executable_from_command_line(&self.command_line)
4557    }
4558}
4559
4560/// One platform's service manager, for one start-mode domain.
4561///
4562/// Every method takes the identity rather than storing it, so one control can
4563/// answer about the product's registration and about a test fixture's without
4564/// either being able to reach the other by accident.
4565pub trait ServiceControl: fmt::Debug {
4566    /// Which manager this is.
4567    fn manager(&self) -> DefinitionKind;
4568
4569    /// Registers the plan, returning the definition that was applied.
4570    ///
4571    /// # Errors
4572    ///
4573    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`].
4574    fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
4575
4576    /// Deregisters. Returns whether there was a registration to remove.
4577    ///
4578    /// **Removes the registration and nothing else.** No implementation of this
4579    /// method may delete a directory, a database, a secret, or a cache;
4580    /// `05-infrastructure.md` item 5 is a property of every backend, not a
4581    /// check somewhere above them.
4582    ///
4583    /// # Errors
4584    ///
4585    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`].
4586    fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
4587
4588    /// What the manager knows about this registration, if anything.
4589    ///
4590    /// # Errors
4591    ///
4592    /// [`ServiceError::Control`].
4593    fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
4594
4595    /// Starts it now, without waiting for the next boot or logon.
4596    ///
4597    /// # Errors
4598    ///
4599    /// [`ServiceError::Control`], [`ServiceError::NeedsElevation`], or
4600    /// [`ServiceError::NotInstalled`].
4601    fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
4602
4603    /// Stops it. Returns whether it was running.
4604    ///
4605    /// # Errors
4606    ///
4607    /// As [`ServiceControl::start`].
4608    fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
4609}
4610
4611/// Chooses the control for a start mode.
4612///
4613/// The indirection is what makes [`ServiceOperations`] testable: production
4614/// hands it [`HostControls`], every test in this file hands it
4615/// [`RecordingControls`], and neither knows the difference.
4616pub trait ControlFactory: fmt::Debug + Send + Sync {
4617    /// The control for one start-mode domain.
4618    ///
4619    /// # Errors
4620    ///
4621    /// [`ServiceError::Control`] when this host has no manager for that domain.
4622    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
4623
4624    /// The text of an installed definition file, for drift detection. `None`
4625    /// when there is none to read. The default reads nothing, so a double
4626    /// never reports the real host's unit as its own.
4627    fn installed_definition(&self, _path: &Path) -> Option<String> {
4628        None
4629    }
4630
4631    /// Whether this Mac signs somebody in after a restart, and whether
4632    /// FileVault holds it at the unlock screen. `None` where the platform has
4633    /// no such probe; the default probes nothing, for the same reason as
4634    /// [`Self::installed_definition`].
4635    fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
4636        None
4637    }
4638}
4639
4640/// The real service managers of the host this binary was built for.
4641#[derive(Debug, Clone, Copy, Default)]
4642pub struct HostControls;
4643
4644// ---------------------------------------------------------------------------
4645// The operations
4646// ---------------------------------------------------------------------------
4647
4648/// What `install` did.
4649#[derive(Debug, Clone)]
4650pub struct Installed {
4651    /// The plan that was applied, including the resolved absolute binary path.
4652    pub plan: InstallPlan,
4653    /// The definition the platform was given.
4654    pub definition: ServiceDefinition,
4655    /// The record that was written.
4656    pub record: InstallRecord,
4657    /// What the definition grants, measured against the requirement.
4658    pub review: PrivilegeReview,
4659    /// What was done to the runner root the registration will run jobs under.
4660    ///
4661    /// Named trustees rather than SIDs, so printing it adds no identity to the
4662    /// output. See [`crate::runner_root_access`].
4663    pub runner_root: RootAccessSummary,
4664    /// Whether this replaced a registration that was already there, rather than
4665    /// making a new one.
4666    ///
4667    /// Reported rather than glossed over: "Service installed" on a host that
4668    /// had one already reads as though nothing had been taken away and put
4669    /// back, and what was taken away and put back is the thing an operator
4670    /// watching a running agent wants to know about.
4671    pub replaced_existing: bool,
4672}
4673
4674/// What `uninstall` did — and, as importantly, what it did not.
4675#[derive(Debug, Clone, PartialEq, Eq)]
4676pub struct Uninstalled {
4677    /// Whether a registration was removed.
4678    pub removed_registration: bool,
4679    /// Whether the install record was removed.
4680    pub removed_record: bool,
4681    /// Whether a definition file was removed, and which.
4682    pub removed_definition: Option<PathBuf>,
4683    /// The directories that were **left exactly as they were**.
4684    ///
4685    /// `05-infrastructure.md` item 5 stated as a value rather than as a
4686    /// promise: `service uninstall` prints this list, so an operator can see
4687    /// that the configuration, the SQLite database, the stored token and the
4688    /// runner package cache are all still there.
4689    pub preserved: Vec<PathBuf>,
4690}
4691
4692impl fmt::Display for Uninstalled {
4693    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4694        if self.removed_registration {
4695            writeln!(f, "The service registration was removed.")?;
4696        } else {
4697            writeln!(f, "There was no service registration to remove.")?;
4698        }
4699        writeln!(f, "Nothing else was deleted. These are untouched:")?;
4700        for path in &self.preserved {
4701            writeln!(f, "  {}", path.display())?;
4702        }
4703        write!(
4704            f,
4705            "The stored GitHub token is untouched too; `auth logout` is what purges it."
4706        )
4707    }
4708}
4709
4710/// What `set_start_mode` did.
4711#[derive(Debug, Clone, PartialEq, Eq)]
4712pub struct StartModeChange {
4713    /// What it was.
4714    pub from: StartMode,
4715    /// What it is now.
4716    pub to: StartMode,
4717    /// `false` when the registration was already in the requested mode.
4718    pub changed: bool,
4719    /// The secret store the new mode obliges, so a caller can tell an operator
4720    /// whether the token has to move too.
4721    pub store_scope: crate::secrets::SecretScope,
4722    /// What the mode change did to the runner root's access control.
4723    ///
4724    /// A mode change moves the account the daemon runs as, and the runner root
4725    /// admits that account by name — so the two move together or the new
4726    /// registration cannot write its own workspaces.
4727    pub runner_root: RootAccessSummary,
4728}
4729
4730impl fmt::Display for StartModeChange {
4731    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4732        if !self.changed {
4733            return write!(f, "The service already starts at {}.", self.to);
4734        }
4735        write!(
4736            f,
4737            "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
4738             if the token was stored under the other scope, run `auth login` again. {}",
4739            self.to, self.from, self.store_scope, self.runner_root
4740        )
4741    }
4742}
4743
4744/// Install, uninstall, inspect, and switch start mode.
4745///
4746/// This is the whole of the library contract `f3` builds `service install`,
4747/// `service uninstall` and `service status` on. Nothing here prints; every
4748/// operation returns a value with a [`fmt::Display`] a command can write, so the
4749/// same facts are available to the TUI and to a JSON document without being
4750/// re-derived from text.
4751#[derive(Debug, Clone)]
4752pub struct ServiceOperations {
4753    paths: AppPaths,
4754    identity: ServiceIdentity,
4755    controls: std::sync::Arc<dyn ControlFactory>,
4756    runner_root: Option<LocalAbsolutePath>,
4757}
4758
4759impl ServiceOperations {
4760    /// Operates on this host's real service managers.
4761    #[must_use]
4762    pub fn on_this_host(paths: AppPaths) -> Self {
4763        Self::with_controls(
4764            paths,
4765            ServiceIdentity::product(),
4766            std::sync::Arc::new(HostControls),
4767        )
4768    }
4769
4770    /// Operates on the controls a caller supplies.
4771    ///
4772    /// Both other arguments are explicit for the same reason: the privileged
4773    /// installer tests register a real service under a
4774    /// [`ServiceIdentity::fixture`] name, against a disposable
4775    /// [`AppPaths::rooted_at`] tree, and must not be able to touch an operator's
4776    /// installation even by mistake.
4777    #[must_use]
4778    pub fn with_controls(
4779        paths: AppPaths,
4780        identity: ServiceIdentity,
4781        controls: std::sync::Arc<dyn ControlFactory>,
4782    ) -> Self {
4783        Self {
4784            paths,
4785            identity,
4786            controls,
4787            runner_root: None,
4788        }
4789    }
4790
4791    /// Points runner-root preparation at a directory the caller owns, for the
4792    /// **current account**.
4793    ///
4794    /// **Only a [`ServiceIdentity::fixture`] registration is allowed to move
4795    /// it, and the guard is here rather than at the call site.** A product
4796    /// registration ignores the override entirely and resolves
4797    /// [`crate::runner_root::default_runner_root`] itself, which is what keeps
4798    /// "custom roots are never re-ACLed" true no matter what a caller passes.
4799    ///
4800    /// It exists because the directory the product uses is
4801    /// `%SystemDrive%\rman`, and a smoke test that created and re-permissioned
4802    /// *that* would be editing the machine it runs on from outside its own
4803    /// fixture. So a fixture aims this at a temporary directory it created and
4804    /// will delete — and "and will delete" is why the override is always
4805    /// reconciled for the calling account rather than for the account the start
4806    /// mode obliges. A boot-mode root admits `SY` and `BA` only, which an
4807    /// ordinary filtered token is neither, so a test that supplied one could
4808    /// not then inspect or remove its own temporary directory.
4809    #[must_use]
4810    pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
4811        self.runner_root = Some(root);
4812        self
4813    }
4814
4815    /// The directories this operates against.
4816    #[must_use]
4817    pub const fn paths(&self) -> &AppPaths {
4818        &self.paths
4819    }
4820
4821    /// The registration this operates on.
4822    #[must_use]
4823    pub const fn identity(&self) -> &ServiceIdentity {
4824        &self.identity
4825    }
4826
4827    /// Registers `daemon run` with the operating system.
4828    ///
4829    /// The order is the contract, and each step is a requirement:
4830    ///
4831    /// 1. the four directories are created, because item 2 is about writing them;
4832    /// 2. **the single-instance lock is taken** — item 1. It is held for the
4833    ///    whole of the install rather than probed and released, so a daemon that
4834    ///    starts halfway through cannot end up racing a registration;
4835    /// 3. neither start-mode domain already holds a registration — item 6's
4836    ///    record would otherwise describe one of two;
4837    /// 4. the binary path is resolved and confirmed to be a file — item 6;
4838    /// 5. the platform registers it;
4839    /// 6. the record is written.
4840    ///
4841    /// # Errors
4842    ///
4843    /// [`ServiceError::LockHeld`] when an agent is already running,
4844    /// [`ServiceError::AlreadyInstalled`], [`ServiceError::BinaryMissing`], and
4845    /// whatever the platform reports.
4846    pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
4847        self.paths
4848            .create_all()
4849            .map_err(|source| ServiceError::Paths {
4850                source: Box::new(source),
4851            })?;
4852
4853        // Item 1. The guard lives until the end of this function; dropping it is
4854        // the release, so an early return releases it too.
4855        let _guard = self.refuse_while_an_agent_runs()?;
4856        // `service install` moved an older tree in, if there was one, before
4857        // it opened the database here; this finds nothing left to move.
4858        let adopted = self.adopt_boot_installation(request.start_mode(), &|_| Ok(()))?;
4859        // Replacing applies to this account's own registration only: over
4860        // another installation's it would re-register that service against
4861        // this account's directories.
4862        if !adopted {
4863            self.refuse_foreign("replace")?;
4864        }
4865
4866        // --------------------------------------------------------------------
4867        // AN INSTALL OVER THE SAME START MODE REPLACES; IT DOES NOT REFUSE.
4868        // --------------------------------------------------------------------
4869        // This used to refuse any existing registration, and that was wrong in
4870        // a way that took a real host down.
4871        //
4872        // `service install` is *also* how an operator moves to a new version by
4873        // hand — `install_owned_copy` says so, and the `.old` rename it does
4874        // exists precisely so that reinstalling over a **running** service
4875        // works. So the caller replaces the copy the service runs and *then*
4876        // asks to register it. Refusing at that point does not undo the swap:
4877        // the daemon is left running a binary nobody registered, the command
4878        // reports failure, and on macOS the new binary is a program the stored
4879        // credential's keychain grant does not name. Watched on 0.1.17: the
4880        // copy under `state/bin` was replaced at 00:16, `service install` said
4881        // "already registered", and the daemon crash-looped on `-25293` from
4882        // then on.
4883        //
4884        // Registering the same service twice is not what an operator is asking
4885        // for here and is not what they get: the platform is asked to drop the
4886        // registration and take it again, which is a deregister followed by a
4887        // register because that is the only thing launchd, systemd and the SCM
4888        // all support — `launchctl bootstrap` refuses a label already loaded.
4889        //
4890        // The *other* mode is still refused. Moving between boot and login
4891        // moves the registration between two service managers and changes the
4892        // account and the secret store with it; `set_start_mode` does that,
4893        // carefully, and an install is not the place for it.
4894        let (replacing, previous_was_running) = match self.find_registration()? {
4895            Some((existing, registration)) if existing == request.start_mode() => {
4896                (true, registration.running)
4897            }
4898            Some((existing, _)) => {
4899                return Err(ServiceError::AlreadyInstalled {
4900                    name: self.identity.name().to_string(),
4901                    existing,
4902                    requested: request.start_mode(),
4903                });
4904            }
4905            None => (false, false),
4906        };
4907
4908        let plan = InstallPlan::resolve(
4909            self.identity.clone(),
4910            request,
4911            ServiceDirectories::of(&self.paths),
4912        )?;
4913
4914        // Resolved before the runner root, and used after it: every step from
4915        // here on has to be able to undo the directory, and a `?` between the
4916        // preparation and the first fallible use would leave one behind with
4917        // nothing to say so.
4918        let control = self.controls.control(plan.start_mode())?;
4919
4920        // Item 8, and the one step that can refuse an otherwise valid install.
4921        // Before the platform is asked to register anything, because a
4922        // registration whose workspaces would land in a directory ordinary local
4923        // users can write is one that should never have existed — and because
4924        // undoing a directory is cheaper than undoing a service.
4925        let root = self.prepare_runner_root(plan.start_mode())?;
4926
4927        // Read before the registration is dropped, so that a replace which then
4928        // fails to take can put back what was there. A record this account
4929        // cannot read leaves nothing to reinstate, which is reported rather
4930        // than pretended: see `reinstate`.
4931        let previous = if replacing {
4932            InstallRecord::read(&self.paths).ok().flatten()
4933        } else {
4934            None
4935        };
4936        if replacing && let Err(cause) = control.uninstall(&self.identity) {
4937            return Err(undo_runner_root(&root, "install", &self.identity, cause));
4938        }
4939
4940        let definition = match control.install(&plan) {
4941            Ok(definition) => definition,
4942            Err(cause) => {
4943                // Nothing was deregistered on a first install, so there is
4944                // nothing to put back and `reinstate` says so with `Ok(())`.
4945                let restored =
4946                    self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running);
4947                return Err(rolled_back(
4948                    retained_runner_root(&root),
4949                    restored,
4950                    "install",
4951                    &self.identity,
4952                    cause,
4953                ));
4954            }
4955        };
4956        let review = review_least_privilege(&definition, &plan);
4957        let record = InstallRecord::of(&plan, &definition, Utc::now());
4958        if let Err(cause) = record.write(&self.paths) {
4959            let rollback = control
4960                .uninstall(&self.identity)
4961                .map(|_| ())
4962                .and_then(|()| {
4963                    self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4964                });
4965            return Err(rolled_back(
4966                retained_runner_root(&root),
4967                rollback,
4968                "install",
4969                &self.identity,
4970                cause,
4971            ));
4972        }
4973
4974        // Registration is not the postcondition of an install: a service the
4975        // current session still has to start by hand is only half-installed.
4976        // This matters most for a logon-triggered Windows task because its
4977        // trigger has already passed by the time an operator reinstalls it.
4978        // Start through the same control that registered the definition and
4979        // roll the whole replacement back if the manager refuses it.
4980        if let Err(cause) = control.start(&self.identity) {
4981            let remove_new = control.uninstall(&self.identity).map(|_| ());
4982            let restore_registration = remove_new.and_then(|()| {
4983                self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4984            });
4985            let restore_record = restore_registration.and_then(|()| match &previous {
4986                Some(previous) => previous.write(&self.paths),
4987                None => InstallRecord::remove(&self.paths).map(|_| ()),
4988            });
4989            return Err(rolled_back(
4990                retained_runner_root(&root),
4991                restore_record,
4992                "install and start",
4993                &self.identity,
4994                cause,
4995            ));
4996        }
4997        Ok(Installed {
4998            plan,
4999            definition,
5000            record,
5001            review,
5002            runner_root: root.summary().clone(),
5003            replaced_existing: replacing,
5004        })
5005    }
5006
5007    /// Puts back a registration this call deregistered in order to replace it.
5008    ///
5009    /// `Ok(())` when there was nothing to put back, which is the ordinary case:
5010    /// a first install deregisters nothing. When there *was* a record, the plan
5011    /// is rebuilt from it rather than from the request, because the request is
5012    /// the one that just failed.
5013    ///
5014    /// Best effort by construction — the caller passes the result to
5015    /// [`rolled_back`], which reports a failed rollback alongside the original
5016    /// cause rather than replacing it.
5017    fn reinstate(
5018        &self,
5019        control: &dyn ServiceControl,
5020        previous: Option<&InstallRecord>,
5021        was_running: bool,
5022    ) -> Result<(), ServiceError> {
5023        let Some(record) = previous else {
5024            return Ok(());
5025        };
5026        control.install(&record.plan(self.identity.clone()))?;
5027        if was_running {
5028            control.start(&self.identity)?;
5029        }
5030        Ok(())
5031    }
5032
5033    /// Deregisters, and deletes nothing else.
5034    ///
5035    /// # Errors
5036    ///
5037    /// Whatever the platform reports. A missing registration is **not** an
5038    /// error: `service uninstall` on a host that has none should say so and
5039    /// exit cleanly, because an operator running it twice has not made a
5040    /// mistake.
5041    pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
5042        let record = InstallRecord::read(&self.paths).ok().flatten();
5043        let removed_definition = record
5044            .as_ref()
5045            .and_then(|record| record.definition_path.clone());
5046
5047        let mut removed_registration = false;
5048        // Both domains, not only the recorded one. A record can be lost while a
5049        // registration survives, and leaving a registration behind because a
5050        // TOML file was deleted is exactly the state `uninstall` exists to end.
5051        //
5052        // Except another installation's: it is skipped, and when it is all
5053        // there is, the uninstall is refused and this account's record kept.
5054        let mut foreign = None;
5055        for mode in [StartMode::Boot, StartMode::Login] {
5056            let control = self.controls.control(mode)?;
5057            if let Some(directories) = control
5058                .query(&self.identity)
5059                .ok()
5060                .flatten()
5061                .and_then(|registration| self.foreign_directories(&registration))
5062            {
5063                foreign = Some(directories);
5064                continue;
5065            }
5066            if control.uninstall(&self.identity)? {
5067                removed_registration = true;
5068            }
5069        }
5070        if !removed_registration && let Some(directories) = foreign {
5071            return Err(self.foreign_refusal("uninstall", directories));
5072        }
5073        let removed_record = InstallRecord::remove(&self.paths)?;
5074        Ok(Uninstalled {
5075            removed_registration,
5076            removed_record,
5077            removed_definition: removed_definition.filter(|_| removed_registration),
5078            preserved: self
5079                .paths
5080                .all()
5081                .iter()
5082                .map(|(_, path)| (*path).to_path_buf())
5083                .collect(),
5084        })
5085    }
5086
5087    /// Switches between `boot` and `login` **without re-resolving anything**.
5088    ///
5089    /// `05-infrastructure.md` item 7. Everything the new registration carries —
5090    /// the absolute binary path, the arguments, the restart policy, the four
5091    /// directories — comes from the existing record, so the operator is not
5092    /// asked to reinstall the product and a binary that has since moved is not
5093    /// silently swapped for whichever one happens to be running this command.
5094    ///
5095    /// On Windows the registration necessarily moves between two different
5096    /// Windows facilities, because Windows has no service that starts at logon;
5097    /// see this module's documentation. That is still not a reinstall: no file
5098    /// is downloaded, replaced, or re-resolved.
5099    ///
5100    /// The caller must persist the new mode onto this host's `Host` record so
5101    /// that `host show` reports it — [`StartModeChange::to`] is the value, and
5102    /// `f1` already emits `host.service_start_mode` from that field.
5103    ///
5104    /// # Errors
5105    ///
5106    /// [`ServiceError::NotInstalled`] when there is no record to switch, and
5107    /// whatever the platform reports.
5108    pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
5109        let Some(record) = InstallRecord::read(&self.paths)? else {
5110            return Err(ServiceError::NotInstalled {
5111                name: self.identity.name().to_string(),
5112                operation: "switch the start mode of",
5113            });
5114        };
5115        let from = record.start_mode;
5116        if from == to {
5117            // Nothing moves, so nothing about the root's access control has to.
5118            // Reconciling it here would turn a no-op command into one that can
5119            // fail on a permission it does not need.
5120            return Ok(StartModeChange {
5121                from,
5122                to,
5123                changed: false,
5124                store_scope: crate::secrets::SecretScope::for_start_mode(to),
5125                runner_root: RootAccessSummary::NotApplicable,
5126            });
5127        }
5128
5129        // A real switch only: a no-op touches no service manager at all. Over
5130        // another installation's registration it would move that service.
5131        self.refuse_foreign("switch the start mode of")?;
5132
5133        #[cfg(windows)]
5134        let arguments = {
5135            let mut arguments = record.arguments.clone();
5136            arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
5137            if to == StartMode::Boot {
5138                arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
5139            }
5140            arguments
5141        };
5142        #[cfg(not(windows))]
5143        let arguments = record.arguments.clone();
5144        let plan = InstallPlan::unchecked(
5145            self.identity.clone(),
5146            to,
5147            record.binary.clone(),
5148            record.directories.clone(),
5149        )
5150        .with_arguments(arguments)
5151        .with_restart(record.restart());
5152        let plan = if record.starts_on_demand {
5153            plan.started_on_demand()
5154        } else {
5155            plan
5156        };
5157        let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
5158            Ok(store) => plan.with_secret_guard(store.guard()),
5159            Err(_) => plan,
5160        };
5161
5162        // Install the target domain before touching the live one. This makes a
5163        // failed target install a no-op from the operator's point of view and,
5164        // unlike uninstall-first ordering, never trades a working service for
5165        // an error message. Resolved before the runner root and used after it,
5166        // so that no `?` sits between preparing the directory and the first
5167        // step that knows how to undo it.
5168        let target = self.controls.control(to)?;
5169        // The domain being left, resolved here rather than where it is used for
5170        // the same reason: the only step that removes it runs after the runner
5171        // root has been prepared, and a `?` there would abandon the target
5172        // registration, the record and the directory without a word.
5173        let previous = self.controls.control(from)?;
5174
5175        // The account changes with the mode, and so must the account the runner
5176        // root admits: `04-security-recovery.md` requires the selected identity
5177        // to be *reconciled* when service mode changes, which means adding the
5178        // operator's on the way to login and dropping it again on the way back.
5179        // Before the target install, for the same reason `install` does it
5180        // first — a root that cannot be made safe must not produce a working
5181        // registration.
5182        let root = self.prepare_runner_root(to)?;
5183
5184        let definition = match target.install(&plan) {
5185            Ok(definition) => definition,
5186            Err(cause) => {
5187                return Err(undo_runner_root(
5188                    &root,
5189                    "switch start mode",
5190                    &self.identity,
5191                    cause,
5192                ));
5193            }
5194        };
5195        let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
5196        if let Err(cause) = next_record.write(&self.paths) {
5197            return Err(rolled_back(
5198                retained_runner_root(&root),
5199                target.uninstall(&self.identity),
5200                "switch start mode",
5201                &self.identity,
5202                cause,
5203            ));
5204        }
5205
5206        // Only after the target registration and its durable record exist is
5207        // it safe to remove the old domain. If that last step fails, remove the
5208        // target and put the old record back so status and reality agree.
5209        if let Err(cause) = previous.uninstall(&self.identity) {
5210            let target_rollback = target.uninstall(&self.identity);
5211            let record_rollback = record.write(&self.paths);
5212            return Err(rolled_back(
5213                retained_runner_root(&root),
5214                target_rollback.and(record_rollback),
5215                "switch start mode",
5216                &self.identity,
5217                cause,
5218            ));
5219        }
5220        Ok(StartModeChange {
5221            from,
5222            to,
5223            changed: true,
5224            store_scope: crate::secrets::SecretScope::for_start_mode(to),
5225            runner_root: root.summary().clone(),
5226        })
5227    }
5228
5229    /// Starts the registration now.
5230    ///
5231    /// # Errors
5232    ///
5233    /// [`ServiceError::NotInstalled`], or whatever the platform reports.
5234    pub fn start(&self) -> Result<(), ServiceError> {
5235        let mode = self.own_registration_mode("start")?;
5236        self.controls.control(mode)?.start(&self.identity)
5237    }
5238
5239    /// The domain holding this account's own registration, for `operation`.
5240    ///
5241    /// # Errors
5242    ///
5243    /// [`ServiceError::NotInstalled`] when there is none, and
5244    /// [`ServiceError::ForeignInstallation`] when the only one is another
5245    /// installation's.
5246    fn own_registration_mode(&self, operation: &'static str) -> Result<StartMode, ServiceError> {
5247        match self.registration_for_status()? {
5248            None => Err(ServiceError::NotInstalled {
5249                name: self.identity.name().to_string(),
5250                operation,
5251            }),
5252            Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
5253            Some((registration, None)) => Ok(registration.start_mode),
5254        }
5255    }
5256
5257    /// Stops the registration. Returns whether it was running.
5258    ///
5259    /// # Errors
5260    ///
5261    /// As [`ServiceOperations::start`].
5262    pub fn stop(&self) -> Result<bool, ServiceError> {
5263        let mode = self.own_registration_mode("stop")?;
5264        self.controls.control(mode)?.stop(&self.identity)
5265    }
5266
5267    /// Everything Journey 5 step 4 asks `service status` to report.
5268    ///
5269    /// # Errors
5270    ///
5271    /// [`ServiceError::Record`] when local state cannot be read, and whatever
5272    /// the platform reports. A *stale binary path* is deliberately **not** an
5273    /// error here: it is a reported state, because a status command that
5274    /// refused to print anything else would hide the very facts an operator
5275    /// needs in order to fix it.
5276    ///
5277    /// A record **this account may not read** is reported for the same reason
5278    /// and is the second exception. On a boot-mode host the record was written
5279    /// by `sudo service install`, and a version before this one wrote it `0600`
5280    /// — so the operator's own `service status` ended with `Permission denied`
5281    /// and printed nothing at all, on a host whose registration the service
5282    /// manager would have described perfectly well. What launchd, systemd or
5283    /// the SCM says is a separate fact from the record, and it is still worth
5284    /// having.
5285    ///
5286    /// # A registration made from another data root
5287    ///
5288    /// The service manager holds one registration per machine under one name,
5289    /// and every account on the machine sees it. When its command line names
5290    /// application-data directories that are not this account's, it is
5291    /// **another installation's service**, and comparing this account's
5292    /// install record against it would report a stale binary, a mismatched
5293    /// start mode and a secret store in the wrong scope, each with a remedy
5294    /// (`service uninstall`, `service install`, `auth login`) that, followed
5295    /// from this account, breaks a healthy service. So it is reported as what
5296    /// it is: see [`ForeignService`].
5297    pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
5298        match self.registration_for_status()? {
5299            Some((registration, Some(directories))) => {
5300                Ok(self.foreign_status(registration, directories))
5301            }
5302            found => {
5303                let (record, record_refused) = match InstallRecord::read(&self.paths) {
5304                    Ok(record) => (record, None),
5305                    Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
5306                    Err(error) => return Err(error),
5307                };
5308                self.status_at(
5309                    &self.paths,
5310                    record,
5311                    record_refused.as_ref(),
5312                    found.map(|(registration, _)| registration),
5313                )
5314            }
5315        }
5316    }
5317
5318    /// [`Self::status`] for the installation whose directories are `paths`,
5319    /// from the record already read there.
5320    fn status_at(
5321        &self,
5322        paths: &AppPaths,
5323        record: Option<InstallRecord>,
5324        record_refused: Option<&ServiceError>,
5325        found: Option<Registration>,
5326    ) -> Result<ServiceStatus, ServiceError> {
5327        let last_github_contact = last_github_contact(paths)?;
5328        let drift = record
5329            .as_ref()
5330            .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, record));
5331        let credential_rejected_since = github_credential_rejected_since(paths).ok().flatten();
5332        let credential_unreadable_since = credential_unreadable_since(paths).ok().flatten();
5333        let probed = self.controls.unattended_login();
5334        let unattended = record.as_ref().and_then(|record| {
5335            // The account is only read where something was probed to judge
5336            // it against.
5337            let account = probed.as_ref().map_or_else(String::new, |_| {
5338                found
5339                    .as_ref()
5340                    .and_then(|registration| registration.account.clone())
5341                    .unwrap_or_else(|| service_account_name(&record.account))
5342            });
5343            unattended_gap(record.start_mode, &account, probed.as_ref())
5344        });
5345        let now = Utc::now();
5346        let installed = record.is_some() || found.is_some();
5347        let status = ServiceStatus::compose(
5348            self.identity.clone(),
5349            record,
5350            record_refused,
5351            found,
5352            last_github_contact,
5353            paths,
5354        )
5355        .with_definition_drift(drift)
5356        .with_credential_rejection(credential_rejected_since)
5357        .with_credential_unreadable(credential_unreadable_since)
5358        .with_launches_blocked(crate::launch_health::launches_blocked(paths, now))
5359        .with_unattended_gap(unattended)
5360        .with_host_unfit(
5361            crate::host_fitness::host_unfit_in_force(paths, now).filter(|_| installed),
5362        );
5363        let restart = status.restart_command();
5364        Ok(status.with_stall(
5365            crate::daemon_heartbeat::liveness(paths, now).stall(last_github_contact, &restart),
5366        ))
5367    }
5368
5369    /// The installed definition, when it is not what this build renders for
5370    /// the recorded installation. See [`definition_drift`].
5371    ///
5372    /// # Errors
5373    ///
5374    /// Whatever reading the install record reports.
5375    pub fn definition_drift(&self) -> Result<Option<DefinitionDrift>, ServiceError> {
5376        Ok(InstallRecord::read(&self.paths)?
5377            .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, &record)))
5378    }
5379
5380    /// The directories the recorded systemd boot unit lets the service write,
5381    /// whether or not the unit has drifted; empty for any other registration.
5382    ///
5383    /// # Errors
5384    ///
5385    /// Whatever reading the install record reports.
5386    pub fn boot_unit_writable_directories(&self) -> Result<Vec<PathBuf>, ServiceError> {
5387        Ok(InstallRecord::read(&self.paths)?
5388            .filter(|record| {
5389                record.start_mode == StartMode::Boot
5390                    && host_definition_kind(record.start_mode) == DefinitionKind::SystemdUnit
5391            })
5392            .map(|record| {
5393                permitted_paths(
5394                    DefinitionKind::SystemdUnit,
5395                    &record.plan(self.identity.clone()),
5396                )
5397                .into_iter()
5398                .map(PathBuf::from)
5399                .collect()
5400            })
5401            .unwrap_or_default())
5402    }
5403
5404    /// Writes the plist this build renders over an outdated one, then has a
5405    /// detached helper boot the job out and bootstrap it again from the new
5406    /// file.
5407    ///
5408    /// launchd reads a plist only when the job is bootstrapped. Rewriting the
5409    /// file changes nothing for a job that is already loaded, and a `KeepAlive`
5410    /// restart reuses the loaded copy. So the job has to be booted out, and
5411    /// that terminates the daemon that asked for it. The helper runs in a
5412    /// session of its own, which launchd does not kill with the job, and
5413    /// retries the bootstrap: launchd refuses it for a few seconds while the
5414    /// old job is still exiting (`5: Input/output error`, measured).
5415    ///
5416    /// Call this only from the daemon the job runs, and only while it holds no
5417    /// runner. It is an operation on the registration the daemon itself runs
5418    /// under, and it never changes the start mode.
5419    ///
5420    /// # Errors
5421    ///
5422    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`] when the
5423    /// plist cannot be written or the helper cannot be started. The loaded job
5424    /// is untouched in both cases.
5425    #[cfg(target_os = "macos")]
5426    pub fn reload_launchd_definition(&self, drift: &DefinitionDrift) -> Result<(), ServiceError> {
5427        // The record names the start mode; the account this process runs as has
5428        // to agree, or the record describes a registration other than the one
5429        // running, and root would end up rewriting an operator's LaunchAgent.
5430        // SAFETY: `getuid` reads the calling process's real user id and cannot
5431        // fail.
5432        let root = unsafe { libc::getuid() } == 0;
5433        if root != (drift.start_mode == StartMode::Boot) {
5434            return Err(ServiceError::Control {
5435                operation: "rewrite",
5436                name: self.identity.name().to_string(),
5437                manager: "launchd",
5438                detail: format!(
5439                    "the install record says --start-at {}, but this daemon runs as {}; the plist \
5440                     is left as it is",
5441                    drift.start_mode,
5442                    if root { "root" } else { "the operator" }
5443                ),
5444            });
5445        }
5446        replace_definition(self.identity.name(), drift)?;
5447        // The definition is now this build's, as after the WSL unit rewrite.
5448        // Best effort: the record says who wrote the plist and nothing reads it
5449        // to decide whether to repair.
5450        if let Ok(Some(mut record)) = InstallRecord::read(&self.paths) {
5451            record.installed_by_version = env!("CARGO_PKG_VERSION").to_string();
5452            let _ = record.write(&self.paths);
5453        }
5454        sys::reload_detached(&self.identity, drift.start_mode, &drift.path)
5455    }
5456
5457    /// Creates or reconciles the runner root this start mode's account needs.
5458    ///
5459    /// The account is not an argument: it is [`ServiceAccount::for_start_mode`],
5460    /// the same function the registration's own principal comes from, so the
5461    /// directory admits exactly the identity the definition registers and a mode
5462    /// change reconciles both together or neither.
5463    ///
5464    /// On macOS and Linux this is a no-op that returns
5465    /// [`RootAccessSummary::NotApplicable`]; see [`crate::runner_root_access`].
5466    fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
5467        #[cfg(not(windows))]
5468        {
5469            // `runner_root` is consumed here as well as in the Windows arm: a
5470            // field only one platform reads is a dead field on the other.
5471            let _ = (mode, &self.runner_root);
5472            Ok(RootAccessChange::not_applicable())
5473        }
5474        #[cfg(windows)]
5475        {
5476            let wrap = |source| ServiceError::RunnerRoot {
5477                source: Box::new(source),
5478            };
5479            // The test seam, and the two things allowed through it. `cfg!(test)`
5480            // is false in every shipped binary, so what a released build honours
5481            // is the fixture name alone — and a fixture name cannot be the
5482            // product's, which is what keeps a released `service install`
5483            // pointed at the platform default whatever a caller passes.
5484            //
5485            // An overridden root is always reconciled **for this account**,
5486            // which is the foreground admission — a real mode of the product
5487            // rather than a concession invented here. It has to be: a boot-mode
5488            // root admits `SY` and `BA` only, and a test process holding an
5489            // ordinary filtered token is neither, so it could not inspect the
5490            // temporary directory it just supplied nor delete it afterwards.
5491            // What that costs is that the *boot* descriptor is not proved
5492            // through this path; it is proved purely, by this module's
5493            // `the_runner_root_a_boot_registration_needs_admits_only_the_service`
5494            // and by `runner_root_access`'s own tests, and for real by the
5495            // privileged installer test, which runs elevated.
5496            if let Some(root) = self
5497                .runner_root
5498                .as_ref()
5499                .filter(|_| self.identity.is_fixture() || cfg!(test))
5500            {
5501                let admission = RootAdmission::of_this_account().map_err(wrap)?;
5502                return crate::runner_root_access::reconcile(&self.paths, root, &admission)
5503                    .map_err(wrap);
5504            }
5505
5506            let admission = match ServiceAccount::for_start_mode(mode) {
5507                // A boot registration runs as LocalSystem, which the constant
5508                // `SY` ace already names.
5509                ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
5510                // A login registration runs as this account, under a filtered
5511                // token in which Administrators is deny-only — so without an
5512                // ace of its own it would be admitted by nothing.
5513                ServiceAccount::InvokingUser | ServiceAccount::Root => {
5514                    RootAdmission::of_this_account().map_err(wrap)?
5515                }
5516            };
5517            crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
5518        }
5519    }
5520
5521    /// Takes the single-instance lock, or refuses with `d1`'s own message.
5522    fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
5523        HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
5524            |source| match source {
5525                held @ LockError::Held { .. } => ServiceError::LockHeld {
5526                    source: Box::new(held),
5527                },
5528                other => ServiceError::LockUnreadable {
5529                    source: Box::new(other),
5530                },
5531            },
5532        )
5533    }
5534
5535    /// The registration, in whichever domain holds it.
5536    fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
5537        for mode in [StartMode::Boot, StartMode::Login] {
5538            let control = self.controls.control(mode)?;
5539            if let Some(registration) = control.query(&self.identity)? {
5540                return Ok(Some((registration.start_mode, registration)));
5541            }
5542        }
5543        Ok(None)
5544    }
5545
5546    /// The registration [`Self::status`] describes, with the directories it
5547    /// runs against when they are not this account's: this account's own
5548    /// registration when either domain holds one, else whichever there is.
5549    ///
5550    /// [`Self::find_registration`] stops at the boot domain, so a boot service
5551    /// made from another data root would hide this account's own login
5552    /// registration. The other domain is asked only in that case, and a
5553    /// failure to ask it is not this status's failure.
5554    fn registration_for_status(
5555        &self,
5556    ) -> Result<Option<(Registration, Option<ServiceDirectories>)>, ServiceError> {
5557        let Some((mode, found)) = self.find_registration()? else {
5558            return Ok(None);
5559        };
5560        let Some(directories) = self.foreign_directories(&found) else {
5561            return Ok(Some((found, None)));
5562        };
5563        let other = match mode {
5564            StartMode::Boot => StartMode::Login,
5565            StartMode::Login => StartMode::Boot,
5566        };
5567        let own = self
5568            .controls
5569            .control(other)
5570            .ok()
5571            .and_then(|control| control.query(&self.identity).ok().flatten())
5572            .filter(|registration| self.foreign_directories(registration).is_none());
5573        Ok(Some(match own {
5574            Some(own) => (own, None),
5575            None => (found, Some(directories)),
5576        }))
5577    }
5578
5579    /// Moves a Linux boot service registered against another tree onto this
5580    /// one, which for `root` is the system tree. Returns whether it did.
5581    ///
5582    /// `service install` calls this before it opens anything in this tree, so
5583    /// that the copy lands in an empty one; the install itself calls it again,
5584    /// which then only confirms the registration is to be replaced.
5585    ///
5586    /// Before the system tree, a boot service ran against whichever tree
5587    /// `sudo` resolved: `/root`'s on some distributions, the operator's own on
5588    /// others. A boot unit with no capabilities cannot open the operator's, and
5589    /// `ProtectHome=` hides both, so the service is stopped, its configuration
5590    /// and state are copied here unless this tree already has its own, and the
5591    /// install then replaces the registration. The old tree is left as it was.
5592    ///
5593    /// # Errors
5594    ///
5595    /// Whatever stopping the old service or copying its tree reports.
5596    pub fn adopt_boot_installation(
5597        &self,
5598        mode: StartMode,
5599        idle: &dyn Fn(&ServiceDirectories) -> Result<(), String>,
5600    ) -> Result<bool, ServiceError> {
5601        #[cfg(target_os = "linux")]
5602        return self.adopt_boot_installation_on_linux(mode, idle);
5603        #[cfg(not(target_os = "linux"))]
5604        {
5605            let _ = (mode, idle);
5606            Ok(false)
5607        }
5608    }
5609
5610    #[cfg(target_os = "linux")]
5611    fn adopt_boot_installation_on_linux(
5612        &self,
5613        mode: StartMode,
5614        idle: &dyn Fn(&ServiceDirectories) -> Result<(), String>,
5615    ) -> Result<bool, ServiceError> {
5616        if mode != StartMode::Boot {
5617            return Ok(false);
5618        }
5619        self.refuse_overridden_account()?;
5620        if !crate::host_fitness::is_elevated() {
5621            return Ok(false);
5622        }
5623        let Some((StartMode::Boot, found)) = self.find_registration()? else {
5624            return Ok(false);
5625        };
5626        let Some(old) = self.foreign_directories(&found) else {
5627            return Ok(false);
5628        };
5629        let deferred = |reason: String| ServiceError::MoveDeferred {
5630            from: old.config.clone(),
5631            reason,
5632        };
5633        // The moved unit hides `/root` and `/home`, so a setting of the
5634        // operator's that points into the old tree would point nowhere.
5635        let unit = self.identity.systemd_unit();
5636        // Every directory systemd reads a unit's drop-ins from.
5637        let trees = [&old.config, &old.state, &old.runtime, &old.logs];
5638        if let Some(drop_in) = [
5639            SYSTEMD_SYSTEM_DIR,
5640            "/run/systemd/system",
5641            "/usr/lib/systemd/system",
5642        ]
5643        .iter()
5644        .find_map(|base| {
5645            operator_drop_in_naming(&Path::new(base).join(format!("{unit}.d")), &trees)
5646        }) {
5647            return Err(deferred(format!(
5648                "{} names that tree, and the moved service could not see it; change or remove it, \
5649                 run `sudo systemctl daemon-reload`, and run this again",
5650                drop_in.display()
5651            )));
5652        }
5653        let control = self.controls.control(StartMode::Boot)?;
5654        let was_running = control.stop(&self.identity)?;
5655        // Asked once the service is stopped, so no runner can start after: a
5656        // runner keeps running when the service stops (`KillMode=process`),
5657        // in a directory the moved service could not see. Asked of the tree
5658        // the registration names, which is not always the caller's own.
5659        if let Err(reason) = idle(&old) {
5660            // Unchanged: a service the operator had stopped stays stopped.
5661            if was_running {
5662                control.start(&self.identity)?;
5663            }
5664            return Err(deferred(reason));
5665        }
5666        // No service was ever installed against this tree, so whatever is in it
5667        // is what an interrupted attempt or a stray command left: set aside,
5668        // never deleted, in favour of the data the running service used. A
5669        // tree with its own install record keeps it.
5670        if matches!(InstallRecord::read(&self.paths), Ok(None)) {
5671            let stamp = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
5672            for (from, to, skip) in [
5673                (&old.config, self.paths.config_dir(), None),
5674                // `service install` copies the binary in again.
5675                (&old.state, self.paths.state_dir(), Some("bin")),
5676            ] {
5677                set_aside(to, &stamp)
5678                    .and_then(|()| copy_tree(from, to, skip))
5679                    .map_err(|error| ServiceError::Control {
5680                        operation: "move the data of",
5681                        name: self.identity.name().to_string(),
5682                        manager: "systemd",
5683                        detail: format!(
5684                            "cannot copy {} to {}: {error}",
5685                            from.display(),
5686                            to.display()
5687                        ),
5688                    })?;
5689            }
5690        }
5691        Ok(true)
5692    }
5693
5694    /// Refuses a Linux boot install over a unit an administrator's drop-in made
5695    /// run as another account.
5696    ///
5697    /// A drop-in outlives the unit file `service install` rewrites, so the new
5698    /// unit would run as that account too, and that account cannot open the
5699    /// system tree `root` owns. Said before anything is stopped or copied.
5700    #[cfg(target_os = "linux")]
5701    fn refuse_overridden_account(&self) -> Result<(), ServiceError> {
5702        let Some((StartMode::Boot, found)) = self.find_registration()? else {
5703            return Ok(());
5704        };
5705        match found.account.as_deref() {
5706            Some(account) if account != ServiceAccount::Root.as_str() => {
5707                Err(ServiceError::Control {
5708                    operation: "install",
5709                    name: self.identity.name().to_string(),
5710                    manager: "systemd",
5711                    detail: format!(
5712                        "a drop-in under {SYSTEMD_SYSTEM_DIR}/{}.d/ makes it run as {account}, and \
5713                         a boot service must run as root to use {}. Remove `User=` and `Group=` \
5714                         from that drop-in, run `sudo systemctl daemon-reload`, and install again",
5715                        self.identity.systemd_unit(),
5716                        crate::paths::SYSTEM_DATA_ROOT,
5717                    ),
5718                })
5719            }
5720            _ => Ok(()),
5721        }
5722    }
5723
5724    /// Refuses `operation` when the machine's registration is another
5725    /// installation's. See [`ServiceError::ForeignInstallation`].
5726    fn refuse_foreign(&self, operation: &'static str) -> Result<(), ServiceError> {
5727        match self.registration_for_status()? {
5728            Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
5729            _ => Ok(()),
5730        }
5731    }
5732
5733    /// [`ServiceError::ForeignInstallation`] for the installation at
5734    /// `directories`.
5735    fn foreign_refusal(
5736        &self,
5737        operation: &'static str,
5738        directories: ServiceDirectories,
5739    ) -> ServiceError {
5740        let foreign = self.foreign_service_at(directories);
5741        ServiceError::ForeignInstallation {
5742            name: self.identity.name().to_string(),
5743            operation,
5744            installed_by: foreign.installed_by(),
5745            act_as: foreign.act_as(),
5746        }
5747    }
5748
5749    /// The directories `registration` runs against, when they are not this
5750    /// account's. See [`ForeignService`].
5751    fn foreign_directories(&self, registration: &Registration) -> Option<ServiceDirectories> {
5752        ServiceDirectories::from_command_line(&registration.command_line)
5753            .filter(|directories| !same_directory(&directories.config, self.paths.config_dir()))
5754    }
5755
5756    /// The machine's service and the installation it belongs to, when that is
5757    /// not this account's, without reading anything of that installation's
5758    /// beyond its path. `None` when the service is this account's, when there
5759    /// is none, or when the service manager cannot be asked.
5760    #[must_use]
5761    pub fn foreign_service(&self) -> Option<ForeignService> {
5762        let (_, directories) = self.registration_for_status().ok()??;
5763        Some(self.foreign_service_at(directories?))
5764    }
5765
5766    /// The [`ForeignService`] for an installation at `directories`, before
5767    /// anything of its own is read.
5768    fn foreign_service_at(&self, directories: ServiceDirectories) -> ForeignService {
5769        let root = directories.root();
5770        let home = host_home();
5771        // This account's own installation under other directories, when the
5772        // four are exactly a layout a command of this account resolves.
5773        let this_account = home
5774            .as_deref()
5775            .filter(|home| contains_directory(home, &root))
5776            .and_then(|_| {
5777                if directories == ServiceDirectories::of(&AppPaths::rooted_at(&root)) {
5778                    Some(format!(
5779                        "with `--data-dir {}`",
5780                        quote_argument(&root.to_string_lossy())
5781                    ))
5782                } else {
5783                    AppPaths::discover()
5784                        .ok()
5785                        .filter(|standard| directories == ServiceDirectories::of(standard))
5786                        .map(|_| "without `--data-dir`".to_owned())
5787                }
5788            });
5789        ForeignService {
5790            owner: data_root_owner(&root, home.as_deref()),
5791            this_account,
5792            root,
5793            directories,
5794            local_root: ServiceDirectories::of(&self.paths).root(),
5795            local_record: InstallRecord::path(&self.paths),
5796            unused_local_record: InstallRecord::read(&self.paths).ok().flatten(),
5797            unreadable: None,
5798        }
5799    }
5800
5801    /// [`Self::status`] for a registration made from another data root.
5802    ///
5803    /// What that installation recorded is read when this account may read it,
5804    /// and the status is then exactly the one its owner would see. When it may
5805    /// not, the status holds what the service manager says and nothing it
5806    /// would have to guess.
5807    fn foreign_status(
5808        &self,
5809        registration: Registration,
5810        directories: ServiceDirectories,
5811    ) -> ServiceStatus {
5812        let service_paths = directories.paths();
5813        let mut foreign = self.foreign_service_at(directories);
5814        let unreadable = match InstallRecord::read(&service_paths) {
5815            Ok(Some(record)) => {
5816                match self.status_at(
5817                    &service_paths,
5818                    Some(record),
5819                    None,
5820                    Some(registration.clone()),
5821                ) {
5822                    Ok(status) => return status.with_foreign(foreign),
5823                    Err(error) => error.to_string(),
5824                }
5825            }
5826            Ok(None) => format!(
5827                "there is no install record at {}",
5828                InstallRecord::path(&service_paths).display()
5829            ),
5830            // Not the error's own words: `RecordNotPermitted` and
5831            // `RecordUnreadable` both end in a `service install` remedy, which
5832            // from this account would re-register the machine's service
5833            // against the wrong directories.
5834            Err(
5835                ServiceError::RecordNotPermitted { path, detail }
5836                | ServiceError::RecordUnreadable { path, detail }
5837                | ServiceError::Record { path, detail, .. },
5838            ) => format!("this account cannot read {} ({detail})", path.display()),
5839            Err(error) => error.to_string(),
5840        };
5841        foreign.unreadable = Some(unreadable);
5842        ServiceStatus::foreign_unread(self.identity.clone(), registration, foreign)
5843    }
5844
5845    /// Moves this account's own configuration aside, when no registration on
5846    /// this machine runs against it: `host forget-local`.
5847    ///
5848    /// Everything in the configuration directory -- the install record, the
5849    /// database with its host row and policies, `runner.env` and the rest -- is
5850    /// **renamed** into a `forgotten-<timestamp>` directory beside it, never
5851    /// deleted, so moving it back restores it exactly. The state, runtime and
5852    /// log directories are left alone even where the platform puts them inside
5853    /// the configuration directory (macOS does), and so is every secret store
5854    /// and every directory a registration names.
5855    ///
5856    /// Both domains are asked, strictly: a domain that cannot be asked might
5857    /// hold this account's own registration, so it refuses rather than reads
5858    /// as empty.
5859    ///
5860    /// # Errors
5861    ///
5862    /// [`ServiceError::LocalConfigurationInUse`] when a registration on this
5863    /// machine runs against these directories, when a registration from
5864    /// another data root names a directory overlapping them, or when an agent
5865    /// holds this account's single-instance lock. [`ServiceError::Record`] when
5866    /// the move fails; whatever had already moved is put back first, and
5867    /// anything that could not be put back is named.
5868    pub fn forget_local(&self, at: DateTime<Utc>) -> Result<ForgottenLocal, ServiceError> {
5869        let config = self.paths.config_dir();
5870        let in_use = |detail: String| ServiceError::LocalConfigurationInUse { detail };
5871        let mut service_root = None;
5872        for mode in [StartMode::Boot, StartMode::Login] {
5873            let registration = self
5874                .controls
5875                .control(mode)
5876                .and_then(|control| control.query(&self.identity))
5877                .map_err(|error| {
5878                    in_use(format!(
5879                        "whether a {mode} registration runs against it could not be read ({error})"
5880                    ))
5881                })?;
5882            let Some(registration) = registration else {
5883                continue;
5884            };
5885            let Some(directories) = self.foreign_directories(&registration) else {
5886                return Err(in_use(format!(
5887                    "{} holds a registration that runs against {}. `service status` describes it",
5888                    registration.manager,
5889                    config.display()
5890                )));
5891            };
5892            if let Some(overlap) = directories.all().into_iter().find(|directory| {
5893                contains_directory(directory, config) || contains_directory(config, directory)
5894            }) {
5895                return Err(in_use(format!(
5896                    "the machine's service runs against {}, which overlaps {}",
5897                    overlap.display(),
5898                    config.display()
5899                )));
5900            }
5901            service_root.get_or_insert_with(|| directories.root());
5902        }
5903        let _lock =
5904            HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(|source| {
5905                in_use(format!(
5906                    "an agent may be running against these directories: {source}"
5907                ))
5908            })?;
5909        let failed = |operation: &'static str, path: &Path| {
5910            let path = path.to_path_buf();
5911            move |error: std::io::Error| ServiceError::Record {
5912                operation,
5913                path,
5914                detail: error.to_string(),
5915            }
5916        };
5917        // Never the agent's own directories, wherever the platform nests them.
5918        let kept = [
5919            self.paths.state_dir(),
5920            self.paths.runtime_dir(),
5921            self.paths.logs_dir(),
5922        ];
5923        let mut entries = Vec::new();
5924        for entry in std::fs::read_dir(config).map_err(failed("list", config))? {
5925            let entry = entry.map_err(failed("list", config))?.path();
5926            let archived = entry
5927                .file_name()
5928                .is_some_and(|name| name.to_string_lossy().starts_with(FORGOTTEN_PREFIX));
5929            if !archived && !kept.iter().any(|kept| contains_directory(&entry, kept)) {
5930                entries.push(entry);
5931            }
5932        }
5933        if entries.is_empty() {
5934            return Ok(ForgottenLocal {
5935                archive: None,
5936                moved: entries,
5937                service_root,
5938            });
5939        }
5940        entries.sort();
5941        let archive = config.join(format!("{FORGOTTEN_PREFIX}{}", at.format("%Y%m%dT%H%M%SZ")));
5942        std::fs::create_dir(&archive).map_err(failed("create", &archive))?;
5943        let inside = |entry: &Path| archive.join(entry.file_name().unwrap_or_default());
5944        for (index, entry) in entries.iter().enumerate() {
5945            if let Err(error) = std::fs::rename(entry, inside(entry)) {
5946                let stranded: Vec<String> = entries[..index]
5947                    .iter()
5948                    .rev()
5949                    .filter(|done| std::fs::rename(inside(done), done).is_err())
5950                    .map(|done| inside(done).display().to_string())
5951                    .collect();
5952                let _ = std::fs::remove_dir(&archive);
5953                let mut detail = error.to_string();
5954                if !stranded.is_empty() {
5955                    detail.push_str(&format!(
5956                        "; these could not be put back and are still in the archive: {}",
5957                        stranded.join(", ")
5958                    ));
5959                }
5960                return Err(ServiceError::Record {
5961                    operation: "archive",
5962                    path: entry.clone(),
5963                    detail,
5964                });
5965            }
5966        }
5967        Ok(ForgottenLocal {
5968            archive: Some(archive),
5969            moved: entries,
5970            service_root,
5971        })
5972    }
5973}
5974
5975/// The name every `host forget-local` archive starts with. A later run leaves
5976/// earlier archives where they are.
5977pub const FORGOTTEN_PREFIX: &str = "forgotten-";
5978
5979/// What [`ServiceOperations::forget_local`] did.
5980#[derive(Debug, Clone, PartialEq, Eq)]
5981pub struct ForgottenLocal {
5982    /// Where the configuration went, or `None` when there was nothing to move.
5983    pub archive: Option<PathBuf>,
5984    /// What was moved, by its old path.
5985    pub moved: Vec<PathBuf>,
5986    /// The data root of the machine's service, when one runs from another
5987    /// installation. Named so the operator can see it was not touched.
5988    pub service_root: Option<PathBuf>,
5989}
5990
5991impl fmt::Display for ForgottenLocal {
5992    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5993        let Some(archive) = &self.archive else {
5994            return write!(
5995                f,
5996                "Nothing to archive: this account's configuration directory is empty."
5997            );
5998        };
5999        writeln!(f, "Archived this account's unused local configuration")?;
6000        writeln!(f, "  archive                   {}", archive.display())?;
6001        for path in &self.moved {
6002            writeln!(f, "  moved                     {}", path.display())?;
6003        }
6004        if let Some(root) = &self.service_root {
6005            writeln!(
6006                f,
6007                "  service data root         {} (untouched)",
6008                root.display()
6009            )?;
6010        }
6011        write!(
6012            f,
6013            "  restore                   move everything in the archive back into its parent \
6014             directory"
6015        )
6016    }
6017}
6018
6019/// Whether two directory spellings name the same place: the same text on
6020/// this platform's terms, or the same canonical directory.
6021fn same_directory(left: &Path, right: &Path) -> bool {
6022    let text = |path: &Path| {
6023        path.to_string_lossy()
6024            .trim_end_matches(['/', '\\'])
6025            .to_owned()
6026    };
6027    same_path_text(&text(left), &text(right))
6028        || matches!(
6029            (std::fs::canonicalize(left), std::fs::canonicalize(right)),
6030            (Ok(left), Ok(right)) if left == right
6031        )
6032}
6033
6034/// Where the remedy starts in a [`StatusProblem`]'s detail: the sentence
6035/// openings every builder above uses for it.
6036const REMEDY_OPENINGS: [&str; 8] = [
6037    ". Run ",
6038    ". If nothing is waiting",
6039    ". Re-register",
6040    ". Switch the start mode",
6041    " Sign in again",
6042    ". Fix: ",
6043    "; to do it now",
6044    "; if the service manager denies",
6045];
6046
6047/// A problem's diagnosis without its remedy. See
6048/// [`ServiceStatus::with_foreign`].
6049///
6050/// A detail that still names a command that changes the registration or the
6051/// credential after the cut -- a builder this list does not know yet -- is
6052/// reduced to the first clause rather than passed on, because the cost of
6053/// showing too little is a shorter sentence and the cost of showing too much
6054/// is a broken service.
6055fn diagnosis_only(detail: &str) -> String {
6056    let cut = REMEDY_OPENINGS
6057        .iter()
6058        .filter_map(|opening| detail.find(opening))
6059        .min()
6060        .unwrap_or(detail.len());
6061    let diagnosis = detail[..cut].trim_end_matches(['.', ' ']);
6062    let names_a_change = |text: &str| {
6063        [
6064            "service install",
6065            "service uninstall",
6066            "auth login",
6067            "launchctl",
6068            "systemctl",
6069        ]
6070        .iter()
6071        .any(|command| text.contains(command))
6072    };
6073    if !names_a_change(diagnosis) {
6074        return diagnosis.to_owned();
6075    }
6076    // Clause ends followed by a space, so a `.` inside a path does not cut.
6077    let end = [", ", "; ", ". "]
6078        .iter()
6079        .filter_map(|separator| diagnosis.find(separator))
6080        .min()
6081        .unwrap_or(diagnosis.len());
6082    let first = &diagnosis[..end];
6083    if names_a_change(first) {
6084        "the installation's own records report this".to_owned()
6085    } else {
6086        first.to_owned()
6087    }
6088}
6089
6090/// A path's components as this platform compares them: case-insensitively
6091/// on Windows.
6092fn path_key(path: &Path) -> Vec<String> {
6093    path.components()
6094        .map(|component| {
6095            let text = component.as_os_str().to_string_lossy();
6096            if cfg!(windows) {
6097                text.to_lowercase()
6098            } else {
6099                text.into_owned()
6100            }
6101        })
6102        .collect()
6103}
6104
6105/// Whether `inner` is `outer` or a directory under it, by the rule
6106/// [`same_directory`] uses: on this platform's terms, or canonically.
6107fn contains_directory(outer: &Path, inner: &Path) -> bool {
6108    path_key(inner).starts_with(&path_key(outer))
6109        || matches!(
6110            (std::fs::canonicalize(outer), std::fs::canonicalize(inner)),
6111            (Ok(outer), Ok(inner)) if inner.starts_with(&outer)
6112        )
6113}
6114
6115/// The account whose data root `root` is, when the path says: the folder
6116/// under the directory that holds this account's `home`, `root` for root's
6117/// home on Unix, and `LocalSystem` for its profile on Windows. `None` when
6118/// the path names nobody, which is reported as the path alone.
6119#[must_use]
6120pub fn data_root_owner(root: &Path, home: Option<&Path>) -> Option<String> {
6121    let target = path_key(root);
6122    if cfg!(windows) {
6123        if target
6124            .windows(3)
6125            .any(|run| run == ["system32", "config", "systemprofile"])
6126        {
6127            return Some("LocalSystem".to_owned());
6128        }
6129    } else if [
6130        "/root",
6131        "/var/root",
6132        "/private/var/root",
6133        crate::paths::SYSTEM_DATA_ROOT,
6134    ]
6135    .iter()
6136    .any(|home| root.starts_with(home))
6137    {
6138        return Some("root".to_owned());
6139    }
6140    let users = path_key(home?.parent()?);
6141    // A home directly under the root (`/root` under `sudo`) names no directory
6142    // of homes: every path would then "belong" to its first component.
6143    if users.len() < 2 {
6144        return None;
6145    }
6146    if target.len() > users.len() && target[..users.len()] == users[..] {
6147        root.components()
6148            .nth(users.len())
6149            .map(|name| name.as_os_str().to_string_lossy().into_owned())
6150    } else {
6151        None
6152    }
6153}
6154
6155/// A registration whose command line names application-data directories that
6156/// are not this account's: **another installation's service**.
6157///
6158/// Every account on a machine sees the one registration its service manager
6159/// holds under the product's name. A second account -- one that ran a
6160/// login-mode install once, say -- has an install record and a database of its
6161/// own, and comparing them against the other installation's registration used
6162/// to report a stale binary, a mismatched start mode and a secret store in the
6163/// wrong scope, with `service uninstall`, `service install` and `auth login`
6164/// as the remedies. Followed from that account, each of them breaks or
6165/// replaces a service that is healthy. So the registration is reported as the
6166/// other installation's, with what can be read of it, and this account's own
6167/// record as the unused configuration it is.
6168#[derive(Debug, Clone, PartialEq, Eq)]
6169pub struct ForeignService {
6170    /// The directories its command line names.
6171    pub directories: ServiceDirectories,
6172    /// The data root they sit under. See [`ServiceDirectories::root`].
6173    pub root: PathBuf,
6174    /// The account that data root belongs to, when its path says.
6175    pub owner: Option<String>,
6176    /// How a command run by this account reaches the installation, when it is
6177    /// this account's own under other directories -- an install made with
6178    /// `--data-dir`, seen from a command run without it, or the other way
6179    /// round. `None` for another account's.
6180    pub this_account: Option<String>,
6181    /// This account's own data root, which no registration runs against.
6182    pub local_root: PathBuf,
6183    /// Where this account's own install record is, or would be.
6184    pub local_record: PathBuf,
6185    /// This account's own install record, from an earlier install.
6186    pub unused_local_record: Option<InstallRecord>,
6187    /// Why the service's own records could not be read, or `None` when they
6188    /// were and the status describes them.
6189    pub unreadable: Option<String>,
6190}
6191
6192impl ForeignService {
6193    /// How to see the service's own details: as its owner, or elevated.
6194    #[must_use]
6195    pub fn where_to_look(&self) -> String {
6196        if let Some(reach) = &self.this_account {
6197            return reach.clone();
6198        }
6199        let elevated = if cfg!(windows) {
6200            "from an elevated terminal (Run as administrator)"
6201        } else {
6202            "with sudo"
6203        };
6204        match &self.owner {
6205            Some(owner) if owner != "LocalSystem" && owner != "root" => {
6206                format!("{elevated}, or signed in as {owner}")
6207            }
6208            _ => elevated.to_owned(),
6209        }
6210    }
6211
6212    /// Where a change to the service has to be made from: the installation's
6213    /// own account, because every remedy a command prints acts on the
6214    /// directories of whoever runs it. Elevation alone reads the service's
6215    /// records but is still this account.
6216    #[must_use]
6217    pub fn act_as(&self) -> String {
6218        if let Some(reach) = &self.this_account {
6219            return reach.clone();
6220        }
6221        match &self.owner {
6222            Some(owner) if owner != "LocalSystem" && owner != "root" => {
6223                format!("signed in as {owner}")
6224            }
6225            _ => format!(
6226                "from the account whose data root {} is",
6227                self.root.display()
6228            ),
6229        }
6230    }
6231
6232    /// Whether the service's own records could not be read, so only what the
6233    /// service manager says is known.
6234    #[must_use]
6235    pub const fn is_unread(&self) -> bool {
6236        self.unreadable.is_some()
6237    }
6238
6239    /// Who installed it, for a sentence.
6240    #[must_use]
6241    pub fn installed_by(&self) -> String {
6242        match &self.owner {
6243            Some(owner) => format!("{owner}'s data root {}", self.root.display()),
6244            None => format!("the data root {}", self.root.display()),
6245        }
6246    }
6247
6248    /// The data root and its owner, for a field: `<root> (<owner>)`.
6249    #[must_use]
6250    pub fn label(&self) -> String {
6251        match &self.owner {
6252            Some(owner) => format!("{} ({owner})", self.root.display()),
6253            None => self.root.display().to_string(),
6254        }
6255    }
6256
6257    /// The note describing this account's own record, when there is one.
6258    #[must_use]
6259    pub fn unused_local_note(&self) -> Option<String> {
6260        let record = self.unused_local_record.as_ref()?;
6261        Some(format!(
6262            "unused local configuration from an earlier install: {} records a {} registration \
6263             made by runner-manager {} on {}, and no service on this machine runs against it. \
6264             `runner-manager host forget-local` moves it aside; it never touches the service's \
6265             own directories or any secret.",
6266            self.local_record.display(),
6267            record.start_mode,
6268            record.installed_by_version,
6269            record.installed_at.format("%Y-%m-%d"),
6270        ))
6271    }
6272}
6273
6274// ---------------------------------------------------------------------------
6275// Status
6276// ---------------------------------------------------------------------------
6277
6278/// One thing `service status` has to report as wrong.
6279#[derive(Debug, Clone, PartialEq, Eq)]
6280pub struct StatusProblem {
6281    /// What it is about.
6282    pub subject: &'static str,
6283    /// What is wrong, and what to do.
6284    pub detail: String,
6285}
6286
6287impl fmt::Display for StatusProblem {
6288    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6289        write!(f, "{}: {}", self.subject, self.detail)
6290    }
6291}
6292
6293/// Everything `service status` reports.
6294///
6295/// Journey 5 step 4 names four of these — the start mode, the resolved binary
6296/// path, the diagnostic log path, and the last successful GitHub contact — and
6297/// the release gate adds the fifth: *"`service status` reports a stale binary
6298/// path as an error rather than appearing healthy"*. [`Self::is_healthy`] is
6299/// what an exit code should be derived from, and it is false whenever
6300/// [`Self::problems`] is non-empty.
6301#[derive(Debug, Clone)]
6302pub struct ServiceStatus {
6303    identity: ServiceIdentity,
6304    record: Option<InstallRecord>,
6305    registration: Option<Registration>,
6306    binary: Option<BinaryPath>,
6307    log_file: PathBuf,
6308    store: Option<crate::secrets::ActiveStore>,
6309    last_github_contact: Option<DateTime<Utc>>,
6310    runner_root: Option<(PathBuf, RootAccessReport)>,
6311    credential_rejected_since: Option<DateTime<Utc>>,
6312    launches_blocked: Option<crate::launch_health::LaunchesBlocked>,
6313    unattended_gap: Option<UnattendedGap>,
6314    foreign: Option<ForeignService>,
6315    problems: Vec<StatusProblem>,
6316    notes: Vec<String>,
6317}
6318
6319/// Why a registration does not come back by itself after an unattended
6320/// restart, and what an operator can do about it.
6321#[derive(Debug, Clone, PartialEq, Eq)]
6322pub struct UnattendedGap {
6323    /// One or more sentences, the way `service status` prints them.
6324    pub reason: String,
6325    /// What changes it. Never something this product does by itself.
6326    pub remedy: String,
6327}
6328
6329/// The name of the account a registration runs as: the invoking user's own
6330/// for a login-mode one.
6331#[must_use]
6332pub fn service_account_name(account: &ServiceAccount) -> String {
6333    match account {
6334        // Under `sudo` the invoking user is the one sudo names, not root.
6335        ServiceAccount::InvokingUser => std::env::var("SUDO_USER")
6336            .ok()
6337            .filter(|user| !user.is_empty() && crate::host_fitness::is_elevated())
6338            .or_else(crate::unattended_login::current_account)
6339            .unwrap_or_else(|| "the operator".to_owned()),
6340        other => other.as_str().to_owned(),
6341    }
6342}
6343
6344/// The account `registration` names, which an administrator can change from
6345/// the one the install record saved, and otherwise the record's.
6346fn registered_account<'a>(registration: Option<&'a Registration>, recorded: &'a str) -> &'a str {
6347    registration
6348        .and_then(|registration| registration.account.as_deref())
6349        .unwrap_or(recorded)
6350}
6351
6352/// A Windows service account as `QueryServiceConfigW` names it, spelled the
6353/// way [`ServiceAccount`] does, so a service nobody edited reports the account
6354/// the record holds.
6355#[cfg(any(windows, test))]
6356fn windows_service_account(name: &str) -> String {
6357    if name.eq_ignore_ascii_case("LocalSystem") || name.eq_ignore_ascii_case(".\\LocalSystem") {
6358        ServiceAccount::LocalSystem.as_str().to_string()
6359    } else {
6360        name.to_string()
6361    }
6362}
6363
6364/// A Task Scheduler `UserId`, unless it is a raw SID, which names no account
6365/// an operator would recognise.
6366#[cfg(any(windows, test))]
6367fn scheduled_task_account(user_id: Option<String>) -> Option<String> {
6368    user_id.filter(|id| !id.to_ascii_uppercase().starts_with("S-1-"))
6369}
6370
6371/// Works out the gap for a registration in `mode` running as `account`.
6372///
6373/// `found` is `None` where nothing could be probed (every platform but macOS):
6374/// a login registration then keeps the general statement that it waits for a
6375/// sign-in. On a Mac the statement depends on automatic login and FileVault,
6376/// and a login registration that automatic login brings back has no gap. The
6377/// earlier statement was printed for every login registration, so a Mac set to
6378/// sign its runner account in automatically was told it would not resume.
6379#[must_use]
6380pub fn unattended_gap(
6381    mode: StartMode,
6382    account: &str,
6383    found: Option<&crate::unattended_login::UnattendedLogin>,
6384) -> Option<UnattendedGap> {
6385    let Some(found) = found else {
6386        return (mode == StartMode::Login).then(|| UnattendedGap {
6387            reason: "This registration starts at login, so the agent does not run until the \
6388                     operator signs in; this host does not resume work after an unattended \
6389                     reboot."
6390                .to_owned(),
6391            remedy: "For machine-on operation, move the service and credential to `--start-at \
6392                     boot` from an elevated terminal."
6393                .to_owned(),
6394        });
6395    };
6396    let resume = crate::unattended_login::resume(found, mode, account);
6397    if matches!(resume, crate::unattended_login::Resume::Unknown(_)) {
6398        // `host doctor` says what could not be read; a note here would claim
6399        // a gap nobody has seen.
6400        return None;
6401    }
6402    Some(UnattendedGap {
6403        reason: resume.explain(account)?,
6404        remedy: resume.remedy().unwrap_or_else(|| {
6405            "Run `runner-manager host doctor` on the Mac's desktop, where automatic login and \
6406             FileVault can be read."
6407                .to_owned()
6408        }),
6409    })
6410}
6411
6412impl ServiceStatus {
6413    /// `record_refused` carries a [`ServiceError::RecordNotPermitted`] when
6414    /// there **is** a record and this account may not read it, which is not the
6415    /// same state as having none and must not be reported as one. See
6416    /// [`Operations::status`].
6417    fn compose(
6418        identity: ServiceIdentity,
6419        record: Option<InstallRecord>,
6420        record_refused: Option<&ServiceError>,
6421        registration: Option<Registration>,
6422        last_github_contact: Option<DateTime<Utc>>,
6423        paths: &AppPaths,
6424    ) -> Self {
6425        let mut problems = Vec::new();
6426        let mut notes = Vec::new();
6427
6428        if let Some(refusal) = record_refused {
6429            // A problem rather than a note: `is_healthy` stays false, because
6430            // an operator who cannot read their own install record does have
6431            // something to fix. It is just not a reason to withhold everything
6432            // the service manager knows.
6433            problems.push(StatusProblem {
6434                subject: "install record",
6435                detail: refusal.to_string(),
6436            });
6437        }
6438
6439        let log_file = record.as_ref().map_or_else(
6440            || ServiceDirectories::of(paths).log_file(),
6441            |record| record.log_file.clone(),
6442        );
6443
6444        let binary = record.as_ref().map(|record| {
6445            inspect_binary(
6446                &record.binary,
6447                registration
6448                    .as_ref()
6449                    .and_then(Registration::binary)
6450                    .as_deref(),
6451            )
6452        });
6453        if let Some(state) = &binary
6454            && state.is_error()
6455        {
6456            problems.push(StatusProblem {
6457                subject: "binary",
6458                detail: state.to_string(),
6459            });
6460        }
6461
6462        match (&record, &registration) {
6463            (Some(_), None) => problems.push(StatusProblem {
6464                subject: "registration",
6465                detail: "this host has a service record but no service manager knows the \
6466                         registration. Run `service install` again; it deletes nothing."
6467                    .to_string(),
6468            }),
6469            // Not reported when the record was refused rather than absent: the
6470            // problem above already names the real state, and "there is no
6471            // install record" would be a second, wrong diagnosis of the same
6472            // file, with a `service uninstall` in its remedy.
6473            (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
6474                subject: "record",
6475                detail: format!(
6476                    "{} holds a registration for this service but there is no install record, so \
6477                     the path it was installed from and the directories it was installed against \
6478                     are unknown. Run `service uninstall` and `service install`.",
6479                    found.manager
6480                ),
6481            }),
6482            (None, Some(_)) => {}
6483            (Some(record), Some(found)) => {
6484                if record.start_mode != found.start_mode {
6485                    problems.push(StatusProblem {
6486                        subject: "start mode",
6487                        detail: format!(
6488                            "the record says {} and {} holds a {} registration. Switch the start \
6489                             mode again to make them agree.",
6490                            record.start_mode, found.manager, found.start_mode
6491                        ),
6492                    });
6493                }
6494                if record.start_mode == StartMode::Boot && !found.starts_automatically {
6495                    problems.push(StatusProblem {
6496                        subject: "start mode",
6497                        detail: format!(
6498                            "{} holds the registration but will not start it by itself, so this \
6499                             host does not resume work after a reboot.",
6500                            found.manager
6501                        ),
6502                    });
6503                }
6504                if let Some(actual) = found.restart_delay {
6505                    let expected = record.restart().effective_delay(found.manager);
6506                    if actual != expected {
6507                        problems.push(StatusProblem {
6508                            subject: "restart policy",
6509                            detail: format!(
6510                                "the record says the service restarts after {}s and {} reports \
6511                                 {}s. Something has edited the registration since it was \
6512                                 installed.",
6513                                expected.as_secs(),
6514                                found.manager,
6515                                actual.as_secs()
6516                            ),
6517                        });
6518                    } else if expected != record.restart().delay() {
6519                        // Not a fault. Task Scheduler expresses this in whole
6520                        // minutes, so the delay in force is longer than the one
6521                        // asked for -- never shorter, which is the direction
6522                        // the requirement cares about.
6523                        notes.push(format!(
6524                            "{} expresses the restart delay in whole minutes, so the {}s asked \
6525                             for is enforced as {}s. The service therefore never restarts faster \
6526                             than the configured bound.",
6527                            found.manager,
6528                            record.restart().delay().as_secs(),
6529                            expected.as_secs()
6530                        ));
6531                    }
6532                }
6533                if found.starts_automatically && !found.running {
6534                    problems.push(StatusProblem {
6535                        subject: "runtime",
6536                        detail: format!(
6537                            "{} holds an automatic registration, but the daemon is stopped. Run \
6538                             `runner-manager service install --start-at {}`; if the service \
6539                             manager denies replacement, retry from an elevated terminal. If it \
6540                             stops again, inspect {}.",
6541                            found.manager,
6542                            found.start_mode,
6543                            log_file.display()
6544                        ),
6545                    });
6546                }
6547            }
6548            (None, None) => {}
6549        }
6550
6551        // The two facts here really are independent, which is what `f1`'s
6552        // `status.rs` says this check needs and could not have there: the scope
6553        // is derived from the mode the **record** carries, and it is compared
6554        // against the mode the **service manager** actually holds.
6555        let store = record.as_ref().and_then(|record| {
6556            let registered_mode = registration
6557                .as_ref()
6558                .map_or(record.start_mode, |found| found.start_mode);
6559            crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
6560                .ok()
6561                .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
6562        });
6563        if let Some(store) = &store
6564            && !store.agrees_with_start_mode()
6565        {
6566            problems.push(StatusProblem {
6567                subject: "secret store",
6568                detail: format!(
6569                    "{store}. Run `auth login` again so the token is stored where the registered \
6570                     start mode can read it."
6571                ),
6572            });
6573        }
6574
6575        if last_github_contact.is_none() {
6576            notes.push(
6577                "GitHub has not been reached successfully since this host's state directory was \
6578                 created."
6579                    .to_string(),
6580            );
6581        }
6582
6583        // The privileged inspection output. Read-only, tolerant of an account
6584        // that may not read the descriptor at all, and never a `problem`: a
6585        // root this account cannot inspect is a true statement about this
6586        // account's rights, not a fault in the registration. What refuses an
6587        // install is the preflight in `runner_root_access`, which runs as the
6588        // installing account and has the authority to know.
6589        let runner_root = crate::runner_root::default_runner_root(paths)
6590            .ok()
6591            .map(|root| {
6592                let path = root.as_path().to_path_buf();
6593                let report = crate::runner_root_access::report(&path);
6594                (path, report)
6595            });
6596        // Reported, and deliberately not a `problem`. The Definition of Done
6597        // asks for a broad root to be "reported and fail the security
6598        // preflight", and the security preflight is `install`'s -- which runs
6599        // as the installing account and refuses outright. `service status` runs
6600        // as whoever typed it, is expected to be readable on a host with
6601        // nothing installed at all, and drives an exit code; turning a
6602        // directory that predates this feature into a non-zero exit for a
6603        // machine that has never installed the service would report a fault
6604        // that is not this registration's.
6605        if let Some((
6606            path,
6607            RootAccessReport::Present {
6608                broad_write: true, ..
6609            },
6610        )) = &runner_root
6611        {
6612            notes.push(format!(
6613                "the platform default runner root {} can be written by ordinary local users, so \
6614                 it is not a safe place to run jobs. `service install` refuses it rather than \
6615                 tightening it, because the contents of a directory anybody could write cannot \
6616                 be trusted: remove or empty it, or choose another root with `runner-manager \
6617                 host set-runtime-root --path <PATH>`.",
6618                path.display()
6619            ));
6620        }
6621
6622        // The surface the agent has nowhere else to reach. A root that refuses
6623        // a launch does so before any attempt row exists, and the daemon's log
6624        // redacts the paths out of the sentence, so without this the operator
6625        // sees `runner_start_failed reason=other` once per poll and nothing
6626        // that names the directory or the fix.
6627        //
6628        // A **note** and not a problem, by the same rule the broad-write root
6629        // above obeys: `service status` runs as whoever typed it, is expected
6630        // to be readable on a host with nothing installed at all, and drives an
6631        // exit code. A problem here would fail that exit code on three hosts
6632        // that are not broken -- one whose root was fixed but which has had no
6633        // queued job since, one whose service was uninstalled without clearing
6634        // `state/`, and one that only ever ran the agent in the foreground --
6635        // and the printed remedy (`service uninstall && service install`) does
6636        // not touch `state/`, so following it would not clear the error either.
6637        // A note is read by the same operator at the same moment and traps
6638        // nobody in a loop.
6639        //
6640        // A read failure is reported and not swallowed, for the reason
6641        // `runner_root_refusals` documents: absence means "every policy is
6642        // placing runners", and answering a parse failure with that would be
6643        // the wrong answer to the question this row exists for.
6644        match runner_root_refusals(paths) {
6645            Ok(refusals) => {
6646                for refusal in refusals {
6647                    notes.push(format!(
6648                        "policy {} started no runner: its runner root {} refused the launch \
6649                         ({}), last at {}. {} This clears when that policy next places a \
6650                         runner.",
6651                        refusal.policy,
6652                        refusal.root,
6653                        refusal.kind,
6654                        refusal.at.to_rfc3339(),
6655                        refusal.detail,
6656                    ));
6657                }
6658            }
6659            Err(error) => notes.push(format!(
6660                "whether the agent could use its runner roots could not be read: {error}"
6661            )),
6662        }
6663
6664        Self {
6665            identity,
6666            record,
6667            registration,
6668            binary,
6669            log_file,
6670            store,
6671            last_github_contact,
6672            runner_root,
6673            credential_rejected_since: None,
6674            launches_blocked: None,
6675            unattended_gap: None,
6676            foreign: None,
6677            problems,
6678            notes,
6679        }
6680    }
6681
6682    /// Marks a status read from another installation's directories as that
6683    /// installation's. See [`ForeignService`].
6684    ///
6685    /// Its problems were written for its owner, and their remedies -- `service
6686    /// install`, `auth login`, re-registering a drifted definition -- act on
6687    /// the directories of whoever runs them. Printed to this account, which
6688    /// reaches here precisely by following "run it elevated", they would
6689    /// re-register the machine's service against this account's data root.
6690    /// So each keeps its diagnosis and loses its remedy, and says where the
6691    /// remedy has to be run instead.
6692    fn with_foreign(mut self, foreign: ForeignService) -> Self {
6693        let act_as = foreign.act_as();
6694        for problem in &mut self.problems {
6695            problem.detail = format!(
6696                "{}. Act on it {act_as}: a remedy run from this account acts on this account's \
6697                 own directories, not the service's.",
6698                diagnosis_only(&problem.detail)
6699            );
6700        }
6701        if let Some(note) = foreign.unused_local_note() {
6702            self.notes.push(note);
6703        }
6704        self.foreign = Some(foreign);
6705        self
6706    }
6707
6708    /// A registration made from another data root whose records this account
6709    /// cannot read: what the service manager reports, and nothing else.
6710    ///
6711    /// The only problems are ones the service manager alone settles -- an
6712    /// automatic registration that is stopped, a boot registration that will
6713    /// not start by itself -- and their remedy is to look as the owner, never
6714    /// to reinstall from here.
6715    fn foreign_unread(
6716        identity: ServiceIdentity,
6717        registration: Registration,
6718        foreign: ForeignService,
6719    ) -> Self {
6720        let mut problems = Vec::new();
6721        let look = foreign.where_to_look();
6722        if registration.starts_automatically && !registration.running {
6723            problems.push(StatusProblem {
6724                subject: "runtime",
6725                detail: format!(
6726                    "{} holds an automatic registration for the service installed from {}, but \
6727                     it is stopped. Run `runner-manager service status` {look} to see why; do not \
6728                     reinstall it from this account.",
6729                    registration.manager,
6730                    foreign.installed_by(),
6731                ),
6732            });
6733        }
6734        if registration.start_mode == StartMode::Boot && !registration.starts_automatically {
6735            problems.push(StatusProblem {
6736                subject: "start mode",
6737                detail: format!(
6738                    "{} holds the boot registration installed from {} but will not start it by \
6739                     itself. Run `runner-manager service status` {look}.",
6740                    registration.manager,
6741                    foreign.installed_by(),
6742                ),
6743            });
6744        }
6745        let mut notes = Vec::new();
6746        if let Some(reason) = &foreign.unreadable {
6747            notes.push(format!(
6748                "its health is not known here: {reason}. Run `runner-manager service status` \
6749                 {look} to see it."
6750            ));
6751        }
6752        notes.extend(foreign.unused_local_note());
6753        Self {
6754            identity,
6755            record: None,
6756            log_file: foreign.directories.log_file(),
6757            registration: Some(registration),
6758            binary: None,
6759            store: None,
6760            last_github_contact: None,
6761            runner_root: None,
6762            credential_rejected_since: None,
6763            launches_blocked: None,
6764            unattended_gap: None,
6765            foreign: Some(foreign),
6766            problems,
6767            notes,
6768        }
6769    }
6770
6771    /// The other installation this machine's service belongs to, when it is
6772    /// not this account's. See [`ForeignService`].
6773    #[must_use]
6774    pub const fn foreign(&self) -> Option<&ForeignService> {
6775        self.foreign.as_ref()
6776    }
6777
6778    /// Whether this is another installation's service whose own records this
6779    /// account could not read, so only the service manager's facts are here.
6780    #[must_use]
6781    pub fn is_unread(&self) -> bool {
6782        self.foreign.as_ref().is_some_and(ForeignService::is_unread)
6783    }
6784
6785    /// The one-word-or-so verdict `service status` ends with.
6786    #[must_use]
6787    pub fn verdict(&self) -> String {
6788        if !self.is_healthy() {
6789            return "NOT healthy".to_owned();
6790        }
6791        match &self.foreign {
6792            Some(foreign) if foreign.is_unread() => format!(
6793                "{}; installed from {}, so its health is read {}",
6794                if self.is_running() {
6795                    "running"
6796                } else {
6797                    "not running"
6798                },
6799                foreign.installed_by(),
6800                foreign.where_to_look()
6801            ),
6802            _ => "healthy".to_owned(),
6803        }
6804    }
6805
6806    /// Reports a definition this build would render differently.
6807    #[must_use]
6808    pub fn with_definition_drift(mut self, drift: Option<DefinitionDrift>) -> Self {
6809        if let Some(drift) = drift {
6810            if drift.manager == DefinitionKind::LaunchdPlist {
6811                // A restart, not `service install`: the running daemon holds the
6812                // single-instance lock, which refuses an install, and a daemon
6813                // that starts with no runner repairs the plist itself.
6814                let restart = match drift.start_mode {
6815                    StartMode::Boot => format!(
6816                        "sudo launchctl kickstart -k system/{}",
6817                        self.identity.launchd_label()
6818                    ),
6819                    StartMode::Login => format!(
6820                        "launchctl kickstart -k gui/$(id -u)/{}",
6821                        self.identity.launchd_label()
6822                    ),
6823                };
6824                self.problems.push(StatusProblem {
6825                    subject: "definition",
6826                    detail: format!(
6827                        "{} was written by runner-manager {} with a `ProcessType` other than \
6828                         `{LAUNCHD_PROCESS_TYPE}`, which launchd applies to every runner the \
6829                         daemon starts, so their jobs run below normal priority. The daemon \
6830                         rewrites it and reloads itself when it starts with no runner, which it \
6831                         does after every upgrade; to do it now, restart the service while no \
6832                         job is running: `{restart}`. Configuration and credentials are kept.",
6833                        drift.path.display(),
6834                        drift.installed_by_version,
6835                    ),
6836                });
6837                return self;
6838            }
6839            let repair = match drift.start_mode {
6840                StartMode::Boot => format!(
6841                    "`sudo systemctl stop runner-manager && {}` (for a managed WSL host, run \
6842                     `runner-manager wsl install --distribution <name>` from Windows)",
6843                    sudo_command("service install --start-at boot")
6844                ),
6845                StartMode::Login => "`systemctl --user stop runner-manager && runner-manager \
6846                                     service install --start-at login`"
6847                    .to_owned(),
6848            };
6849            self.problems.push(StatusProblem {
6850                subject: "definition",
6851                detail: format!(
6852                    "{} was written by runner-manager {} and differs from what {} renders, so \
6853                     the daemon runs under outdated settings (for example a credential frozen \
6854                     at service start). Re-register it with {repair}; configuration and \
6855                     credentials are kept.",
6856                    drift.path.display(),
6857                    drift.installed_by_version,
6858                    env!("CARGO_PKG_VERSION"),
6859                ),
6860            });
6861        }
6862        self
6863    }
6864
6865    /// Reports that GitHub is rejecting the daemon's credential.
6866    #[must_use]
6867    pub fn with_credential_rejection(mut self, since: Option<DateTime<Utc>>) -> Self {
6868        if let Some(since) = since {
6869            self.credential_rejected_since = Some(since);
6870            self.problems.push(StatusProblem {
6871                subject: "github credential",
6872                detail: format!(
6873                    "GitHub has rejected the service's credential since {}, so the service \
6874                     cannot see queued jobs or start runners. Run `runner-manager auth login` \
6875                     (for a managed WSL host, `runner-manager --host wsl:<name> auth login` \
6876                     from Windows).",
6877                    since.to_rfc3339()
6878                ),
6879            });
6880        }
6881        self
6882    }
6883
6884    /// Since when GitHub has rejected the service's credential, if it has.
6885    #[must_use]
6886    pub const fn credential_rejected_since(&self) -> Option<DateTime<Utc>> {
6887        self.credential_rejected_since
6888    }
6889
6890    /// Reports that the daemon starts no runner because its launches are
6891    /// blocked. See [`crate::launch_health`].
6892    #[must_use]
6893    pub fn with_launches_blocked(
6894        mut self,
6895        blocked: Option<crate::launch_health::LaunchesBlocked>,
6896    ) -> Self {
6897        if let Some(blocked) = &blocked {
6898            self.problems.push(StatusProblem {
6899                subject: LAUNCHES_BLOCKED_SUBJECT,
6900                detail: format!(
6901                    "the service has started no runner {blocked}. Fix: {}.",
6902                    blocked.remedy
6903                ),
6904            });
6905        }
6906        self.launches_blocked = blocked;
6907        self
6908    }
6909
6910    /// Why the service starts no runner, when its launches are blocked.
6911    #[must_use]
6912    pub const fn launches_blocked(&self) -> Option<&crate::launch_health::LaunchesBlocked> {
6913        self.launches_blocked.as_ref()
6914    }
6915
6916    /// Reports that the daemon cannot read its own stored credential, and the
6917    /// exact command that ends it. See [`record_credential_unreadable`].
6918    #[must_use]
6919    pub fn with_credential_unreadable(mut self, since: Option<DateTime<Utc>>) -> Self {
6920        if let Some(since) = since {
6921            let mode = self.start_mode().unwrap_or_default();
6922            self.problems.push(StatusProblem {
6923                subject: CREDENTIAL_UNREADABLE_SUBJECT,
6924                detail: format!(
6925                    "the service has been unable to read its stored GitHub credential since {}, \
6926                     so it starts no runner; the diagnostic log has the store's own answer.{} \
6927                     Sign in again: {}.",
6928                    since.to_rfc3339(),
6929                    if cfg!(target_os = "macos") {
6930                        " That is what a keychain item written by a different build looks like."
6931                    } else {
6932                        ""
6933                    },
6934                    sign_in_instruction(mode)
6935                ),
6936            });
6937        }
6938        self
6939    }
6940
6941    /// What this host's default runner root grants, and to whom.
6942    ///
6943    /// `None` when the platform default could not even be resolved. The
6944    /// descriptor inside has been through
6945    /// [`crate::runner_root_access::redact`], so it names the well-known
6946    /// trustees and says "an account" for everything else — no more identity
6947    /// than the `account` line above it already prints.
6948    #[must_use]
6949    pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
6950        self.runner_root
6951            .as_ref()
6952            .map(|(path, report)| (path.as_path(), report))
6953    }
6954
6955    /// Whether a registration exists at all.
6956    #[must_use]
6957    pub const fn is_installed(&self) -> bool {
6958        self.registration.is_some() || self.record.is_some()
6959    }
6960
6961    /// Whether the daemon is running now.
6962    #[must_use]
6963    pub fn is_running(&self) -> bool {
6964        self.registration
6965            .as_ref()
6966            .is_some_and(|registration| registration.running)
6967    }
6968
6969    /// **False whenever anything is wrong**, including a stale binary path.
6970    #[must_use]
6971    pub fn is_healthy(&self) -> bool {
6972        self.problems.is_empty()
6973    }
6974
6975    /// Everything that is wrong.
6976    #[must_use]
6977    pub fn problems(&self) -> &[StatusProblem] {
6978        &self.problems
6979    }
6980
6981    /// True statements that are not faults — a login-mode registration not
6982    /// running unattended, or an agent that has not yet reached GitHub.
6983    #[must_use]
6984    pub fn notes(&self) -> &[String] {
6985        &self.notes
6986    }
6987
6988    /// Reports that the daemon starts no runner because a required host check
6989    /// fails from its own session. That is the service not doing its job, so
6990    /// it is a problem: a host whose daemon refused every runner used to read
6991    /// `verdict healthy`.
6992    #[must_use]
6993    pub fn with_host_unfit(mut self, unfit: Option<crate::host_fitness::HostUnfitRecord>) -> Self {
6994        if let Some(unfit) = unfit {
6995            self.problems.push(StatusProblem {
6996                subject: HOST_UNFIT_SUBJECT,
6997                detail: format!(
6998                    "the service has started no runner since {}, because a required host check \
6999                     fails from its own session: {}",
7000                    unfit.since.to_rfc3339(),
7001                    unfit.describe()
7002                ),
7003            });
7004        }
7005        self
7006    }
7007
7008    /// Reports a daemon that is running and has stopped making progress. See
7009    /// [`crate::daemon_heartbeat`]: launchd, systemd and the SCM all report
7010    /// such a daemon running, so this used to read `verdict healthy`.
7011    #[must_use]
7012    pub fn with_stall(mut self, stall: Option<String>) -> Self {
7013        if let Some(detail) = stall {
7014            self.problems.push(StatusProblem {
7015                subject: DAEMON_STALLED_SUBJECT,
7016                detail,
7017            });
7018        }
7019        self
7020    }
7021
7022    /// Records whether this registration comes back by itself after an
7023    /// unattended restart, as a note. See [`unattended_gap`].
7024    #[must_use]
7025    pub fn with_unattended_gap(mut self, gap: Option<UnattendedGap>) -> Self {
7026        if let Some(gap) = &gap {
7027            self.notes.push(gap.reason.clone());
7028        }
7029        self.unattended_gap = gap;
7030        self
7031    }
7032
7033    /// Why this registration does not come back by itself after an
7034    /// unattended restart, when it does not.
7035    #[must_use]
7036    pub const fn unattended_gap(&self) -> Option<&UnattendedGap> {
7037        self.unattended_gap.as_ref()
7038    }
7039
7040    /// The service manager's command that restarts this registration, for its
7041    /// start mode (boot when unknown). See [`ServiceIdentity::restart_command`].
7042    #[must_use]
7043    pub fn restart_command(&self) -> String {
7044        self.identity
7045            .restart_command(self.start_mode().unwrap_or(StartMode::Boot))
7046    }
7047
7048    /// The recorded start mode. Journey 5 step 4. For another installation's
7049    /// service whose record this account cannot read, the mode the service
7050    /// manager's domain implies.
7051    #[must_use]
7052    pub fn start_mode(&self) -> Option<StartMode> {
7053        self.record
7054            .as_ref()
7055            .map(|record| record.start_mode)
7056            .or_else(|| {
7057                self.registration
7058                    .as_ref()
7059                    .filter(|_| self.is_unread())
7060                    .map(|registration| registration.start_mode)
7061            })
7062    }
7063
7064    /// The binary the service runs: the recorded one, or for another
7065    /// installation's service whose record this account cannot read, the one
7066    /// the service manager names.
7067    #[must_use]
7068    pub fn service_binary(&self) -> Option<PathBuf> {
7069        self.record
7070            .as_ref()
7071            .map(|record| record.binary.clone())
7072            .or_else(|| {
7073                self.registration
7074                    .as_ref()
7075                    .filter(|_| self.is_unread())
7076                    .and_then(Registration::binary)
7077            })
7078    }
7079
7080    /// The resolved absolute binary path and what became of it. Journey 5
7081    /// step 4 and `05-infrastructure.md` item 6.
7082    #[must_use]
7083    pub const fn binary(&self) -> Option<&BinaryPath> {
7084        self.binary.as_ref()
7085    }
7086
7087    /// The diagnostic log path. `05-infrastructure.md` item 4.
7088    #[must_use]
7089    pub fn log_file(&self) -> &Path {
7090        &self.log_file
7091    }
7092
7093    /// The last successful GitHub contact. Journey 5 step 4.
7094    #[must_use]
7095    pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
7096        self.last_github_contact
7097    }
7098
7099    /// Which store the daemon reads, and whether that agrees with the
7100    /// registration.
7101    #[must_use]
7102    pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
7103        self.store.as_ref()
7104    }
7105
7106    /// The record `install` wrote.
7107    #[must_use]
7108    pub const fn record(&self) -> Option<&InstallRecord> {
7109        self.record.as_ref()
7110    }
7111
7112    /// What the service manager says.
7113    #[must_use]
7114    pub const fn registration(&self) -> Option<&Registration> {
7115        self.registration.as_ref()
7116    }
7117}
7118
7119impl fmt::Display for ServiceStatus {
7120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
7121        writeln!(f, "Service: {}", self.identity)?;
7122        match (&self.record, &self.registration) {
7123            (None, None) => {
7124                writeln!(
7125                    f,
7126                    "  installed                 no. `service install` registers `{} {}`.",
7127                    SERVICE_NAME,
7128                    DAEMON_ARGUMENTS.join(" ")
7129                )?;
7130            }
7131            _ => {
7132                let manager = self
7133                    .registration
7134                    .as_ref()
7135                    .map(|registration| registration.manager.manager());
7136                writeln!(
7137                    f,
7138                    "  installed                 {}",
7139                    manager.unwrap_or("yes, but no service manager knows it")
7140                )?;
7141                writeln!(
7142                    f,
7143                    "  state                     {}",
7144                    if self.is_running() {
7145                        "running"
7146                    } else {
7147                        "not running"
7148                    }
7149                )?;
7150            }
7151        }
7152        if let Some(foreign) = &self.foreign {
7153            writeln!(
7154                f,
7155                "  installed from            {} -- another installation's, not this account's ({})",
7156                foreign.installed_by(),
7157                foreign.local_root.display()
7158            )?;
7159            // Without the record, the service manager's own facts.
7160            if foreign.is_unread()
7161                && let Some(registration) = &self.registration
7162            {
7163                writeln!(f, "  start mode                {}", registration.start_mode)?;
7164                if let Some(account) = &registration.account {
7165                    writeln!(f, "  account                   {account}")?;
7166                }
7167                if let Some(binary) = self.service_binary() {
7168                    writeln!(f, "  binary                    {}", binary.display())?;
7169                }
7170            }
7171        }
7172        if let Some(record) = &self.record {
7173            writeln!(f, "  start mode                {}", record.start_mode)?;
7174            let recorded = record.account.to_string();
7175            let account = registered_account(self.registration.as_ref(), &recorded);
7176            writeln!(f, "  account                   {account}")?;
7177            writeln!(f, "  restart on failure        {}", record.restart())?;
7178            writeln!(
7179                f,
7180                "  arguments                 {}",
7181                record.arguments.join(" ")
7182            )?;
7183        }
7184        if let Some(binary) = &self.binary {
7185            writeln!(f, "  binary                    {binary}")?;
7186        }
7187        writeln!(f, "  diagnostic log            {}", self.log_file.display())?;
7188        if let Some((path, report)) = &self.runner_root {
7189            // Named as the *default* rather than as "the runner root", because
7190            // it is only the effective one until an operator runs
7191            // `host set-runtime-root`. This crate cannot see that setting — it
7192            // lives in the application's store — so an unqualified label here
7193            // would contradict the `runner root … (host-configured)` row that
7194            // `status` and `host show` print from the value that is in force.
7195            writeln!(f, "  default runner root       {}", path.display())?;
7196            if *report != RootAccessReport::NotApplicable {
7197                writeln!(f, "  default root access       {report}")?;
7198            }
7199        }
7200        if let Some(store) = &self.store {
7201            writeln!(f, "  secret store              {store}")?;
7202        }
7203        // Not known, rather than "never", for a service whose records were not
7204        // read: its contact file is in a directory this account cannot open.
7205        if !self.is_unread() {
7206            writeln!(
7207                f,
7208                "  last GitHub contact       {}",
7209                match self.last_github_contact {
7210                    Some(at) => at.to_rfc3339(),
7211                    None => "never".to_string(),
7212                }
7213            )?;
7214        }
7215        for note in &self.notes {
7216            writeln!(f, "  note                      {note}")?;
7217        }
7218        for problem in &self.problems {
7219            writeln!(f, "  ERROR                     {problem}")?;
7220        }
7221        write!(f, "  verdict                   {}", self.verdict())
7222    }
7223}
7224
7225// ---------------------------------------------------------------------------
7226// The in-memory double
7227// ---------------------------------------------------------------------------
7228
7229/// A [`ControlFactory`] that registers nothing and remembers everything.
7230///
7231/// Public rather than `#[cfg(test)]` on purpose. `f3` builds three commands on
7232/// [`ServiceOperations`] and has to be able to test them without a service
7233/// manager, and a double that lived behind `#[cfg(test)]` here would be
7234/// invisible from another crate — which would leave `f3` either untested or
7235/// writing a second double that drifts from this one.
7236///
7237/// It is not a simulator. It stores what it was asked to register and reports it
7238/// back, which is exactly enough to exercise the logic that sits *above* a
7239/// service manager: the lock refusal, the record, the stale-path detection, the
7240/// start-mode switch, and the promise that uninstall deletes nothing else.
7241#[derive(Debug, Clone, Default)]
7242pub struct RecordingControls {
7243    state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
7244}
7245
7246#[derive(Debug, Default)]
7247struct RecordingState {
7248    registrations: BTreeMap<(StartMode, String), Registration>,
7249    definitions: BTreeMap<String, ServiceDefinition>,
7250    calls: Vec<String>,
7251    #[cfg(test)]
7252    install_failures: BTreeMap<StartMode, String>,
7253    #[cfg(test)]
7254    after_install: BTreeMap<StartMode, TestInstallSideEffect>,
7255}
7256
7257#[cfg(test)]
7258#[derive(Debug, Clone)]
7259enum TestInstallSideEffect {
7260    HideDirectory { directory: PathBuf, hidden: PathBuf },
7261}
7262
7263impl RecordingControls {
7264    /// A factory holding no registrations.
7265    #[must_use]
7266    pub fn new() -> Self {
7267        Self::default()
7268    }
7269
7270    /// Every call made through this factory, in order, as
7271    /// `"<operation> <name> (<mode>)"`.
7272    #[must_use]
7273    pub fn calls(&self) -> Vec<String> {
7274        self.state.lock().expect("not poisoned").calls.clone()
7275    }
7276
7277    /// Every registration currently held, with the domain it is in.
7278    #[must_use]
7279    pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
7280        self.state
7281            .lock()
7282            .expect("not poisoned")
7283            .registrations
7284            .iter()
7285            .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
7286            .collect()
7287    }
7288
7289    /// The definition applied for a registration, if it is held.
7290    #[must_use]
7291    pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
7292        self.state
7293            .lock()
7294            .expect("not poisoned")
7295            .definitions
7296            .get(name)
7297            .cloned()
7298    }
7299
7300    /// Edits a held registration, as something outside this product would.
7301    ///
7302    /// This is how the divergence and start-type problems are made reachable
7303    /// from a test: `sc config`, `launchctl` and `systemctl` can all change a
7304    /// registration after installation, and a status command that could not be
7305    /// shown detecting that would be a status command nobody had tested against
7306    /// the case it exists for.
7307    ///
7308    /// Does nothing when no registration of that name is held.
7309    pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
7310        let mut state = self.state.lock().expect("not poisoned");
7311        if let Some((_, registration)) = state
7312            .registrations
7313            .iter_mut()
7314            .find(|((_, held), _)| held == name)
7315        {
7316            edit(registration);
7317        }
7318    }
7319
7320    #[cfg(test)]
7321    fn fail_next_install(&self, mode: StartMode, detail: &str) {
7322        self.state
7323            .lock()
7324            .expect("not poisoned")
7325            .install_failures
7326            .insert(mode, detail.to_string());
7327    }
7328
7329    #[cfg(test)]
7330    fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
7331        self.state
7332            .lock()
7333            .expect("not poisoned")
7334            .after_install
7335            .insert(
7336                mode,
7337                TestInstallSideEffect::HideDirectory { directory, hidden },
7338            );
7339    }
7340}
7341
7342impl ControlFactory for RecordingControls {
7343    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7344        Ok(Box::new(RecordingControl {
7345            mode,
7346            state: std::sync::Arc::clone(&self.state),
7347        }))
7348    }
7349}
7350
7351#[derive(Debug)]
7352struct RecordingControl {
7353    mode: StartMode,
7354    state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
7355}
7356
7357impl RecordingControl {
7358    fn note(&self, operation: &str, name: &str) {
7359        self.state
7360            .lock()
7361            .expect("not poisoned")
7362            .calls
7363            .push(format!("{operation} {name} ({})", self.mode));
7364    }
7365}
7366
7367impl ServiceControl for RecordingControl {
7368    fn manager(&self) -> DefinitionKind {
7369        // The double reports the kind this host's real backend would, so a test
7370        // that asserts on the manager name asserts something true of the
7371        // platform it is running on.
7372        host_definition_kind(self.mode)
7373    }
7374
7375    fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7376        self.note("install", plan.identity().name());
7377        #[cfg(test)]
7378        if let Some(detail) = self
7379            .state
7380            .lock()
7381            .expect("not poisoned")
7382            .install_failures
7383            .remove(&self.mode)
7384        {
7385            return Err(ServiceError::Control {
7386                operation: "install",
7387                name: plan.identity().name().to_string(),
7388                manager: "recording control",
7389                detail,
7390            });
7391        }
7392        // The real definition, not a stub. Rendering is pure, so the double can
7393        // afford it -- and a stub would make `Installed::review` report that the
7394        // definition confirms nothing, which is exactly the shape of false
7395        // assurance a test double must never hand back.
7396        let definition = ServiceDefinition::for_host(plan)?;
7397        let mut state = self.state.lock().expect("not poisoned");
7398        state.registrations.insert(
7399            (self.mode, plan.identity().name().to_string()),
7400            Registration {
7401                manager: host_definition_kind(self.mode),
7402                start_mode: self.mode,
7403                command_line: plan.command_line(),
7404                account: (*plan.account() != ServiceAccount::InvokingUser)
7405                    .then(|| plan.account().as_str().to_string()),
7406                running: false,
7407                starts_automatically: true,
7408                restart_delay: Some(plan.restart().delay()),
7409            },
7410        );
7411        state
7412            .definitions
7413            .insert(plan.identity().name().to_string(), definition.clone());
7414        #[cfg(test)]
7415        let side_effect = state.after_install.remove(&self.mode);
7416        drop(state);
7417        #[cfg(test)]
7418        if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
7419            std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
7420            std::fs::write(&directory, b"blocks recreation")
7421                .expect("test fault can block record directory recreation");
7422        }
7423        Ok(definition)
7424    }
7425
7426    fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7427        self.note("uninstall", identity.name());
7428        let mut state = self.state.lock().expect("not poisoned");
7429        state.definitions.remove(identity.name());
7430        Ok(state
7431            .registrations
7432            .remove(&(self.mode, identity.name().to_string()))
7433            .is_some())
7434    }
7435
7436    fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7437        self.note("query", identity.name());
7438        Ok(self
7439            .state
7440            .lock()
7441            .expect("not poisoned")
7442            .registrations
7443            .get(&(self.mode, identity.name().to_string()))
7444            .cloned())
7445    }
7446
7447    fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7448        self.note("start", identity.name());
7449        let mut state = self.state.lock().expect("not poisoned");
7450        match state
7451            .registrations
7452            .get_mut(&(self.mode, identity.name().to_string()))
7453        {
7454            Some(registration) => {
7455                registration.running = true;
7456                Ok(())
7457            }
7458            None => Err(ServiceError::NotInstalled {
7459                name: identity.name().to_string(),
7460                operation: "start",
7461            }),
7462        }
7463    }
7464
7465    fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7466        self.note("stop", identity.name());
7467        let mut state = self.state.lock().expect("not poisoned");
7468        match state
7469            .registrations
7470            .get_mut(&(self.mode, identity.name().to_string()))
7471        {
7472            Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
7473            None => Err(ServiceError::NotInstalled {
7474                name: identity.name().to_string(),
7475                operation: "stop",
7476            }),
7477        }
7478    }
7479}
7480
7481impl ControlFactory for HostControls {
7482    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7483        sys::control(mode)
7484    }
7485
7486    fn installed_definition(&self, path: &Path) -> Option<String> {
7487        std::fs::read_to_string(path).ok()
7488    }
7489
7490    fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
7491        crate::unattended_login::probe()
7492    }
7493}
7494
7495/// The operator's home directory, when the platform reports one.
7496///
7497/// Only login-mode registrations need it — a LaunchAgent and a systemd user
7498/// unit both live under it — and it is resolved lazily so that a boot-mode
7499/// operation on an account with no profile is not refused for wanting something
7500/// it never uses.
7501#[must_use]
7502pub fn host_home() -> Option<PathBuf> {
7503    directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
7504}
7505
7506/// The Unix backends' name for the same thing.
7507#[cfg(unix)]
7508fn home_directory() -> Option<PathBuf> {
7509    host_home()
7510}
7511
7512/// Runs a command and returns its exit status with both streams as text.
7513///
7514/// The three Unix backends and the Windows Task Scheduler backend all drive a
7515/// stock command-line tool, and all four want the same three things back. The
7516/// tools are chosen for machine-readable, locale-independent output wherever one
7517/// exists — `systemctl is-active`, `launchctl print`, `schtasks /XML` — because
7518/// parsing a localised human-facing table is how a status command starts lying
7519/// on somebody else's machine.
7520fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
7521    let output = std::process::Command::new(program)
7522        .args(arguments)
7523        .output()?;
7524    Ok((
7525        output.status.success(),
7526        String::from_utf8_lossy(&output.stdout).into_owned(),
7527        String::from_utf8_lossy(&output.stderr).into_owned(),
7528    ))
7529}
7530
7531/// Which manager holds a given start-mode domain on the platform this binary
7532/// was built for.
7533#[must_use]
7534pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
7535    if cfg!(windows) {
7536        match mode {
7537            StartMode::Boot => DefinitionKind::WindowsService,
7538            StartMode::Login => DefinitionKind::WindowsScheduledTask,
7539        }
7540    } else if cfg!(target_os = "macos") {
7541        DefinitionKind::LaunchdPlist
7542    } else {
7543        DefinitionKind::SystemdUnit
7544    }
7545}
7546
7547/// Enables a launchd label after it has been bootstrapped.
7548///
7549/// `launchctl bootstrap` deliberately preserves a label's disabled bit. An
7550/// `enable` failure therefore means the new registration cannot satisfy its
7551/// start policy. Compensate by booting it out and removing the plist so a
7552/// failed install cannot look installed to `service status`.
7553#[cfg(any(target_os = "macos", test))]
7554fn enable_launchd_registration(
7555    mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
7556    domain: &str,
7557    service_target: &str,
7558    plist: &Path,
7559    name: &str,
7560    elevation_remedy: &'static str,
7561) -> Result<(), ServiceError> {
7562    let (enabled, cause) = launchctl(&[
7563        std::ffi::OsStr::new("enable"),
7564        std::ffi::OsStr::new(service_target),
7565    ]);
7566    if enabled {
7567        return Ok(());
7568    }
7569
7570    let (booted_out, bootout_detail) = launchctl(&[
7571        std::ffi::OsStr::new("bootout"),
7572        std::ffi::OsStr::new(service_target),
7573    ]);
7574    let removed = std::fs::remove_file(plist);
7575    if !booted_out || removed.is_err() {
7576        return Err(ServiceError::Rollback {
7577            operation: "enable launchd registration",
7578            name: name.to_string(),
7579            cause,
7580            rollback: format!(
7581                "launchctl bootout {domain}: {}; remove {}: {}",
7582                if booted_out {
7583                    "succeeded".to_string()
7584                } else {
7585                    bootout_detail
7586                },
7587                plist.display(),
7588                removed
7589                    .err()
7590                    .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
7591            ),
7592        });
7593    }
7594
7595    if cause.to_ascii_lowercase().contains("permission denied") {
7596        Err(ServiceError::NeedsElevation {
7597            operation: "enable",
7598            name: name.to_string(),
7599            detail: cause,
7600            remedy: elevation_remedy,
7601        })
7602    } else {
7603        Err(ServiceError::Control {
7604            operation: "enable",
7605            name: name.to_string(),
7606            manager: "launchd",
7607            detail: cause,
7608        })
7609    }
7610}
7611
7612// ---------------------------------------------------------------------------
7613// Windows
7614// ---------------------------------------------------------------------------
7615
7616/// A stop request delivered by the Windows Service Control Manager.
7617///
7618/// The application owns the drain policy; this platform boundary only turns
7619/// `SERVICE_CONTROL_STOP`/`SHUTDOWN` into an awaitable notification.
7620#[derive(Debug, Clone)]
7621pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
7622
7623impl ServiceShutdown {
7624    /// Waits until SCM asks the service to stop or shut down.
7625    pub async fn wait(mut self) {
7626        if !*self.0.borrow() {
7627            let _ = self.0.changed().await;
7628        }
7629    }
7630}
7631
7632/// Runs the production process as a real Windows service host.
7633///
7634/// `StartServiceCtrlDispatcher` must run in the service process's main thread,
7635/// so the callback is handed through a process-global slot to the entrypoint
7636/// invoked by SCM. The slot is single-use by design: one process hosts exactly
7637/// one own-process service.
7638#[cfg(windows)]
7639pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
7640where
7641    F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
7642{
7643    windows_host::run(Box::new(run))
7644}
7645
7646#[cfg(windows)]
7647mod windows_host {
7648    use std::ffi::OsString;
7649    use std::sync::{Arc, Mutex, OnceLock, mpsc};
7650    use std::time::Duration;
7651
7652    use windows_service::service::{
7653        ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
7654        ServiceType,
7655    };
7656    use windows_service::service_control_handler::{
7657        self, ServiceControlHandlerResult, ServiceStatusHandle,
7658    };
7659
7660    use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
7661
7662    type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
7663
7664    struct Invocation {
7665        run: Runner,
7666        result: mpsc::SyncSender<Result<u8, String>>,
7667    }
7668
7669    static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
7670
7671    windows_service::define_windows_service!(ffi_service_main, service_main);
7672
7673    pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
7674        let (result_tx, result_rx) = mpsc::sync_channel(1);
7675        let slot = INVOCATION.get_or_init(|| Mutex::new(None));
7676        let mut invocation = slot
7677            .lock()
7678            .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
7679        if invocation.is_some() {
7680            return Err(host_error(
7681                "prepare",
7682                "the service-host slot was already used",
7683            ));
7684        }
7685        *invocation = Some(Invocation {
7686            run,
7687            result: result_tx,
7688        });
7689        drop(invocation);
7690
7691        windows_service::service_dispatcher::start("", ffi_service_main)
7692            .map_err(|error| host_error("connect", &error.to_string()))?;
7693        result_rx
7694            .recv()
7695            .map_err(|error| host_error("finish", &error.to_string()))?
7696            .map_err(|detail| host_error("run", &detail))
7697    }
7698
7699    fn service_main(_arguments: Vec<OsString>) {
7700        let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
7701            return;
7702        };
7703        let result = run_service(invocation.run);
7704        let _ = invocation.result.send(result);
7705    }
7706
7707    fn run_service(run: Runner) -> Result<u8, String> {
7708        let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
7709        let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
7710        let handler_status = Arc::clone(&status);
7711        let handler = move |control| match control {
7712            ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
7713            ServiceControl::Stop | ServiceControl::Shutdown => {
7714                if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
7715                    let _ = handle.set_service_status(service_status(
7716                        ServiceState::StopPending,
7717                        ServiceControlAccept::empty(),
7718                        1,
7719                        Duration::from_secs(300),
7720                        0,
7721                    ));
7722                }
7723                let _ = stop_tx.send(true);
7724                ServiceControlHandlerResult::NoError
7725            }
7726            _ => ServiceControlHandlerResult::NotImplemented,
7727        };
7728        let handle = service_control_handler::register("", handler)
7729            .map_err(|error| format!("cannot register the service control handler: {error}"))?;
7730        *status
7731            .lock()
7732            .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
7733        handle
7734            .set_service_status(service_status(
7735                ServiceState::Running,
7736                ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
7737                0,
7738                Duration::default(),
7739                0,
7740            ))
7741            .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
7742
7743        let exit = run(ServiceShutdown(stop_rx));
7744        handle
7745            .set_service_status(service_status(
7746                ServiceState::Stopped,
7747                ServiceControlAccept::empty(),
7748                0,
7749                Duration::default(),
7750                u32::from(exit),
7751            ))
7752            .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
7753        Ok(exit)
7754    }
7755
7756    fn service_status(
7757        state: ServiceState,
7758        accepted: ServiceControlAccept,
7759        checkpoint: u32,
7760        wait_hint: Duration,
7761        exit: u32,
7762    ) -> ServiceStatus {
7763        ServiceStatus {
7764            service_type: ServiceType::OWN_PROCESS,
7765            current_state: state,
7766            controls_accepted: accepted,
7767            exit_code: if exit == 0 {
7768                ServiceExitCode::Win32(0)
7769            } else {
7770                ServiceExitCode::ServiceSpecific(exit)
7771            },
7772            checkpoint,
7773            wait_hint,
7774            process_id: None,
7775        }
7776    }
7777
7778    fn host_error(operation: &'static str, detail: &str) -> ServiceError {
7779        ServiceError::Control {
7780            operation,
7781            name: SERVICE_NAME.to_string(),
7782            manager: "the Windows Service Control Manager",
7783            detail: detail.to_string(),
7784        }
7785    }
7786}
7787
7788#[cfg(windows)]
7789mod sys {
7790    //! Two managers, because Windows has two answers.
7791    //!
7792    //! `--start-at boot` is a service in the Service Control Manager, which is
7793    //! the only Windows facility that starts something before anybody logs in.
7794    //! `--start-at login` is a Task Scheduler task with a logon trigger, which
7795    //! is the only Windows facility that starts something when somebody does.
7796    //! There is no single mechanism that does both: service trigger-start has no
7797    //! logon trigger, and a scheduled task cannot run before a session exists.
7798
7799    use std::ffi::{OsStr, OsString};
7800    use std::time::{Duration, Instant};
7801
7802    use runner_manager_domain::model::StartMode;
7803    use windows_service::service::{
7804        ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
7805        ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
7806        ServiceState, ServiceType,
7807    };
7808    use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
7809
7810    use super::{
7811        DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
7812        ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
7813    };
7814
7815    /// `ERROR_SERVICE_DOES_NOT_EXIST`. Not an error here: it is the answer to
7816    /// "is this registered".
7817    const SERVICE_DOES_NOT_EXIST: i32 = 1060;
7818    /// `ERROR_SERVICE_MARKED_FOR_DELETE`. `DeleteService` is asynchronous: the
7819    /// registration remains in this state until every open service handle is
7820    /// closed, and callers must not mistake that short window for a leak.
7821    const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
7822    /// `ERROR_ACCESS_DENIED`.
7823    const ACCESS_DENIED: i32 = 5;
7824    const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
7825    const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
7826
7827    const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
7828         choose \"Run as administrator\".";
7829
7830    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7831        Ok(match mode {
7832            StartMode::Boot => Box::new(ScmControl),
7833            StartMode::Login => Box::new(TaskControl),
7834        })
7835    }
7836
7837    // -- the Service Control Manager -----------------------------------------
7838
7839    #[derive(Debug)]
7840    struct ScmControl;
7841
7842    /// Turns a `windows-service` failure into this module's error, keeping the
7843    /// "needs elevation" case separate: an operator told "access is denied" and
7844    /// an operator told "run this elevated" are not equally well served.
7845    fn scm_error(
7846        operation: &'static str,
7847        name: &str,
7848        error: &windows_service::Error,
7849    ) -> ServiceError {
7850        let raw = match error {
7851            windows_service::Error::Winapi(io) => io.raw_os_error(),
7852            _ => None,
7853        };
7854        let detail = match error {
7855            windows_service::Error::Winapi(io) => io.to_string(),
7856            other => other.to_string(),
7857        };
7858        if raw == Some(ACCESS_DENIED) {
7859            return ServiceError::NeedsElevation {
7860                operation,
7861                name: name.to_string(),
7862                detail,
7863                remedy: ELEVATION_REMEDY,
7864            };
7865        }
7866        ServiceError::Control {
7867            operation,
7868            name: name.to_string(),
7869            manager: "the Windows Service Control Manager",
7870            detail,
7871        }
7872    }
7873
7874    fn open_manager(
7875        access: ServiceManagerAccess,
7876        operation: &'static str,
7877        name: &str,
7878    ) -> Result<ServiceManager, ServiceError> {
7879        ServiceManager::local_computer(None::<&OsStr>, access)
7880            .map_err(|error| scm_error(operation, name, &error))
7881    }
7882
7883    impl ServiceControl for ScmControl {
7884        fn manager(&self) -> DefinitionKind {
7885            DefinitionKind::WindowsService
7886        }
7887
7888        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7889            let spec = windows_service_spec(plan);
7890            let manager = open_manager(
7891                ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
7892                "install",
7893                &spec.name,
7894            )?;
7895            let info = ServiceInfo {
7896                name: OsString::from(&spec.name),
7897                display_name: OsString::from(&spec.display_name),
7898                // Never INTERACTIVE_PROCESS: see `windows_service_descriptor`.
7899                service_type: ServiceType::OWN_PROCESS,
7900                start_type: if spec.automatic_start {
7901                    ServiceStartType::AutoStart
7902                } else {
7903                    ServiceStartType::OnDemand
7904                },
7905                error_control: ServiceErrorControl::Normal,
7906                executable_path: plan.binary().to_path_buf(),
7907                launch_arguments: plan.arguments().to_vec(),
7908                dependencies: Vec::new(),
7909                // `None` is LocalSystem, which is the account `d2`'s DACL admits.
7910                account_name: spec.account.as_ref().map(OsString::from),
7911                // No password is ever taken, asked for, or stored. An account
7912                // that needed one would be an account this installer refuses.
7913                account_password: None,
7914            };
7915            let service = manager
7916                .create_service(
7917                    &info,
7918                    ServiceAccess::CHANGE_CONFIG
7919                        | ServiceAccess::QUERY_CONFIG
7920                        | ServiceAccess::QUERY_STATUS
7921                        | ServiceAccess::START
7922                        | ServiceAccess::STOP
7923                        | ServiceAccess::DELETE,
7924                )
7925                .map_err(|error| scm_error("install", &spec.name, &error))?;
7926            service
7927                .set_description(&spec.description)
7928                .map_err(|error| scm_error("describe", &spec.name, &error))?;
7929            service
7930                .update_failure_actions(ServiceFailureActions {
7931                    reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
7932                    reboot_msg: None,
7933                    command: None,
7934                    // Three identical restart actions rather than one: the
7935                    // Service Control Manager applies the first action to the
7936                    // first failure, the second to the second, and the last to
7937                    // every failure after that. One action would leave the
7938                    // second and later failures unhandled, which is a service
7939                    // that comes back once and then stays down.
7940                    actions: Some(vec![
7941                        ServiceAction {
7942                            action_type: ServiceActionType::Restart,
7943                            delay: spec.restart.delay(),
7944                        };
7945                        3
7946                    ]),
7947                })
7948                .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7949            // Without this the failure actions apply only to a crash, and a
7950            // daemon that exits non-zero is not a crash.
7951            service
7952                .set_failure_actions_on_non_crash_failures(true)
7953                .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7954            Ok(ServiceDefinition::windows_service(plan))
7955        }
7956
7957        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7958            let manager =
7959                open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
7960            let service = match manager.open_service(
7961                identity.name(),
7962                ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
7963            ) {
7964                Ok(service) => Some(service),
7965                Err(error) if is_missing(&error) => return Ok(false),
7966                // A previous caller has already asked SCM to delete this
7967                // registration. That is the desired state, not an uninstall
7968                // failure. Wait for SCM to release the name so a following
7969                // `service install` can use it immediately.
7970                Err(error) if is_marked_for_delete(&error) => None,
7971                Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
7972            };
7973            if let Some(service) = service {
7974                // A running service can be deleted, but it lingers until it stops.
7975                // Stopping first is what makes `uninstall` followed by `install`
7976                // work in one sitting, which is what the start-mode switch needs.
7977                if let Ok(status) = service.query_status()
7978                    && status.current_state != ServiceState::Stopped
7979                {
7980                    let _ = service.stop();
7981                }
7982                match service.delete() {
7983                    Ok(()) => {}
7984                    // DeleteService is idempotent from the operator's point of
7985                    // view. Another process can mark the service between our
7986                    // OpenService and DeleteService calls.
7987                    Err(error) if is_marked_for_delete(&error) => {}
7988                    Err(error) => {
7989                        return Err(scm_error("uninstall", identity.name(), &error));
7990                    }
7991                }
7992                drop(service);
7993            }
7994
7995            // `DeleteService` marks a registration for deletion and returns;
7996            // SCM removes it only after the last service handle closes. Drop
7997            // ours before polling, then wait for the observable postcondition
7998            // promised by `uninstall`: an immediate status check must not see
7999            // a registration that is merely on its way out. This also keeps a
8000            // stop/uninstall/install sequence deterministic on busy hosts.
8001            let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
8002                match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
8003                    Ok(service) => {
8004                        drop(service);
8005                        Ok(false)
8006                    }
8007                    Err(error) if is_missing(&error) => Ok(true),
8008                    Err(error) if is_marked_for_delete(&error) => Ok(false),
8009                    Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
8010                }
8011            })?;
8012            if !absent {
8013                return Err(ServiceError::Control {
8014                    operation: "verify uninstall of",
8015                    name: identity.name().to_string(),
8016                    manager: "the Windows Service Control Manager",
8017                    detail: format!(
8018                        "the registration was still visible {} seconds after DeleteService; \
8019                         retry `service uninstall` from an elevated prompt",
8020                        DELETE_TIMEOUT.as_secs()
8021                    ),
8022                });
8023            }
8024            Ok(true)
8025        }
8026
8027        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8028            let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
8029            let service = match manager.open_service(
8030                identity.name(),
8031                ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
8032            ) {
8033                Ok(service) => service,
8034                Err(error) if is_missing(&error) => return Ok(None),
8035                Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
8036            };
8037            let config = service
8038                .query_config()
8039                .map_err(|error| scm_error("inspect", identity.name(), &error))?;
8040            let status = service
8041                .query_status()
8042                .map_err(|error| scm_error("inspect", identity.name(), &error))?;
8043            let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
8044                actions
8045                    .actions
8046                    .and_then(|actions| actions.into_iter().next())
8047                    .filter(|action| action.action_type == ServiceActionType::Restart)
8048                    .map(|action| action.delay)
8049            });
8050            Ok(Some(Registration {
8051                manager: DefinitionKind::WindowsService,
8052                start_mode: StartMode::Boot,
8053                // `lpBinaryPathName` holds the executable *and* its arguments
8054                // as one string, which is why this module carries its own
8055                // parser rather than trusting `executable_path`'s name.
8056                command_line: config.executable_path.to_string_lossy().into_owned(),
8057                account: config
8058                    .account_name
8059                    .map(|account| super::windows_service_account(&account.to_string_lossy())),
8060                running: status.current_state == ServiceState::Running,
8061                starts_automatically: config.start_type == ServiceStartType::AutoStart,
8062                restart_delay,
8063            }))
8064        }
8065
8066        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8067            let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
8068            let service = manager
8069                .open_service(identity.name(), ServiceAccess::START)
8070                .map_err(|error| scm_error("start", identity.name(), &error))?;
8071            service
8072                .start::<&OsStr>(&[])
8073                .map_err(|error| scm_error("start", identity.name(), &error))
8074        }
8075
8076        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8077            let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
8078            let service = manager
8079                .open_service(
8080                    identity.name(),
8081                    ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
8082                )
8083                .map_err(|error| scm_error("stop", identity.name(), &error))?;
8084            let status = service
8085                .query_status()
8086                .map_err(|error| scm_error("stop", identity.name(), &error))?;
8087            if status.current_state == ServiceState::Stopped {
8088                return Ok(false);
8089            }
8090            service
8091                .stop()
8092                .map_err(|error| scm_error("stop", identity.name(), &error))?;
8093            Ok(true)
8094        }
8095    }
8096
8097    fn is_missing(error: &windows_service::Error) -> bool {
8098        matches!(error, windows_service::Error::Winapi(io)
8099            if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
8100    }
8101
8102    pub(super) fn is_marked_for_delete(error: &windows_service::Error) -> bool {
8103        matches!(error, windows_service::Error::Winapi(io)
8104            if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
8105    }
8106
8107    pub(super) fn wait_until_scm_absent(
8108        timeout: Duration,
8109        poll_interval: Duration,
8110        mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
8111    ) -> Result<bool, ServiceError> {
8112        let deadline = Instant::now() + timeout;
8113        loop {
8114            if probe_absent()? {
8115                return Ok(true);
8116            }
8117            if Instant::now() >= deadline {
8118                return Ok(false);
8119            }
8120            std::thread::sleep(poll_interval);
8121        }
8122    }
8123
8124    // -- Task Scheduler ------------------------------------------------------
8125
8126    #[derive(Debug)]
8127    struct TaskControl;
8128
8129    fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
8130        if detail.to_ascii_lowercase().contains("access is denied") {
8131            return ServiceError::NeedsElevation {
8132                operation,
8133                name: name.to_string(),
8134                detail,
8135                remedy: ELEVATION_REMEDY,
8136            };
8137        }
8138        ServiceError::Control {
8139            operation,
8140            name: name.to_string(),
8141            manager: "Windows Task Scheduler",
8142            detail,
8143        }
8144    }
8145
8146    fn schtasks(
8147        operation: &'static str,
8148        name: &str,
8149        arguments: &[&OsStr],
8150    ) -> Result<(bool, String), ServiceError> {
8151        match run("schtasks.exe", arguments) {
8152            Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
8153            Err(error) => Err(task_error(operation, name, error.to_string())),
8154        }
8155    }
8156
8157    /// Task Scheduler reads `/XML` files as UTF-16, so the document is written
8158    /// as UTF-16 little-endian with a byte-order mark rather than as UTF-8.
8159    fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
8160        let mut bytes = vec![0xFF, 0xFE];
8161        for unit in text.encode_utf16() {
8162            bytes.extend_from_slice(&unit.to_le_bytes());
8163        }
8164        std::fs::write(path, bytes)
8165    }
8166
8167    impl ServiceControl for TaskControl {
8168        fn manager(&self) -> DefinitionKind {
8169            DefinitionKind::WindowsScheduledTask
8170        }
8171
8172        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8173            let name = plan.identity().name().to_string();
8174            let principal = TaskPrincipal::current()?;
8175            let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
8176            let directory = tempfile::tempdir()
8177                .map_err(|error| task_error("install", &name, error.to_string()))?;
8178            let document = directory.path().join("task.xml");
8179            write_utf16(&document, definition.text())
8180                .map_err(|error| task_error("install", &name, error.to_string()))?;
8181            let (ok, message) = schtasks(
8182                "install",
8183                &name,
8184                &[
8185                    OsStr::new("/Create"),
8186                    OsStr::new("/TN"),
8187                    OsStr::new(&name),
8188                    OsStr::new("/XML"),
8189                    document.as_os_str(),
8190                ],
8191            )?;
8192            if !ok {
8193                return Err(task_error("install", &name, message));
8194            }
8195            Ok(definition)
8196        }
8197
8198        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8199            if self.query(identity)?.is_none() {
8200                return Ok(false);
8201            }
8202            let name = identity.name().to_string();
8203            let (ok, message) = schtasks(
8204                "uninstall",
8205                &name,
8206                &[
8207                    OsStr::new("/Delete"),
8208                    OsStr::new("/TN"),
8209                    OsStr::new(&name),
8210                    OsStr::new("/F"),
8211                ],
8212            )?;
8213            if !ok {
8214                return Err(task_error("uninstall", &name, message));
8215            }
8216            Ok(true)
8217        }
8218
8219        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8220            let name = identity.name().to_string();
8221            let (ok, document) = schtasks(
8222                "inspect",
8223                &name,
8224                &[
8225                    OsStr::new("/Query"),
8226                    OsStr::new("/TN"),
8227                    OsStr::new(&name),
8228                    OsStr::new("/XML"),
8229                    OsStr::new("ONE"),
8230                ],
8231            )?;
8232            if !ok {
8233                // `schtasks` reports a missing task and a broken Task Scheduler
8234                // the same way, with a non-zero exit; there is no distinct code.
8235                // Treating it as absence is the safe reading: the caller either
8236                // installs, which will then fail loudly, or reports "not
8237                // installed", which is what an operator with no task sees.
8238                return Ok(None);
8239            }
8240            let command = xml_value(&document, "Command").unwrap_or_default();
8241            let arguments = xml_value(&document, "Arguments").unwrap_or_default();
8242            let command_line = if arguments.is_empty() {
8243                super::quote_argument(&command)
8244            } else {
8245                format!("{} {arguments}", super::quote_argument(&command))
8246            };
8247            Ok(Some(Registration {
8248                manager: DefinitionKind::WindowsScheduledTask,
8249                start_mode: StartMode::Login,
8250                command_line,
8251                account: super::scheduled_task_account(xml_value(&document, "UserId")),
8252                running: task_is_running(&name),
8253                starts_automatically: super::windows_login_task_starts_automatically(&document),
8254                restart_delay: xml_value(&document, "Interval")
8255                    .as_deref()
8256                    .and_then(parse_iso8601),
8257            }))
8258        }
8259
8260        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8261            let name = identity.name().to_string();
8262            let (ok, message) = schtasks(
8263                "start",
8264                &name,
8265                &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
8266            )?;
8267            if ok {
8268                Ok(())
8269            } else {
8270                Err(task_error("start", &name, message))
8271            }
8272        }
8273
8274        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8275            let running = self
8276                .query(identity)?
8277                .is_some_and(|registration| registration.running);
8278            if !running {
8279                return Ok(false);
8280            }
8281            let name = identity.name().to_string();
8282            let (ok, message) = schtasks(
8283                "stop",
8284                &name,
8285                &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
8286            )?;
8287            if ok {
8288                Ok(true)
8289            } else {
8290                Err(task_error("stop", &name, message))
8291            }
8292        }
8293    }
8294
8295    /// Whether Task Scheduler reports the task as running.
8296    ///
8297    /// **This is the one value in this module read from localised output.**
8298    /// `schtasks /Query /FO CSV` prints a `Status` column in the machine's
8299    /// display language, and Task Scheduler exposes no locale-independent
8300    /// equivalent short of COM. On a non-English Windows this therefore reports
8301    /// `false` for a task that is in fact running.
8302    ///
8303    /// That is stated rather than hidden because of what does *not* depend on
8304    /// it: Journey 5's liveness question is answered by the last successful
8305    /// GitHub contact, which is a timestamp this product writes itself and no
8306    /// locale can move. Nothing in the Definition of Done rests on this
8307    /// boolean.
8308    fn task_is_running(name: &str) -> bool {
8309        let Ok((true, stdout, _)) = run(
8310            "schtasks.exe",
8311            &[
8312                OsStr::new("/Query"),
8313                OsStr::new("/TN"),
8314                OsStr::new(name),
8315                OsStr::new("/FO"),
8316                OsStr::new("CSV"),
8317                OsStr::new("/NH"),
8318            ],
8319        ) else {
8320            return false;
8321        };
8322        stdout
8323            .lines()
8324            .filter_map(|line| line.rsplit(',').next())
8325            .any(|status| {
8326                status
8327                    .trim()
8328                    .trim_matches('"')
8329                    .eq_ignore_ascii_case("running")
8330            })
8331    }
8332
8333    /// Parses the `PT<n>M` shape this module writes, and the `PT<n>S` shape
8334    /// Task Scheduler would accept if it took seconds. Anything richer -- days,
8335    /// hours, a combination -- is `None` rather than a guess.
8336    fn parse_iso8601(value: &str) -> Option<Duration> {
8337        let rest = value.strip_prefix("PT")?;
8338        if let Some(minutes) = rest.strip_suffix('M') {
8339            return minutes
8340                .parse::<u64>()
8341                .ok()
8342                .map(|minutes| Duration::from_secs(minutes * 60));
8343        }
8344        rest.strip_suffix('S')?
8345            .parse::<u64>()
8346            .ok()
8347            .map(Duration::from_secs)
8348    }
8349}
8350
8351// ---------------------------------------------------------------------------
8352// Unix: shared plumbing
8353// ---------------------------------------------------------------------------
8354
8355/// Writes a definition file, creating its directory, and says when the refusal
8356/// was a permissions one.
8357///
8358/// Both Unix backends write a file into a directory only `root` can write —
8359/// `/Library/LaunchDaemons` and `/etc/systemd/system` — and an operator who ran
8360/// `service install` without `sudo` deserves to be told that rather than handed
8361/// an `EACCES`.
8362#[cfg(unix)]
8363fn write_definition(
8364    operation: &'static str,
8365    name: &str,
8366    path: &Path,
8367    text: &str,
8368    remedy: &'static str,
8369) -> Result<(), ServiceError> {
8370    if let Some(parent) = path.parent()
8371        && let Err(error) = std::fs::create_dir_all(parent)
8372        && error.kind() != std::io::ErrorKind::AlreadyExists
8373    {
8374        return Err(definition_error(operation, name, error, remedy, parent));
8375    }
8376    std::fs::write(path, text)
8377        .map_err(|error| definition_error(operation, name, error, remedy, path))
8378}
8379
8380/// Replaces an installed definition with [`DefinitionDrift::rendered`].
8381///
8382/// Through a temporary file in the same directory and a rename, so a failure
8383/// at any point leaves the old definition in place rather than a truncated
8384/// one. The mode is set to `0644` before the rename, as `service install`
8385/// leaves it: launchd refuses a plist that others can write.
8386#[cfg(all(unix, any(target_os = "macos", test)))]
8387fn replace_definition(name: &str, drift: &DefinitionDrift) -> Result<(), ServiceError> {
8388    use std::io::Write as _;
8389
8390    let path = drift.path.as_path();
8391    let directory = path.parent().unwrap_or_else(|| Path::new("/"));
8392    let failed = |error: std::io::Error, at: &Path| {
8393        definition_error("rewrite", name, error, SUDO_REMEDY, at)
8394    };
8395    let mut staged =
8396        tempfile::NamedTempFile::new_in(directory).map_err(|error| failed(error, directory))?;
8397    staged
8398        .write_all(drift.rendered.as_bytes())
8399        .and_then(|()| staged.as_file().sync_all())
8400        .map_err(|error| failed(error, staged.path()))?;
8401    std::fs::set_permissions(
8402        staged.path(),
8403        <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o644),
8404    )
8405    .map_err(|error| failed(error, path))?;
8406    staged
8407        .persist(path)
8408        .map(drop)
8409        .map_err(|error| failed(error.error, path))
8410}
8411
8412#[cfg(unix)]
8413fn definition_error(
8414    operation: &'static str,
8415    name: &str,
8416    error: std::io::Error,
8417    remedy: &'static str,
8418    path: &Path,
8419) -> ServiceError {
8420    let detail = format!("{}: {error}", path.display());
8421    if error.kind() == std::io::ErrorKind::PermissionDenied {
8422        ServiceError::NeedsElevation {
8423            operation,
8424            name: name.to_string(),
8425            detail,
8426            remedy,
8427        }
8428    } else {
8429        ServiceError::Control {
8430            operation,
8431            name: name.to_string(),
8432            manager: "the local service manager",
8433            detail,
8434        }
8435    }
8436}
8437
8438/// `sudo` is the remedy on both Unixes, and the message says which command.
8439#[cfg(unix)]
8440const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
8441     `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
8442     agent not running until you sign in.";
8443
8444// ---------------------------------------------------------------------------
8445// macOS
8446// ---------------------------------------------------------------------------
8447
8448#[cfg(target_os = "macos")]
8449mod sys {
8450    //! One manager, two domains. `--start-at boot` is a LaunchDaemon in
8451    //! `/Library/LaunchDaemons`, loaded into launchd's `system` domain;
8452    //! `--start-at login` is a LaunchAgent in the operator's own
8453    //! `~/Library/LaunchAgents`, loaded into `gui/<uid>`.
8454    //!
8455    //! `launchctl print` is the only inspection command here whose output is
8456    //! parsed, and it is English-only on every macOS release — launchd has no
8457    //! localisation for it. The registration's *existence* is decided by the
8458    //! plist file rather than by that output, so a launchd that changed its
8459    //! wording would cost this module the running/not-running line and nothing
8460    //! else.
8461
8462    use std::ffi::OsStr;
8463    use std::path::PathBuf;
8464    use std::time::Duration;
8465
8466    use runner_manager_domain::model::StartMode;
8467
8468    use super::{
8469        DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
8470        SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
8471        enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
8472        write_definition, xml_value,
8473    };
8474
8475    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
8476        Ok(Box::new(LaunchdControl { mode }))
8477    }
8478
8479    #[derive(Debug)]
8480    struct LaunchdControl {
8481        mode: StartMode,
8482    }
8483
8484    impl LaunchdControl {
8485        /// launchd's domain target for this start mode.
8486        fn domain(&self) -> String {
8487            match self.mode {
8488                StartMode::Boot => "system".to_string(),
8489                // SAFETY: `getuid` reads the calling process's real user id and
8490                // cannot fail; it takes no arguments and touches no memory.
8491                StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
8492            }
8493        }
8494
8495        fn service_target(&self, identity: &ServiceIdentity) -> String {
8496            format!("{}/{}", self.domain(), identity.launchd_label())
8497        }
8498
8499        /// Where this domain's plist lives, when the domain has a home to put
8500        /// it in.
8501        fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
8502            let file = format!("{}.plist", identity.launchd_label());
8503            match self.mode {
8504                StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
8505                StartMode::Login => {
8506                    home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
8507                }
8508            }
8509        }
8510
8511        fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
8512            ServiceError::Control {
8513                operation,
8514                name: name.to_string(),
8515                manager: "launchd",
8516                detail,
8517            }
8518        }
8519
8520        fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
8521            match run("launchctl", arguments) {
8522                Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
8523                Err(error) => (false, error.to_string()),
8524            }
8525        }
8526    }
8527
8528    impl ServiceControl for LaunchdControl {
8529        fn manager(&self) -> DefinitionKind {
8530            DefinitionKind::LaunchdPlist
8531        }
8532
8533        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8534            let name = plan.identity().name().to_string();
8535            let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
8536            let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
8537                return Err(self.failed(
8538                    "install",
8539                    &name,
8540                    "this account has no home directory, so there is nowhere to put a \
8541                     LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
8542                     /Library/LaunchDaemons."
8543                        .to_string(),
8544                ));
8545            };
8546            write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8547            let target = self.domain();
8548            let (ok, message) = self.launchctl(&[
8549                OsStr::new("bootstrap"),
8550                OsStr::new(&target),
8551                path.as_os_str(),
8552            ]);
8553            if !ok {
8554                // Leave nothing behind: a plist launchd refused is a file that
8555                // would make the next `service status` claim a registration
8556                // that does not exist.
8557                let _ = std::fs::remove_file(&path);
8558                if message.to_ascii_lowercase().contains("permission denied") {
8559                    return Err(ServiceError::NeedsElevation {
8560                        operation: "install",
8561                        name,
8562                        detail: message,
8563                        remedy: SUDO_REMEDY,
8564                    });
8565                }
8566                return Err(self.failed("install", &name, message));
8567            }
8568            // A previously disabled label stays disabled through a bootstrap,
8569            // which is a service that is installed and will never start.
8570            let service_target = self.service_target(plan.identity());
8571            enable_launchd_registration(
8572                |arguments| self.launchctl(arguments),
8573                &target,
8574                &service_target,
8575                &path,
8576                &name,
8577                SUDO_REMEDY,
8578            )?;
8579            Ok(definition)
8580        }
8581
8582        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8583            let Some(path) = self.plist_path(identity) else {
8584                return Ok(false);
8585            };
8586            if !path.exists() {
8587                return Ok(false);
8588            }
8589            let target = self.service_target(identity);
8590            let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
8591            if !ok
8592                && !message.to_ascii_lowercase().contains("no such process")
8593                && !message.contains("113")
8594            {
8595                if message.to_ascii_lowercase().contains("permission denied") {
8596                    return Err(ServiceError::NeedsElevation {
8597                        operation: "uninstall",
8598                        name: identity.name().to_string(),
8599                        detail: message,
8600                        remedy: SUDO_REMEDY,
8601                    });
8602                }
8603                return Err(self.failed("uninstall", identity.name(), message));
8604            }
8605            // Only the definition. `05-infrastructure.md` item 5: nothing else
8606            // on this host is touched here or anywhere below it.
8607            std::fs::remove_file(&path).map_err(|error| {
8608                super::definition_error(
8609                    "uninstall",
8610                    identity.name(),
8611                    error,
8612                    SUDO_REMEDY,
8613                    path.as_path(),
8614                )
8615            })?;
8616            Ok(true)
8617        }
8618
8619        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8620            let Some(path) = self.plist_path(identity) else {
8621                return Ok(None);
8622            };
8623            let Ok(document) = std::fs::read_to_string(&path) else {
8624                return Ok(None);
8625            };
8626            let target = self.service_target(identity);
8627            let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
8628            Ok(Some(Registration {
8629                manager: DefinitionKind::LaunchdPlist,
8630                start_mode: self.mode,
8631                command_line: program_arguments(&document),
8632                // A LaunchDaemon without `UserName` runs as root; a LaunchAgent
8633                // runs as whoever signs in, which names no one in particular.
8634                account: plist_string_value(&document, "UserName").or_else(|| {
8635                    (self.mode == StartMode::Boot)
8636                        .then(|| super::ServiceAccount::Root.as_str().to_string())
8637                }),
8638                running: loaded && printed.contains("state = running"),
8639                starts_automatically: document.contains("<key>RunAtLoad</key>")
8640                    && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
8641                restart_delay: xml_value(
8642                    super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
8643                    "integer",
8644                )
8645                .and_then(|value| value.parse::<u64>().ok())
8646                .map(Duration::from_secs),
8647            }))
8648        }
8649
8650        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8651            let target = self.service_target(identity);
8652            let (ok, message) = self.launchctl(&[
8653                OsStr::new("kickstart"),
8654                OsStr::new("-k"),
8655                OsStr::new(&target),
8656            ]);
8657            if ok {
8658                Ok(())
8659            } else {
8660                Err(self.failed("start", identity.name(), message))
8661            }
8662        }
8663
8664        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8665            let running = self
8666                .query(identity)?
8667                .is_some_and(|registration| registration.running);
8668            if !running {
8669                return Ok(false);
8670            }
8671            let target = self.service_target(identity);
8672            let (ok, message) = self.launchctl(&[
8673                OsStr::new("kill"),
8674                OsStr::new("SIGTERM"),
8675                OsStr::new(&target),
8676            ]);
8677            if ok {
8678                Ok(true)
8679            } else {
8680                Err(self.failed("stop", identity.name(), message))
8681            }
8682        }
8683    }
8684
8685    /// Boots the job out and bootstraps it again from `plist`, from a shell
8686    /// that outlives this process.
8687    ///
8688    /// `setsid` puts the shell in a session and process group of its own. On a
8689    /// bootout launchd sends SIGTERM to the job's whole process group, the
8690    /// process that asked for the bootout included, and spares a child in
8691    /// another session (measured on macOS 27). Without `setsid` the helper
8692    /// survived that only by luck: one run in four left the job unloaded. The
8693    /// work runs in a background subshell, so the outer shell exits at once and
8694    /// is reaped here rather than left behind. `bootout` blocks until the daemon
8695    /// has exited; the bootstrap is retried for a minute because launchd
8696    /// refuses it while the old job is still being torn down. When the job is
8697    /// still loaded after a failed bootout, every bootstrap fails and the
8698    /// running daemon carries on as it was.
8699    pub(super) fn reload_detached(
8700        identity: &ServiceIdentity,
8701        mode: StartMode,
8702        plist: &std::path::Path,
8703    ) -> Result<(), ServiceError> {
8704        use std::os::unix::process::CommandExt;
8705
8706        const SCRIPT: &str = r#"(
8707  sleep 1
8708  /bin/launchctl bootout "$1"
8709  tries=0
8710  until /bin/launchctl bootstrap "$2" "$3"; do
8711    tries=$((tries + 1))
8712    if [ "$tries" -ge 60 ]; then
8713      echo "runner-manager: launchd did not load $3 again; run: $4" >&2
8714      exit 1
8715    fi
8716    sleep 1
8717  done
8718) </dev/null &"#;
8719
8720        let control = LaunchdControl { mode };
8721        // Once the job is booted out nothing holds the single-instance lock,
8722        // so a reinstall in the same start mode is the remedy.
8723        let reinstall = match mode {
8724            StartMode::Boot => "sudo runner-manager service install --start-at boot",
8725            StartMode::Login => "runner-manager service install --start-at login",
8726        };
8727        let mut command = std::process::Command::new("/bin/sh");
8728        command
8729            .args(["-c", SCRIPT, "runner-manager-reload"])
8730            .arg(control.service_target(identity))
8731            .arg(control.domain())
8732            .arg(plist)
8733            .arg(reinstall)
8734            .stdin(std::process::Stdio::null());
8735        // SAFETY: one async-signal-safe system call between `fork` and `exec`,
8736        // touching no memory, allocator or lock.
8737        unsafe {
8738            command.pre_exec(|| {
8739                if libc::setsid() == -1 {
8740                    return Err(std::io::Error::last_os_error());
8741                }
8742                Ok(())
8743            });
8744        }
8745        let status = command.status().map_err(|error| {
8746            control.failed("reload", identity.name(), format!("/bin/sh: {error}"))
8747        })?;
8748        if status.success() {
8749            Ok(())
8750        } else {
8751            Err(control.failed(
8752                "reload",
8753                identity.name(),
8754                format!("the reload helper exited with {status}"),
8755            ))
8756        }
8757    }
8758
8759    /// Rebuilds the command line from a plist's `ProgramArguments` array.
8760    fn program_arguments(document: &str) -> String {
8761        let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
8762            return String::new();
8763        };
8764        let Some(end) = rest.find("</array>") else {
8765            return String::new();
8766        };
8767        let mut out = Vec::new();
8768        let mut cursor = &rest[..end];
8769        while let Some(open) = cursor.find("<string>") {
8770            let after = &cursor[open + "<string>".len()..];
8771            let Some(close) = after.find("</string>") else {
8772                break;
8773            };
8774            out.push(quote_argument(&super::xml_unescape(&after[..close])));
8775            cursor = &after[close..];
8776        }
8777        out.join(" ")
8778    }
8779}
8780
8781// ---------------------------------------------------------------------------
8782// Linux and the other Unixes
8783// ---------------------------------------------------------------------------
8784
8785#[cfg(all(unix, not(target_os = "macos")))]
8786mod sys {
8787    //! One manager, two domains. `--start-at boot` is a system unit in
8788    //! `/etc/systemd/system`, wanted by `multi-user.target`; `--start-at login`
8789    //! is a user unit in `~/.config/systemd/user`, wanted by `default.target`.
8790    //!
8791    //! Every inspection here goes through `systemctl cat` and `systemctl show`,
8792    //! whose output is machine-readable `key=value` text rather than a
8793    //! sentence, so nothing in this module depends on the machine's display
8794    //! language.
8795
8796    use std::ffi::OsStr;
8797    use std::path::PathBuf;
8798
8799    use runner_manager_domain::model::StartMode;
8800
8801    use super::{
8802        DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
8803        SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
8804        home_directory, ini_directives, run, systemd_exec_command_line, systemd_timespan,
8805        write_definition,
8806    };
8807
8808    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
8809        Ok(Box::new(SystemdControl { mode }))
8810    }
8811
8812    #[derive(Debug)]
8813    struct SystemdControl {
8814        mode: StartMode,
8815    }
8816
8817    impl SystemdControl {
8818        fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
8819            let file = identity.systemd_unit();
8820            match self.mode {
8821                StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
8822                StartMode::Login => {
8823                    home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
8824                }
8825            }
8826        }
8827
8828        /// `systemctl`, with `--user` for the login domain.
8829        fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
8830            let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
8831            if self.mode == StartMode::Login {
8832                all.push(OsStr::new("--user"));
8833            }
8834            all.extend(arguments.iter().map(OsStr::new));
8835            match run("systemctl", &all) {
8836                Ok((ok, stdout, stderr)) => (
8837                    ok,
8838                    if stdout.trim().is_empty() {
8839                        stderr
8840                    } else {
8841                        stdout
8842                    },
8843                ),
8844                Err(error) => (false, error.to_string()),
8845            }
8846        }
8847
8848        fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
8849            ServiceError::Control {
8850                operation,
8851                name: name.to_string(),
8852                manager: "systemd",
8853                detail,
8854            }
8855        }
8856    }
8857
8858    impl ServiceControl for SystemdControl {
8859        fn manager(&self) -> DefinitionKind {
8860            DefinitionKind::SystemdUnit
8861        }
8862
8863        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8864            use std::os::unix::fs::MetadataExt as _;
8865
8866            let name = plan.identity().name().to_string();
8867            if self.mode == StartMode::Boot {
8868                // The credential store's directory as well as the four: the
8869                // service rewrites the credential there when GitHub rotates it.
8870                let refusals = super::permitted_paths(DefinitionKind::SystemdUnit, plan)
8871                    .iter()
8872                    .filter_map(|path| {
8873                        let path = std::path::Path::new(path);
8874                        let owner = std::fs::metadata(path).ok().map(|metadata| metadata.uid());
8875                        super::boot_directory_refusal(path, owner)
8876                    })
8877                    .collect::<Vec<_>>();
8878                if !refusals.is_empty() {
8879                    return Err(self.failed(
8880                        "install",
8881                        &name,
8882                        format!(
8883                            "{}. Run `{}` without --data-dir, which uses {} and {}; a \
8884                             directory there that root does not own is handed back with \
8885                             `sudo chown -R root:root <directory>`",
8886                            refusals.join("; "),
8887                            super::sudo_command("service install --start-at boot"),
8888                            crate::paths::SYSTEM_DATA_ROOT,
8889                            crate::paths::SYSTEM_LOGS_DIR,
8890                        ),
8891                    ));
8892                }
8893            }
8894            // The accounts the daemon starts its runners as, one per concurrent
8895            // runner. Created here, from the elevated install, since the
8896            // service itself can create no account.
8897            if self.mode == StartMode::Boot
8898                && let Err(detail) = crate::runner_account::RunnerPool::ensure()
8899            {
8900                return Err(self.failed("install", &name, detail));
8901            }
8902            let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
8903            let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
8904                return Err(self.failed(
8905                    "install",
8906                    &name,
8907                    "this account has no home directory, so there is nowhere to put a systemd \
8908                     user unit. Use --start-at boot, which installs a system unit under \
8909                     /etc/systemd/system."
8910                        .to_string(),
8911                ));
8912            };
8913            write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8914            let unit = plan.identity().systemd_unit();
8915            let (reloaded, message) = self.systemctl(&["daemon-reload"]);
8916            if !reloaded {
8917                let _ = std::fs::remove_file(&path);
8918                return Err(self.failed("install", &name, message));
8919            }
8920            let (enabled, message) = self.systemctl(&["enable", &unit]);
8921            if !enabled {
8922                let _ = std::fs::remove_file(&path);
8923                let _ = self.systemctl(&["daemon-reload"]);
8924                return Err(self.failed("install", &name, message));
8925            }
8926            Ok(definition)
8927        }
8928
8929        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8930            let Some(path) = self.unit_path(identity) else {
8931                return Ok(false);
8932            };
8933            if !path.exists() {
8934                return Ok(false);
8935            }
8936            let unit = identity.systemd_unit();
8937            // `--now` stops it as well as disabling it. A failure here is not
8938            // fatal: the unit file is about to go, and refusing to remove it
8939            // because `systemctl` disliked something would leave the host with
8940            // a unit nothing manages.
8941            let _ = self.systemctl(&["disable", "--now", &unit]);
8942            std::fs::remove_file(&path).map_err(|error| {
8943                super::definition_error(
8944                    "uninstall",
8945                    identity.name(),
8946                    error,
8947                    SUDO_REMEDY,
8948                    path.as_path(),
8949                )
8950            })?;
8951            let _ = self.systemctl(&["daemon-reload"]);
8952            Ok(true)
8953        }
8954
8955        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8956            let Some(path) = self.unit_path(identity) else {
8957                return Ok(None);
8958            };
8959            if !path.exists() {
8960                return Ok(None);
8961            }
8962            let unit = identity.systemd_unit();
8963            // `cat` includes administrator drop-ins in systemd's order. Reading
8964            // only our base file hides User/ExecStart/RestartSec overrides, so
8965            // the file is read only when `systemctl` cannot answer.
8966            let effective = match self.systemctl(&["cat", &unit]) {
8967                (true, text) => text,
8968                _ => match std::fs::read_to_string(&path) {
8969                    Ok(document) => document,
8970                    Err(_) => return Ok(None),
8971                },
8972            };
8973            let directives = ini_directives(&effective, "Service");
8974            // One `show` answers what `is-active` and `is-enabled` each did.
8975            let (_, shown) = self.systemctl(&[
8976                "show",
8977                "--property=ActiveState",
8978                "--property=UnitFileState",
8979                &unit,
8980            ]);
8981            let state = |key: &str| {
8982                shown
8983                    .lines()
8984                    .find_map(|line| line.strip_prefix(key)?.strip_prefix('='))
8985                    .map(str::trim)
8986            };
8987            Ok(Some(Registration {
8988                manager: DefinitionKind::SystemdUnit,
8989                start_mode: self.mode,
8990                command_line: directives
8991                    .get("ExecStart")
8992                    .map(|value| systemd_exec_command_line(value))
8993                    .unwrap_or_default(),
8994                // A system unit without `User=` runs as root; a user unit runs
8995                // as whoever owns the session, which names no one in particular.
8996                account: directives
8997                    .get("User")
8998                    .filter(|user| !user.is_empty())
8999                    .cloned()
9000                    .or_else(|| {
9001                        (self.mode == StartMode::Boot)
9002                            .then(|| super::ServiceAccount::Root.as_str().to_string())
9003                    }),
9004                running: state("ActiveState") == Some("active"),
9005                starts_automatically: state("UnitFileState") == Some("enabled"),
9006                restart_delay: directives
9007                    .get("RestartSec")
9008                    .and_then(|value| systemd_timespan(value)),
9009            }))
9010        }
9011
9012        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
9013            let unit = identity.systemd_unit();
9014            let (ok, message) = self.systemctl(&["start", &unit]);
9015            if ok {
9016                Ok(())
9017            } else {
9018                Err(self.failed("start", identity.name(), message))
9019            }
9020        }
9021
9022        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
9023            let running = self
9024                .query(identity)?
9025                .is_some_and(|registration| registration.running);
9026            if !running {
9027                return Ok(false);
9028            }
9029            let unit = identity.systemd_unit();
9030            let (ok, message) = self.systemctl(&["stop", &unit]);
9031            if ok {
9032                Ok(true)
9033            } else {
9034                Err(self.failed("stop", identity.name(), message))
9035            }
9036        }
9037    }
9038}
9039
9040/// What the macOS tests that load real launch agents share.
9041#[cfg(all(test, target_os = "macos"))]
9042pub(crate) mod launchd_fixture {
9043    use std::path::{Path, PathBuf};
9044
9045    /// Opts a macOS host into the tests that load launch agents into the
9046    /// caller's GUI session. CI's macOS leg sets it, and
9047    /// `privileged_tests_are_wired_into_ci.rs` asserts that it still does.
9048    const ACCEPTANCE: &str = "RUNNER_MANAGER_LAUNCHD_ACCEPTANCE";
9049
9050    /// Whether this run may load launch agents. Says so when it may not, since
9051    /// the test then passes having measured nothing.
9052    pub(crate) fn enabled() -> bool {
9053        let enabled = std::env::var_os(ACCEPTANCE).is_some_and(|value| value == "1");
9054        if !enabled {
9055            eprintln!("skipped: set {ACCEPTANCE}=1 to load test launch agents");
9056        }
9057        enabled
9058    }
9059
9060    /// A copy of the running test binary in `directory`, for a launchd job to
9061    /// run as its program.
9062    ///
9063    /// A copy, because a launchd job whose program lives on an external volume
9064    /// waits on a privacy approval nobody gives before `main` runs (measured
9065    /// with a target directory under `/Volumes`), and the temporary directory
9066    /// is on the boot volume.
9067    pub(crate) fn job_binary(directory: &Path) -> PathBuf {
9068        let binary = directory.join("launch-agent-job");
9069        std::fs::copy(std::env::current_exe().expect("test binary"), &binary)
9070            .expect("test binary copied");
9071        binary
9072    }
9073
9074    /// A launch agent loaded into the caller's GUI domain, booted out on drop.
9075    pub(crate) struct LoadedAgent(String);
9076
9077    impl LoadedAgent {
9078        /// Bootstraps `plist`, whose `Label` is `label`, and fails the test when
9079        /// launchd refuses it.
9080        pub(crate) fn bootstrap(label: &str, plist: &Path) -> Self {
9081            // SAFETY: `getuid` reads the calling process's real user id and
9082            // cannot fail.
9083            let domain = format!("gui/{}", unsafe { libc::getuid() });
9084            // Armed before the bootstrap, so a half-loaded job is removed too.
9085            let loaded = Self(format!("{domain}/{label}"));
9086            let bootstrap = std::process::Command::new("launchctl")
9087                .arg("bootstrap")
9088                .arg(&domain)
9089                .arg(plist)
9090                .output()
9091                .expect("launchctl runs");
9092            assert!(
9093                bootstrap.status.success(),
9094                "launchctl bootstrap {domain} refused {label}, so nothing was measured: {}",
9095                String::from_utf8_lossy(&bootstrap.stderr)
9096            );
9097            loaded
9098        }
9099
9100        /// `gui/<uid>/<label>`.
9101        pub(crate) fn target(&self) -> &str {
9102            &self.0
9103        }
9104    }
9105
9106    impl Drop for LoadedAgent {
9107        fn drop(&mut self) {
9108            let _ = std::process::Command::new("launchctl")
9109                .args(["bootout", &self.0])
9110                .output();
9111        }
9112    }
9113}
9114
9115#[cfg(test)]
9116mod tests {
9117    use super::*;
9118
9119    use std::collections::BTreeMap;
9120
9121    #[test]
9122    fn task_scheduler_omitting_default_enabled_still_means_automatic() {
9123        assert!(windows_login_task_starts_automatically(
9124            "<Task><Triggers><LogonTrigger></LogonTrigger></Triggers></Task>"
9125        ));
9126        assert!(windows_login_task_starts_automatically(
9127            "<Task><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Task>"
9128        ));
9129        assert!(!windows_login_task_starts_automatically(
9130            "<Task><LogonTrigger><Enabled>false</Enabled></LogonTrigger></Task>"
9131        ));
9132        assert!(!windows_login_task_starts_automatically(
9133            "<Task><BootTrigger></BootTrigger></Task>"
9134        ));
9135    }
9136
9137    // -----------------------------------------------------------------------
9138    // Fixtures
9139    // -----------------------------------------------------------------------
9140
9141    /// A plan against paths that exist on no platform, so that every renderer
9142    /// can be asserted on every leg of the CI matrix.
9143    fn linux_plan(mode: StartMode) -> InstallPlan {
9144        InstallPlan::unchecked(
9145            ServiceIdentity::product(),
9146            mode,
9147            "/opt/runner-manager/bin/runner-manager",
9148            ServiceDirectories {
9149                config: PathBuf::from("/var/lib/runner-manager/config"),
9150                state: PathBuf::from("/var/lib/runner-manager/state"),
9151                runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
9152                logs: PathBuf::from("/var/log/runner-manager"),
9153            },
9154        )
9155        .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
9156    }
9157
9158    fn windows_plan(mode: StartMode) -> InstallPlan {
9159        InstallPlan::unchecked(
9160            ServiceIdentity::product(),
9161            mode,
9162            "C:\\Program Files\\runner-manager\\runner-manager.exe",
9163            ServiceDirectories {
9164                config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
9165                state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
9166                runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
9167                logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
9168            },
9169        )
9170    }
9171
9172    /// Replaces one fragment of a rendered definition, and **fails the test if
9173    /// the fragment was not there**.
9174    ///
9175    /// Without the assertion a renderer change would quietly turn every
9176    /// "widened definition is rejected" test below into a test that reviews the
9177    /// unmodified definition and passes for the wrong reason. This is the
9178    /// single guard that keeps that class of vacuity out of this file.
9179    fn edited(text: &str, from: &str, to: &str) -> String {
9180        assert!(
9181            text.contains(from),
9182            "the rendered definition does not contain `{from}`, so this test would assert \
9183             nothing about a widened one"
9184        );
9185        text.replace(from, to)
9186    }
9187
9188    /// Every file under `root`, keyed by its path relative to `root`.
9189    fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
9190        fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
9191            let Ok(entries) = std::fs::read_dir(directory) else {
9192                return;
9193            };
9194            for entry in entries.flatten() {
9195                let path = entry.path();
9196                if path.is_dir() {
9197                    walk(&path, out);
9198                } else if let Ok(bytes) = std::fs::read(&path) {
9199                    out.insert(path, bytes);
9200                }
9201            }
9202        }
9203        let mut out = BTreeMap::new();
9204        for root in roots {
9205            walk(root, &mut out);
9206        }
9207        out
9208    }
9209
9210    struct Host {
9211        _root: tempfile::TempDir,
9212        paths: AppPaths,
9213        binary: PathBuf,
9214        /// A runner root inside this host's own temporary tree.
9215        ///
9216        /// Without it every `install` in this module would create and
9217        /// re-permission the **real** `%SystemDrive%\rman` on the machine
9218        /// running the tests, and would fail outright on any machine where that
9219        /// directory already exists with the access control `C:\` gives it.
9220        /// A sibling of the four application-data directories rather than a
9221        /// child of one, so `b1`'s overlap check has nothing to object to.
9222        runner_root: LocalAbsolutePath,
9223        controls: RecordingControls,
9224    }
9225
9226    impl Host {
9227        fn new() -> Self {
9228            let root = tempfile::tempdir().expect("a temporary directory");
9229            let paths = AppPaths::rooted_at(root.path());
9230            paths.create_all().expect("the four directories");
9231            let binary = root.path().join(if cfg!(windows) {
9232                "runner-manager.exe"
9233            } else {
9234                "runner-manager"
9235            });
9236            std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
9237            let runner_root = LocalAbsolutePath::new(
9238                root.path()
9239                    .join("runner-root")
9240                    .to_str()
9241                    .expect("a unicode temporary path"),
9242            )
9243            .expect("a local absolute path");
9244            Self {
9245                _root: root,
9246                paths,
9247                binary,
9248                runner_root,
9249                controls: RecordingControls::new(),
9250            }
9251        }
9252
9253        fn operations(&self) -> ServiceOperations {
9254            ServiceOperations::with_controls(
9255                self.paths.clone(),
9256                ServiceIdentity::product(),
9257                std::sync::Arc::new(self.controls.clone()),
9258            )
9259            .with_runner_root(self.runner_root.clone())
9260        }
9261
9262        fn request(&self, mode: StartMode) -> InstallRequest {
9263            InstallRequest::new(mode).for_binary(&self.binary)
9264        }
9265    }
9266
9267    #[cfg(windows)]
9268    #[test]
9269    fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
9270        let probes = std::cell::Cell::new(0);
9271        let absent =
9272            super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
9273                let next = probes.get() + 1;
9274                probes.set(next);
9275                Ok(next == 3)
9276            })
9277            .expect("the simulated SCM probe succeeds");
9278
9279        assert!(absent);
9280        assert_eq!(
9281            probes.get(),
9282            3,
9283            "uninstall must recheck after transient presence instead of treating it as a leak"
9284        );
9285    }
9286
9287    #[cfg(windows)]
9288    #[test]
9289    fn windows_recognises_an_already_pending_service_deletion() {
9290        let error = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(1072));
9291
9292        assert!(super::sys::is_marked_for_delete(&error));
9293    }
9294
9295    #[test]
9296    fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
9297        let root = tempfile::tempdir().expect("a temporary directory");
9298        let plist = root.path().join("fixture.plist");
9299        std::fs::write(&plist, b"fixture").expect("a plist fixture");
9300        let calls = std::cell::RefCell::new(Vec::new());
9301
9302        let error = enable_launchd_registration(
9303            |arguments| {
9304                let call = arguments
9305                    .iter()
9306                    .map(|argument| argument.to_string_lossy().into_owned())
9307                    .collect::<Vec<_>>();
9308                let operation = call[0].clone();
9309                calls.borrow_mut().push(call);
9310                if operation == "enable" {
9311                    (false, "label remains disabled".to_string())
9312                } else {
9313                    (true, String::new())
9314                }
9315            },
9316            "system",
9317            "system/com.openai.runner-manager-selftest",
9318            &plist,
9319            "runner-manager-selftest",
9320            "rerun with administrative rights",
9321        )
9322        .expect_err("enable failure must fail the install");
9323
9324        assert!(
9325            matches!(
9326                error,
9327                ServiceError::Control {
9328                    operation: "enable",
9329                    ..
9330                }
9331            ),
9332            "{error}"
9333        );
9334        assert_eq!(calls.borrow().len(), 2);
9335        assert_eq!(calls.borrow()[0][0], "enable");
9336        assert_eq!(calls.borrow()[1][0], "bootout");
9337        assert!(!plist.exists(), "rollback must remove the plist");
9338    }
9339
9340    // -----------------------------------------------------------------------
9341    // Quoting, and reading a command line back
9342    // -----------------------------------------------------------------------
9343
9344    #[test]
9345    fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
9346        let plan = windows_plan(StartMode::Boot);
9347        let command_line = plan.command_line();
9348        assert!(
9349            command_line.starts_with('"'),
9350            "a path with a space must be quoted, got {command_line}"
9351        );
9352        assert_eq!(
9353            executable_from_command_line(&command_line).as_deref(),
9354            Some(plan.binary())
9355        );
9356    }
9357
9358    #[test]
9359    fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
9360        let plan = linux_plan(StartMode::Boot);
9361        let command_line = plan.command_line();
9362        assert!(!command_line.starts_with('"'), "got {command_line}");
9363        assert_eq!(
9364            executable_from_command_line(&command_line).as_deref(),
9365            Some(plan.binary())
9366        );
9367    }
9368
9369    #[test]
9370    fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
9371        // Not a path anybody has, but it is the input that separates a real
9372        // implementation of Windows' quoting rules from a `split_whitespace`.
9373        let awkward = r#"C:\odd "name"\rm.exe"#;
9374        let quoted = quote_argument(awkward);
9375        assert_eq!(
9376            executable_from_command_line(&format!("{quoted} daemon run"))
9377                .as_deref()
9378                .map(Path::to_string_lossy)
9379                .as_deref(),
9380            Some(awkward)
9381        );
9382    }
9383
9384    #[test]
9385    fn an_empty_command_line_has_no_executable() {
9386        assert_eq!(executable_from_command_line("   "), None);
9387        assert_eq!(executable_from_command_line(""), None);
9388    }
9389
9390    #[test]
9391    fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
9392        let awkward = r#"DOMAIN\R&D <team> "ops""#;
9393        assert_eq!(
9394            xml_escape(awkward),
9395            "DOMAIN\\R&amp;D &lt;team&gt; &quot;ops&quot;"
9396        );
9397        assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
9398    }
9399
9400    // -----------------------------------------------------------------------
9401    // The restart policy is bounded at both ends
9402    // -----------------------------------------------------------------------
9403
9404    #[test]
9405    fn a_restart_delay_under_the_floor_is_refused() {
9406        let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
9407            .expect_err("half a second is under the one-second floor");
9408        assert!(
9409            matches!(error, ServiceError::RestartDelay { .. }),
9410            "{error}"
9411        );
9412    }
9413
9414    #[test]
9415    fn a_restart_delay_over_the_ceiling_is_refused() {
9416        let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
9417            .expect_err("an hour is over the five-minute ceiling");
9418        assert!(
9419            matches!(error, ServiceError::RestartDelay { .. }),
9420            "{error}"
9421        );
9422    }
9423
9424    #[test]
9425    fn a_reset_window_no_longer_than_the_delay_is_refused() {
9426        let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
9427            .expect_err("a window equal to the delay can never elapse between restarts");
9428        assert!(
9429            matches!(error, ServiceError::RestartResetWindow { .. }),
9430            "{error}"
9431        );
9432    }
9433
9434    #[test]
9435    fn a_delay_inside_the_bound_is_accepted() {
9436        let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
9437            .expect("twenty seconds is inside the bound");
9438        assert_eq!(policy.delay(), Duration::from_secs(20));
9439        assert_eq!(policy.reset_after(), Duration::from_secs(300));
9440    }
9441
9442    // -----------------------------------------------------------------------
9443    // Identity
9444    // -----------------------------------------------------------------------
9445
9446    #[test]
9447    fn a_fixture_identity_can_never_be_the_product_identity() {
9448        let fixture = ServiceIdentity::fixture("abc123");
9449        assert!(fixture.is_fixture());
9450        assert!(!ServiceIdentity::product().is_fixture());
9451        assert_ne!(fixture.name(), ServiceIdentity::product().name());
9452        assert_ne!(
9453            fixture.launchd_label(),
9454            ServiceIdentity::product().launchd_label()
9455        );
9456        assert_ne!(
9457            fixture.systemd_unit(),
9458            ServiceIdentity::product().systemd_unit()
9459        );
9460    }
9461
9462    #[test]
9463    fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
9464        let fixture = ServiceIdentity::fixture("A b/c:\\d");
9465        assert!(
9466            fixture
9467                .name()
9468                .chars()
9469                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
9470            "got {}",
9471            fixture.name()
9472        );
9473    }
9474
9475    // -----------------------------------------------------------------------
9476    // The systemd unit
9477    // -----------------------------------------------------------------------
9478
9479    #[test]
9480    fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
9481        let unit = systemd_unit(&linux_plan(StartMode::Boot));
9482        assert!(unit.contains("KillMode=process\n"), "{unit}");
9483        assert!(unit.contains("Restart=on-failure\n"), "{unit}");
9484        assert!(unit.contains("RestartSec=15\n"), "{unit}");
9485        assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
9486        assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
9487        assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
9488    }
9489
9490    #[test]
9491    fn the_boot_unit_reads_the_live_store_instead_of_a_frozen_systemd_copy() {
9492        let unit = systemd_unit(&linux_plan(StartMode::Boot));
9493        assert!(
9494            !unit.contains("LoadCredential="),
9495            "a startup snapshot would shadow every rotated credential until restart:\n{unit}"
9496        );
9497    }
9498
9499    #[test]
9500    fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
9501        let unit = systemd_unit(&linux_plan(StartMode::Login));
9502        assert!(
9503            !unit.contains("LoadCredential="),
9504            "a user unit must not name a root-owned credential file, got:\n{unit}"
9505        );
9506        assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
9507    }
9508
9509    #[test]
9510    fn the_unit_can_atomically_replace_the_credential_and_write_only_required_directories() {
9511        let plan = linux_plan(StartMode::Boot);
9512        let unit = systemd_unit(&plan);
9513        let directives = ini_directives(&unit, "Service");
9514        let listed = split_quoted(
9515            directives
9516                .get("ReadWritePaths")
9517                .expect("the unit names its writable paths"),
9518        );
9519        assert_eq!(listed.len(), 5, "{listed:?}");
9520        for path in plan.directories().all() {
9521            assert!(
9522                listed.iter().any(|entry| entry == &path.to_string_lossy()),
9523                "{} is missing from {listed:?}",
9524                path.display()
9525            );
9526        }
9527        assert!(
9528            listed
9529                .iter()
9530                .any(|entry| entry == "/var/lib/runner-manager/secrets"),
9531            "atomic credential replacement needs its parent directory in {listed:?}"
9532        );
9533    }
9534
9535    #[test]
9536    fn the_unit_records_the_absolute_binary_path() {
9537        let plan = linux_plan(StartMode::Boot);
9538        let unit = systemd_unit(&plan);
9539        assert!(
9540            unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
9541            "{unit}"
9542        );
9543    }
9544
9545    // -----------------------------------------------------------------------
9546    // The launchd property list
9547    // -----------------------------------------------------------------------
9548
9549    #[test]
9550    fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
9551        let plist = launchd_plist(&linux_plan(StartMode::Boot));
9552        // A bare `KeepAlive` would also restart a job the operator stopped
9553        // deliberately, which turns `service stop` into a fight with launchd.
9554        assert!(
9555            plist.contains(
9556                "<key>KeepAlive</key>\n  <dict>\n    <key>SuccessfulExit</key>\n    <false/>\n"
9557            ),
9558            "{plist}"
9559        );
9560        assert!(
9561            plist.contains("<key>ThrottleInterval</key>\n  <integer>15</integer>"),
9562            "{plist}"
9563        );
9564    }
9565
9566    #[test]
9567    fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
9568        let daemon = launchd_plist(&linux_plan(StartMode::Boot));
9569        assert_eq!(
9570            plist_string_value(&daemon, "UserName").as_deref(),
9571            Some("root")
9572        );
9573        assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
9574
9575        let agent = launchd_plist(&linux_plan(StartMode::Login));
9576        assert_eq!(
9577            plist_string_value(&agent, "UserName"),
9578            None,
9579            "a LaunchAgent already runs as the operator:\n{agent}"
9580        );
9581    }
9582
9583    #[test]
9584    fn the_plist_runs_the_daemon_and_its_runners_as_an_interactive_job() {
9585        for mode in [StartMode::Boot, StartMode::Login] {
9586            let plan = linux_plan(mode);
9587            let plist = launchd_plist(&plan);
9588            assert_eq!(
9589                plist_string_value(&plist, "ProcessType").as_deref(),
9590                Some("Interactive"),
9591                "launchd applies ProcessType to every runner the daemon starts:\n{plist}"
9592            );
9593            let review = review_least_privilege(
9594                &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, plist),
9595                &plan,
9596            );
9597            assert!(
9598                review
9599                    .controls()
9600                    .iter()
9601                    .any(|control| control == "ProcessType=Interactive"),
9602                "{review}"
9603            );
9604            assert!(
9605                !review
9606                    .findings()
9607                    .iter()
9608                    .any(|finding| finding.subject == "ProcessType"),
9609                "{review}"
9610            );
9611        }
9612    }
9613
9614    /// The value 0.4.31 and earlier installed, and launchd's default, both
9615    /// throttle every runner. That is a shortfall the update repairs, not an
9616    /// excess: a scheduling class grants no authority.
9617    #[test]
9618    fn a_plist_that_throttles_its_runners_is_a_shortfall() {
9619        let plan = linux_plan(StartMode::Login);
9620        let rendered = launchd_plist(&plan);
9621        let interactive = "<key>ProcessType</key>\n  <string>Interactive</string>\n";
9622        for (label, throttled) in [
9623            (
9624                "Background",
9625                edited(
9626                    &rendered,
9627                    interactive,
9628                    "<key>ProcessType</key>\n  <string>Background</string>\n",
9629                ),
9630            ),
9631            ("absent", edited(&rendered, interactive, "")),
9632        ] {
9633            let review = review_least_privilege(
9634                &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, throttled),
9635                &plan,
9636            );
9637            assert!(review.is_least_privilege(), "{label}: {review}");
9638            assert!(
9639                review.findings().iter().any(|finding| {
9640                    finding.kind == FindingKind::Shortfall
9641                        && finding.subject == "ProcessType"
9642                        && finding.detail.contains(label)
9643                }),
9644                "{label}: {review}"
9645            );
9646        }
9647    }
9648
9649    #[cfg(unix)]
9650    #[test]
9651    fn converging_a_unit_first_creates_the_directories_it_lets_the_service_write() {
9652        use std::os::unix::fs::PermissionsExt as _;
9653        let root = tempfile::tempdir().expect("dir");
9654        let config = root.path().join(".config").join("runner-manager");
9655        let state = root.path().join("state");
9656        std::fs::create_dir_all(&state).expect("state");
9657        let drift = DefinitionDrift {
9658            path: root.path().join("runner-manager.service"),
9659            installed_by_version: "0.4.44".into(),
9660            rendered: String::new(),
9661            start_mode: StartMode::Boot,
9662            manager: DefinitionKind::SystemdUnit,
9663            writable: vec![config.clone(), state.clone()],
9664        };
9665        drift.create_writable().expect("created");
9666        assert!(config.is_dir(), "the missing one is made");
9667        assert_eq!(
9668            std::fs::metadata(&config).unwrap().permissions().mode() & 0o777,
9669            0o700
9670        );
9671        drift.create_writable().expect("and again changes nothing");
9672
9673        std::fs::remove_dir(&state).expect("state removed");
9674        std::fs::write(&state, "").expect("a file in its place");
9675        assert!(
9676            drift.create_writable().is_err(),
9677            "a file is not a directory"
9678        );
9679    }
9680
9681    #[test]
9682    fn a_plist_with_an_old_process_type_is_drift_and_an_operator_edit_is_not() {
9683        let host = Host::new();
9684        let identity = ServiceIdentity::product();
9685        let plan = InstallPlan::resolve(
9686            identity.clone(),
9687            &host.request(StartMode::Login),
9688            ServiceDirectories::of(&host.paths),
9689        )
9690        .expect("a resolvable plan");
9691        let installed = launchd_plist(&plan);
9692        let definition =
9693            ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, installed.clone());
9694        let mut record = InstallRecord::of(&plan, &definition, Utc::now());
9695        record.installed_by_version = "0.4.31".into();
9696        let path = PathBuf::from("/Users/op/Library/LaunchAgents/runner-manager.plist");
9697
9698        assert_eq!(
9699            launchd_definition_drift(&identity, &record, path.clone(), &installed),
9700            None
9701        );
9702        let edited_by_operator = edited(
9703            &installed,
9704            "</dict>\n</plist>\n",
9705            "  <key>Nice</key>\n  <integer>0</integer>\n</dict>\n</plist>\n",
9706        );
9707        assert_eq!(
9708            launchd_definition_drift(&identity, &record, path.clone(), &edited_by_operator),
9709            None,
9710            "the daemon repairs this drift itself, so it must not key on edits it did not cause"
9711        );
9712
9713        let written_by_0_4_31 = edited(
9714            &installed,
9715            "<string>Interactive</string>",
9716            "<string>Background</string>",
9717        );
9718        let drift = launchd_definition_drift(&identity, &record, path, &written_by_0_4_31)
9719            .expect("a Background plist is drift");
9720        assert_eq!(drift.manager, DefinitionKind::LaunchdPlist);
9721        assert_eq!(drift.start_mode, StartMode::Login);
9722        assert_eq!(
9723            drift.rendered, installed,
9724            "the repair must write exactly what `service install` writes"
9725        );
9726
9727        let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
9728            .with_definition_drift(Some(drift));
9729        let problem = status
9730            .problems()
9731            .iter()
9732            .find(|problem| problem.subject == "definition")
9733            .expect("the drift is reported");
9734        // The remedy is a restart: a running daemon holds the lock that refuses
9735        // `service install`.
9736        assert!(
9737            problem.detail.contains("0.4.31")
9738                && problem.detail.contains(
9739                    "launchctl kickstart -k gui/$(id -u)/io.github.IvanMurzak.runner-manager`"
9740                )
9741                && !problem.detail.contains("service install")
9742                && !problem.detail.contains("systemctl")
9743                && !problem.detail.contains("sudo"),
9744            "{}",
9745            problem.detail
9746        );
9747    }
9748
9749    #[cfg(unix)]
9750    #[test]
9751    fn a_replaced_definition_is_the_rendering_and_readable_by_launchd() {
9752        use std::os::unix::fs::PermissionsExt as _;
9753
9754        let root = tempfile::tempdir().expect("a temporary directory");
9755        let path = root
9756            .path()
9757            .join("io.github.IvanMurzak.runner-manager.plist");
9758        std::fs::write(&path, "<string>Background</string>").expect("old plist");
9759        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
9760        let drift = DefinitionDrift {
9761            path: path.clone(),
9762            installed_by_version: "0.4.31".into(),
9763            rendered: launchd_plist(&linux_plan(StartMode::Login)),
9764            start_mode: StartMode::Login,
9765            manager: DefinitionKind::LaunchdPlist,
9766            writable: Vec::new(),
9767        };
9768
9769        replace_definition("runner-manager", &drift).expect("replaced");
9770
9771        assert_eq!(std::fs::read_to_string(&path).unwrap(), drift.rendered);
9772        assert_eq!(
9773            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
9774            0o644
9775        );
9776        assert_eq!(
9777            std::fs::read_dir(root.path()).unwrap().count(),
9778            1,
9779            "no staging file is left beside the plist"
9780        );
9781    }
9782
9783    /// The job of [`a_job_reloads_itself_from_its_rewritten_plist`], run by
9784    /// launchd and by nothing else.
9785    ///
9786    /// It does what the daemon does when it starts: when its own plist (kept
9787    /// in its working directory) does not carry [`LAUNCHD_PROCESS_TYPE`], it
9788    /// rewrites the plist and asks for the reload from inside the job that the
9789    /// reload boots out. Either way it then stays up, to be booted out by the
9790    /// helper or, once reloaded, by the test.
9791    #[cfg(target_os = "macos")]
9792    #[test]
9793    #[ignore = "run only by launchd, as the job of the detached-reload test"]
9794    fn detached_reload_job() {
9795        let label = std::env::var("XPC_SERVICE_NAME").unwrap_or_default();
9796        let tag = label
9797            .split_once("-selftest-")
9798            .map(|(_, tag)| tag.to_string())
9799            .unwrap_or_else(|| panic!("run only by launchd for the reload test, not {label:?}"));
9800        let identity = ServiceIdentity::fixture(&tag);
9801        assert_eq!(identity.launchd_label(), label);
9802        let path = std::env::current_dir()
9803            .expect("working directory")
9804            .join(format!("{label}.plist"));
9805        let installed = std::fs::read_to_string(&path).expect("own plist");
9806        if plist_string_value(&installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE) {
9807            let drift = DefinitionDrift {
9808                path: path.clone(),
9809                installed_by_version: "0.4.31".into(),
9810                rendered: installed.replace(
9811                    "<string>Background</string>",
9812                    &format!("<string>{LAUNCHD_PROCESS_TYPE}</string>"),
9813                ),
9814                start_mode: StartMode::Login,
9815                manager: DefinitionKind::LaunchdPlist,
9816                writable: Vec::new(),
9817            };
9818            replace_definition(identity.name(), &drift).expect("plist replaced");
9819            super::sys::reload_detached(&identity, StartMode::Login, &path)
9820                .expect("helper started");
9821        }
9822        std::thread::sleep(Duration::from_secs(120));
9823    }
9824
9825    /// A job that rewrites its own plist and asks for the reload comes back
9826    /// under the rewritten plist.
9827    ///
9828    /// The request comes from inside the job, as the daemon's does, so the
9829    /// helper has to outlive the bootout of the very job that started it.
9830    /// launchd sends SIGTERM to the job's whole process group, so a helper that
9831    /// stayed in it would sometimes die before its bootstrap and leave the job
9832    /// unloaded. Measured with `setsid` removed, that failed one run in four
9833    /// here: this test catches it, though not on every run.
9834    #[cfg(target_os = "macos")]
9835    #[test]
9836    fn a_job_reloads_itself_from_its_rewritten_plist() {
9837        if !super::launchd_fixture::enabled() {
9838            return;
9839        }
9840        let root = tempfile::tempdir().expect("a temporary directory");
9841        let identity = ServiceIdentity::fixture(&format!("reload-{}", std::process::id()));
9842        let label = identity.launchd_label();
9843        let path = root.path().join(format!("{label}.plist"));
9844        let binary = super::launchd_fixture::job_binary(root.path());
9845        let mut plist = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
9846        plist.push_str("<plist version=\"1.0\">\n<dict>\n");
9847        plist.push_str(&plist_string("Label", &label));
9848        plist.push_str("  <key>ProgramArguments</key>\n  <array>\n");
9849        for argument in [
9850            binary.to_string_lossy().as_ref(),
9851            "--ignored",
9852            "--exact",
9853            "service::tests::detached_reload_job",
9854            "--test-threads=1",
9855        ] {
9856            plist.push_str(&format!("    <string>{}</string>\n", xml_escape(argument)));
9857        }
9858        plist.push_str("  </array>\n  <key>RunAtLoad</key>\n  <true/>\n");
9859        plist.push_str(&plist_string(
9860            "WorkingDirectory",
9861            &root.path().to_string_lossy(),
9862        ));
9863        plist.push_str(&plist_string("ProcessType", "Background"));
9864        plist.push_str("</dict>\n</plist>\n");
9865        std::fs::write(&path, plist).expect("plist written");
9866
9867        let loaded = super::launchd_fixture::LoadedAgent::bootstrap(&label, &path);
9868        let spawn_type = || {
9869            let printed = std::process::Command::new("launchctl")
9870                .args(["print", loaded.target()])
9871                .output()
9872                .expect("launchctl runs");
9873            String::from_utf8_lossy(&printed.stdout)
9874                .lines()
9875                .find_map(|line| {
9876                    line.trim()
9877                        .strip_prefix("spawn type = ")
9878                        .map(str::to_string)
9879                })
9880        };
9881        assert_eq!(spawn_type().as_deref(), Some("background (5)"));
9882
9883        let deadline = std::time::Instant::now() + Duration::from_secs(90);
9884        while spawn_type().as_deref() != Some("interactive (4)") {
9885            assert!(
9886                std::time::Instant::now() < deadline,
9887                "the job did not come back under its rewritten plist; launchd reports {:?}",
9888                spawn_type()
9889            );
9890            std::thread::sleep(Duration::from_millis(250));
9891        }
9892        let rewritten = std::fs::read_to_string(&path).expect("plist readable");
9893        assert_eq!(
9894            plist_string_value(&rewritten, "ProcessType").as_deref(),
9895            Some(LAUNCHD_PROCESS_TYPE)
9896        );
9897    }
9898
9899    #[test]
9900    fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
9901        let plist = launchd_plist(&linux_plan(StartMode::Boot));
9902        assert!(
9903            plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
9904            "{plist}"
9905        );
9906        assert!(plist.contains("<string>daemon</string>"), "{plist}");
9907        assert!(plist.contains("<string>run</string>"), "{plist}");
9908    }
9909
9910    #[test]
9911    fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
9912        assert_eq!(
9913            ServiceIdentity::product().launchd_label(),
9914            "io.github.IvanMurzak.runner-manager"
9915        );
9916    }
9917
9918    // -----------------------------------------------------------------------
9919    // The Task Scheduler document
9920    // -----------------------------------------------------------------------
9921
9922    #[test]
9923    fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
9924        let xml = windows_scheduled_task_xml(
9925            &windows_plan(StartMode::Login),
9926            &TaskPrincipal::named("HOST\\operator"),
9927        );
9928        assert!(xml.contains("<LogonTrigger>"), "{xml}");
9929        assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
9930        assert!(
9931            xml.contains("<LogonType>InteractiveToken</LogonType>"),
9932            "{xml}"
9933        );
9934        assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
9935        assert!(
9936            xml.contains("<Interval>PT1M</Interval>"),
9937            "Task Scheduler takes whole minutes only, and rejects the registration outright \
9938             for anything finer:\n{xml}"
9939        );
9940    }
9941
9942    #[test]
9943    fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
9944        // Measured against the real Task Scheduler, which answers `PT15S` with
9945        // "The task XML contains a value which is incorrectly formatted or out
9946        // of range". Rounding *up* is what keeps "does not restart-loop faster
9947        // than that bound" true on this manager.
9948        for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
9949            let policy =
9950                RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
9951                    .expect("inside the supported range");
9952            assert_eq!(
9953                policy
9954                    .effective_delay(DefinitionKind::WindowsScheduledTask)
9955                    .as_secs(),
9956                enforced,
9957                "a {asked}s delay must be enforced as {enforced}s"
9958            );
9959            assert!(
9960                policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
9961                "rounding must never shorten the bound"
9962            );
9963        }
9964    }
9965
9966    #[test]
9967    fn every_other_manager_enforces_the_delay_exactly_as_configured() {
9968        let policy = RestartPolicy::default();
9969        for kind in [
9970            DefinitionKind::WindowsService,
9971            DefinitionKind::LaunchdPlist,
9972            DefinitionKind::SystemdUnit,
9973        ] {
9974            assert_eq!(
9975                policy.effective_delay(kind),
9976                policy.delay(),
9977                "{kind:?} takes seconds and enforces exactly what it is given"
9978            );
9979        }
9980    }
9981
9982    #[test]
9983    fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
9984        let host = Host::new();
9985        let operations = host.operations();
9986        operations
9987            .install(&host.request(StartMode::Login))
9988            .expect("an install at login");
9989        // What a real Task Scheduler reports back for a 15-second policy.
9990        host.controls.edit("runner-manager", |registration| {
9991            registration.manager = DefinitionKind::WindowsScheduledTask;
9992            registration.restart_delay = Some(Duration::from_secs(60));
9993        });
9994
9995        let status = operations.status().expect("a status");
9996        assert!(
9997            status.is_healthy(),
9998            "minute granularity is the manager's, not a mis-registration: {status}"
9999        );
10000        assert!(
10001            status
10002                .notes()
10003                .iter()
10004                .any(|note| note.contains("whole minutes")),
10005            "but the operator must be told why 15 became 60: {status}"
10006        );
10007
10008        // The discriminator: a delay that is neither the configured one nor its
10009        // rounding is still a fault.
10010        host.controls.edit("runner-manager", |registration| {
10011            registration.restart_delay = Some(Duration::from_secs(1));
10012        });
10013        assert!(
10014            !operations.status().expect("a status").is_healthy(),
10015            "a one-second delay is not what any manager was asked for"
10016        );
10017    }
10018
10019    #[test]
10020    fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
10021        let plan = windows_plan(StartMode::Login);
10022        let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
10023        assert_eq!(
10024            xml_value(&xml, "Command").as_deref(),
10025            Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
10026            "{xml}"
10027        );
10028        assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
10029    }
10030
10031    #[test]
10032    fn an_account_name_holding_xml_punctuation_is_escaped() {
10033        let xml = windows_scheduled_task_xml(
10034            &windows_plan(StartMode::Login),
10035            &TaskPrincipal::named("R&D\\ops"),
10036        );
10037        assert!(xml.contains("<UserId>R&amp;D\\ops</UserId>"), "{xml}");
10038        assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
10039    }
10040
10041    // -----------------------------------------------------------------------
10042    // The Windows service descriptor
10043    // -----------------------------------------------------------------------
10044
10045    #[test]
10046    fn the_service_starts_automatically_under_the_account_the_store_admits() {
10047        let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
10048        let directives = ini_directives(&text, "windows-service");
10049        assert_eq!(
10050            directives.get("StartType").map(String::as_str),
10051            Some("AutoStart")
10052        );
10053        assert_eq!(
10054            directives.get("Account").map(String::as_str),
10055            Some("NT AUTHORITY\\SYSTEM")
10056        );
10057        assert_eq!(
10058            directives.get("ServiceType").map(String::as_str),
10059            Some("OWN_PROCESS")
10060        );
10061        assert_eq!(
10062            directives
10063                .get("FailureActionRestartDelaySecs")
10064                .map(String::as_str),
10065            Some("15")
10066        );
10067        assert_eq!(
10068            directives
10069                .get("FailureActionsOnNonCrashFailures")
10070                .map(String::as_str),
10071            Some("true"),
10072            "without this flag a non-zero exit is not a failure the manager restarts"
10073        );
10074    }
10075
10076    #[test]
10077    fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
10078        let spec = windows_service_spec(&windows_plan(StartMode::Boot));
10079        assert_eq!(spec.account, None);
10080        assert!(spec.automatic_start);
10081        assert!(
10082            spec.command_line.contains("daemon run"),
10083            "{}",
10084            spec.command_line
10085        );
10086    }
10087
10088    // -----------------------------------------------------------------------
10089    // Where each definition is installed
10090    // -----------------------------------------------------------------------
10091
10092    #[test]
10093    fn each_definition_goes_where_its_platform_expects_it() {
10094        let home = PathBuf::from("/home/op");
10095        assert_eq!(
10096            ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
10097            Some(Path::new(
10098                "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
10099            ))
10100        );
10101        assert_eq!(
10102            ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
10103            Some(Path::new(
10104                "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
10105            ))
10106        );
10107        assert_eq!(
10108            ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
10109            Some(Path::new("/etc/systemd/system/runner-manager.service"))
10110        );
10111        assert_eq!(
10112            ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
10113            Some(Path::new(
10114                "/home/op/.config/systemd/user/runner-manager.service"
10115            ))
10116        );
10117        assert_eq!(
10118            ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
10119            None,
10120            "the Service Control Manager has no file"
10121        );
10122    }
10123
10124    #[test]
10125    fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
10126        assert_eq!(
10127            ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
10128            None
10129        );
10130        assert_eq!(
10131            ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
10132            None
10133        );
10134    }
10135
10136    // -----------------------------------------------------------------------
10137    // The least-privilege review: the passing case, then every failing one
10138    // -----------------------------------------------------------------------
10139
10140    #[test]
10141    fn the_rendered_definitions_are_all_least_privilege() {
10142        let linux = linux_plan(StartMode::Boot);
10143        let windows = windows_plan(StartMode::Boot);
10144        for (definition, plan) in [
10145            (ServiceDefinition::systemd(&linux, None), &linux),
10146            (ServiceDefinition::launchd(&linux, None), &linux),
10147            (ServiceDefinition::windows_service(&windows), &windows),
10148        ] {
10149            let review = review_least_privilege(&definition, plan);
10150            assert!(
10151                review.is_least_privilege(),
10152                "{:?} should be least privilege, got:\n{review}",
10153                definition.kind()
10154            );
10155            assert!(
10156                !review.controls().is_empty(),
10157                "a review that confirms nothing proves nothing: {:?}",
10158                definition.kind()
10159            );
10160        }
10161    }
10162
10163    #[test]
10164    fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
10165        let plan = windows_plan(StartMode::Login);
10166        let definition =
10167            ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
10168        let review = review_least_privilege(&definition, &plan);
10169        assert!(review.is_least_privilege(), "{review}");
10170        assert!(
10171            review
10172                .controls()
10173                .iter()
10174                .any(|control| control.contains("LeastPrivilege")),
10175            "{review}"
10176        );
10177    }
10178
10179    #[test]
10180    fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
10181        let plan = linux_plan(StartMode::Boot);
10182        let rendered = systemd_unit(&plan);
10183        let widened = edited(
10184            &rendered,
10185            "ReadWritePaths=/var/lib/runner-manager/config",
10186            "ReadWritePaths=/etc /var/lib/runner-manager/config",
10187        );
10188        let review = review_least_privilege(
10189            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10190            &plan,
10191        );
10192        assert!(!review.is_least_privilege(), "{review}");
10193        assert!(
10194            review
10195                .excesses()
10196                .iter()
10197                .any(|finding| finding.detail.contains("/etc")),
10198            "the review must name the directory it objects to: {review}"
10199        );
10200    }
10201
10202    #[test]
10203    fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
10204        let plan = linux_plan(StartMode::Boot);
10205        let rendered = systemd_unit(&plan);
10206        let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
10207        let review = review_least_privilege(
10208            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
10209            &plan,
10210        );
10211        assert!(!review.is_least_privilege(), "{review}");
10212        assert!(
10213            review
10214                .excesses()
10215                .iter()
10216                .any(|finding| finding.subject == "NoNewPrivileges"),
10217            "{review}"
10218        );
10219    }
10220
10221    /// Whether reviewing `unit` against `plan` reports `kind` for `subject`.
10222    fn reports(unit: &str, plan: &InstallPlan, subject: &str, kind: FindingKind) -> bool {
10223        review_least_privilege(
10224            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, unit.to_string()),
10225            plan,
10226        )
10227        .findings()
10228        .iter()
10229        .any(|finding| finding.subject == subject && finding.kind == kind)
10230    }
10231
10232    #[test]
10233    fn protected_paths_are_inaccessible_and_the_review_keeps_them_so() {
10234        let plan = linux_plan(StartMode::Boot).with_protected_paths(vec![
10235            PathBuf::from("/srv/keys"),
10236            PathBuf::from("/data/my backups"),
10237        ]);
10238        let unit = systemd_unit(&plan);
10239        let directives = ini_directives(&unit, "Service");
10240        assert_eq!(
10241            split_quoted(&directives["InaccessiblePaths"]),
10242            ["-/srv/keys", "-/data/my backups"]
10243        );
10244        assert!(!reports(
10245            &unit,
10246            &plan,
10247            "InaccessiblePaths",
10248            FindingKind::Excess
10249        ));
10250        let dropped = unit.replace(
10251            &format!("InaccessiblePaths={}\n", directives["InaccessiblePaths"]),
10252            "",
10253        );
10254        assert!(reports(
10255            &dropped,
10256            &plan,
10257            "InaccessiblePaths",
10258            FindingKind::Excess
10259        ));
10260        assert!(
10261            !ini_directives(&systemd_unit(&linux_plan(StartMode::Boot)), "Service")
10262                .contains_key("InaccessiblePaths")
10263        );
10264    }
10265
10266    // Unix paths: protected paths are a systemd mechanism.
10267    #[cfg(unix)]
10268    #[test]
10269    fn a_path_every_job_or_the_service_needs_cannot_be_protected() {
10270        let own = [PathBuf::from("/var/lib/runner-manager/config")];
10271        for refused in [
10272            "/",
10273            "/usr",
10274            "/etc",
10275            "/tmp",
10276            "relative",
10277            "/srv/../etc",
10278            "/var/lib",
10279            "/var/lib/runner-manager/config/x",
10280        ] {
10281            assert!(
10282                ProtectedPaths::refusal(Path::new(refused), &own).is_some(),
10283                "{refused}"
10284            );
10285        }
10286        for accepted in ["/srv/keys", "/home/ivan/.ssh", "/var/lib/postgresql"] {
10287            assert_eq!(
10288                ProtectedPaths::refusal(Path::new(accepted), &own),
10289                None,
10290                "{accepted}"
10291            );
10292        }
10293        let directory = tempfile::tempdir().unwrap();
10294        assert_eq!(
10295            ProtectedPaths::load(directory.path()).unwrap(),
10296            ProtectedPaths::default()
10297        );
10298        let list = ProtectedPaths {
10299            paths: vec![PathBuf::from("/srv/keys")],
10300        };
10301        list.save(directory.path()).unwrap();
10302        assert_eq!(ProtectedPaths::load(directory.path()).unwrap(), list);
10303    }
10304
10305    #[test]
10306    fn a_boot_unit_with_directories_under_root_binds_them_back_into_its_tmpfs_home() {
10307        let legacy = InstallPlan::unchecked(
10308            ServiceIdentity::product(),
10309            StartMode::Boot,
10310            "/root/.local/state/runner-manager/bin/runner-manager",
10311            ServiceDirectories {
10312                config: PathBuf::from("/root/.config/runner-manager"),
10313                state: PathBuf::from("/root/.local/state/runner-manager"),
10314                runtime: PathBuf::from("/root/.local/share/runner-manager/runtime"),
10315                logs: PathBuf::from("/root/.local/share/runner-manager/logs"),
10316            },
10317        )
10318        .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token");
10319        let unit = systemd_unit(&legacy);
10320        let directives = ini_directives(&unit, "Service");
10321        assert_eq!(directives["ProtectHome"], "tmpfs");
10322        assert_eq!(
10323            directives["BindPaths"],
10324            "/root/.config/runner-manager /root/.local/state/runner-manager \
10325             /root/.local/share/runner-manager/runtime /root/.local/share/runner-manager/logs",
10326            "only the directories under a home tree, never the secrets outside it"
10327        );
10328        assert!(!reports(&unit, &legacy, "BindPaths", FindingKind::Excess));
10329        let widened = unit.replace("BindPaths=", "BindPaths=/root/.ssh /etc:/root/.config ");
10330        assert!(reports(&widened, &legacy, "BindPaths", FindingKind::Excess));
10331    }
10332
10333    #[test]
10334    fn a_move_stops_at_an_operator_drop_in_naming_the_old_tree_but_not_at_its_own() {
10335        let root = tempfile::tempdir().expect("dir");
10336        let drop_ins = root.path().join("runner-manager.service.d");
10337        std::fs::create_dir_all(&drop_ins).unwrap();
10338        let runtime = PathBuf::from("/root/.local/share/runner-manager/runtime");
10339        let config = PathBuf::from("/root/.config/runner-manager");
10340        let trees = [&config, &runtime];
10341        std::fs::write(
10342            drop_ins.join("20-wsl-recovery.conf"),
10343            "# Managed by runner-manager; the exact Windows/WSL recovery bridge.\n[Service]\n\
10344             ReadWritePaths=/root/.config/runner-manager/wsl-recovery\n",
10345        )
10346        .unwrap();
10347        assert_eq!(operator_drop_in_naming(&drop_ins, &trees), None);
10348        std::fs::write(
10349            drop_ins.join("10-actions-tar.conf"),
10350            "[Service]\nEnvironment=\"TMPDIR=/root/.local/share/runner-manager/runtime/tmp\"\n",
10351        )
10352        .unwrap();
10353        std::fs::write(drop_ins.join("notes.txt"), "/root/.config/runner-manager").unwrap();
10354        assert_eq!(
10355            operator_drop_in_naming(&drop_ins, &trees),
10356            Some(drop_ins.join("10-actions-tar.conf"))
10357        );
10358        assert_eq!(
10359            operator_drop_in_naming(&root.path().join("absent"), &trees),
10360            None
10361        );
10362    }
10363
10364    #[test]
10365    fn no_unit_breaks_tar_by_answering_openat2_with_enosys() {
10366        for mode in [StartMode::Boot, StartMode::Login] {
10367            let unit = systemd_unit(&linux_plan(mode));
10368            assert!(
10369                !unit.contains("RestrictSUIDSGID"),
10370                "RestrictSUIDSGID fails every tar extraction in a job: {unit}"
10371            );
10372        }
10373    }
10374
10375    #[test]
10376    fn only_a_boot_daemon_keeps_capabilities_and_boot_units_hide_home_trees() {
10377        let boot = systemd_unit(&linux_plan(StartMode::Boot));
10378        let directives = ini_directives(&boot, "Service");
10379        assert_eq!(directives["CapabilityBoundingSet"], BOOT_CAPABILITIES);
10380        assert_eq!(directives["AmbientCapabilities"], "");
10381        assert_eq!(directives["NoNewPrivileges"], "yes");
10382        assert_eq!(directives["ProtectHome"], "tmpfs");
10383        assert!(!directives.contains_key("BindPaths"));
10384
10385        let login = systemd_unit(&linux_plan(StartMode::Login));
10386        let directives = ini_directives(&login, "Service");
10387        assert_eq!(directives["CapabilityBoundingSet"], "");
10388        assert!(!directives.contains_key("ProtectHome"));
10389
10390        let unprotected = boot.replace("ProtectHome=tmpfs\n", "");
10391        assert!(reports(
10392            &unprotected,
10393            &linux_plan(StartMode::Boot),
10394            "ProtectHome",
10395            FindingKind::Excess
10396        ));
10397        let boot_set = format!("CapabilityBoundingSet={BOOT_CAPABILITIES}\n");
10398        let inverted = boot.replace(&boot_set, "CapabilityBoundingSet=~CAP_SYS_ADMIN\n");
10399        assert!(reports(
10400            &inverted,
10401            &linux_plan(StartMode::Boot),
10402            "CapabilityBoundingSet",
10403            FindingKind::Excess
10404        ));
10405        let widened = boot.replace(
10406            &boot_set,
10407            &format!("CapabilityBoundingSet={BOOT_CAPABILITIES} CAP_SYS_ADMIN\n"),
10408        );
10409        assert!(reports(
10410            &widened,
10411            &linux_plan(StartMode::Boot),
10412            "CapabilityBoundingSet",
10413            FindingKind::Excess
10414        ));
10415        let narrowed = boot.replace(&boot_set, "CapabilityBoundingSet=CAP_SETUID\n");
10416        assert!(reports(
10417            &narrowed,
10418            &linux_plan(StartMode::Boot),
10419            "CapabilityBoundingSet",
10420            FindingKind::Shortfall
10421        ));
10422    }
10423
10424    #[test]
10425    fn status_reports_the_registered_account_and_falls_back_to_the_record() {
10426        let recorded = ServiceAccount::for_start_mode(StartMode::Boot).to_string();
10427        for (registered, shown) in [(Some("ivan"), "ivan"), (None, recorded.as_str())] {
10428            let host = Host::new();
10429            let operations = host.operations();
10430            operations.install(&host.request(StartMode::Boot)).unwrap();
10431            host.controls.edit("runner-manager", |registration| {
10432                registration.account = registered.map(str::to_string);
10433            });
10434            let status = operations.status().unwrap().to_string();
10435            assert!(
10436                status.contains(&format!("account                   {shown}\n")),
10437                "{status}"
10438            );
10439        }
10440    }
10441
10442    #[cfg(target_os = "linux")]
10443    #[test]
10444    fn a_boot_install_refuses_a_unit_a_drop_in_runs_as_another_account() {
10445        let host = Host::new();
10446        let operations = host.operations();
10447        operations.install(&host.request(StartMode::Boot)).unwrap();
10448        host.controls.edit("runner-manager", |registration| {
10449            registration.account = Some("ivan".to_string());
10450        });
10451        let refused = operations
10452            .install(&host.request(StartMode::Boot))
10453            .expect_err("a unit running as ivan cannot use root's tree");
10454        let message = refused.to_string();
10455        assert!(message.contains("run as ivan"), "{message}");
10456        assert!(message.contains("Remove `User=`"), "{message}");
10457    }
10458
10459    #[test]
10460    fn manager_account_spellings_match_the_record() {
10461        assert_eq!(
10462            windows_service_account("LocalSystem"),
10463            ServiceAccount::LocalSystem.as_str()
10464        );
10465        assert_eq!(windows_service_account(".\\svc-runner"), ".\\svc-runner");
10466        assert_eq!(
10467            scheduled_task_account(Some("S-1-5-21-1-2-3-1001".into())),
10468            None
10469        );
10470        assert_eq!(
10471            scheduled_task_account(Some("HOST\\op".into())),
10472            Some("HOST\\op".to_string())
10473        );
10474    }
10475
10476    #[test]
10477    fn exec_start_prefixes_are_not_part_of_the_binary() {
10478        for (value, expected) in [
10479            ("/opt/rm daemon run", "/opt/rm daemon run"),
10480            ("-/opt/rm daemon run", "/opt/rm daemon run"),
10481            ("+!/opt/rm daemon run", "/opt/rm daemon run"),
10482            ("!!/opt/rm daemon run", "/opt/rm daemon run"),
10483            ("@/opt/rm runner-manager daemon run", "/opt/rm daemon run"),
10484            ("-@\"/opt/my rm\" rm daemon", "\"/opt/my rm\" daemon"),
10485        ] {
10486            assert_eq!(systemd_exec_command_line(value), expected, "{value}");
10487        }
10488        assert_eq!(
10489            executable_from_command_line(&systemd_exec_command_line("-/opt/rm daemon")),
10490            Some(PathBuf::from("/opt/rm"))
10491        );
10492    }
10493
10494    #[test]
10495    fn restart_sec_accepts_systemd_time_spans() {
10496        for (value, expected) in [
10497            ("15", Duration::from_secs(15)),
10498            ("15s", Duration::from_secs(15)),
10499            ("1min", Duration::from_secs(60)),
10500            ("1min 30s", Duration::from_secs(90)),
10501            ("1min30s", Duration::from_secs(90)),
10502            ("2h", Duration::from_secs(7_200)),
10503            ("500ms", Duration::from_millis(500)),
10504            ("1.5s", Duration::from_millis(1_500)),
10505        ] {
10506            assert_eq!(systemd_timespan(value), Some(expected), "{value}");
10507        }
10508        for value in ["", "infinity", "soon", "5 parsecs", "s"] {
10509            assert_eq!(systemd_timespan(value), None, "{value}");
10510        }
10511    }
10512
10513    #[cfg(unix)]
10514    #[test]
10515    fn the_system_tree_is_reported_as_roots_installation_under_var_lib() {
10516        let system = ServiceDirectories::of(&AppPaths::system());
10517        assert_eq!(system.root(), Path::new("/var/lib/runner-manager"));
10518        assert_eq!(
10519            data_root_owner(&system.root(), Some(Path::new("/home/op"))).as_deref(),
10520            Some("root")
10521        );
10522    }
10523
10524    #[test]
10525    fn a_boot_service_refuses_directories_it_could_not_open() {
10526        let var = Path::new("/var/lib/runner-manager/state");
10527        assert_eq!(boot_directory_refusal(var, Some(0)), None);
10528        let refused = boot_directory_refusal(var, Some(1000)).expect("another owner");
10529        assert!(refused.contains("uid 1000"), "{refused}");
10530        assert!(boot_directory_refusal(var, None).is_some());
10531        for hidden in ["/home/op/.config/runner-manager", "/root/.local/state/rm"] {
10532            let refused =
10533                boot_directory_refusal(Path::new(hidden), Some(0)).expect("a hidden tree");
10534            assert!(refused.contains("cannot see"), "{refused}");
10535        }
10536    }
10537
10538    #[cfg(unix)]
10539    #[test]
10540    fn a_moved_tree_keeps_files_modes_and_links_but_not_the_skipped_entry() {
10541        use std::os::unix::fs::PermissionsExt as _;
10542
10543        let from = tempfile::tempdir().unwrap();
10544        let to = tempfile::tempdir().unwrap();
10545        let target = to.path().join("state");
10546        std::fs::create_dir_all(from.path().join("journal/2026")).unwrap();
10547        std::fs::write(from.path().join("journal/2026/attempt"), b"entry").unwrap();
10548        std::fs::write(from.path().join("secret"), b"x").unwrap();
10549        std::fs::set_permissions(
10550            from.path().join("secret"),
10551            std::fs::Permissions::from_mode(0o600),
10552        )
10553        .unwrap();
10554        std::os::unix::fs::symlink("journal", from.path().join("latest")).unwrap();
10555        std::fs::create_dir(from.path().join("bin")).unwrap();
10556
10557        copy_tree(from.path(), &target, Some("bin")).unwrap();
10558
10559        assert_eq!(
10560            std::fs::read(target.join("journal/2026/attempt")).unwrap(),
10561            b"entry"
10562        );
10563        let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
10564        assert_eq!(mode(&target.join("secret")), 0o600);
10565        assert_eq!(mode(&target.join("journal")), 0o700);
10566        assert_eq!(
10567            std::fs::read_link(target.join("latest")).unwrap(),
10568            Path::new("journal")
10569        );
10570        assert!(!target.join("bin").exists());
10571
10572        // An occupied target is set aside whole, not merged into or deleted.
10573        std::fs::write(target.join("stale.sqlite3-wal"), b"stale").unwrap();
10574        set_aside(&target, "20261110T000000Z").unwrap();
10575        assert!(!target.exists());
10576        let aside = to.path().join("state.before-move-20261110T000000Z");
10577        assert_eq!(
10578            std::fs::read(aside.join("stale.sqlite3-wal")).unwrap(),
10579            b"stale"
10580        );
10581        copy_tree(from.path(), &target, Some("bin")).unwrap();
10582        assert!(!target.join("stale.sqlite3-wal").exists());
10583        set_aside(&to.path().join("absent"), "x").unwrap();
10584    }
10585
10586    #[test]
10587    fn systemd_drop_ins_replace_base_service_directives() {
10588        let base = systemd_unit(&linux_plan(StartMode::Boot));
10589        let combined = format!(
10590            "# /etc/systemd/system/runner-manager.service\n{base}\n\
10591             # /etc/systemd/system/runner-manager.service.d/account.conf\n\
10592             [Service]\nUser=ivan\nRestartSec=30\nExecStart=\n\
10593             ExecStart=/opt/runner-manager daemon run\n"
10594        );
10595        let directives = ini_directives(&combined, "Service");
10596        assert_eq!(directives["User"], "ivan");
10597        assert_eq!(directives["RestartSec"], "30");
10598        assert_eq!(directives["ExecStart"], "/opt/runner-manager daemon run");
10599        assert_eq!(directives["ProtectSystem"], "strict");
10600    }
10601
10602    #[test]
10603    fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
10604        let plan = linux_plan(StartMode::Login);
10605        let rendered = systemd_unit(&plan);
10606        let widened = edited(
10607            &rendered,
10608            "CapabilityBoundingSet=\n",
10609            "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
10610        );
10611        let review = review_least_privilege(
10612            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10613            &plan,
10614        );
10615        assert!(!review.is_least_privilege(), "{review}");
10616        assert!(
10617            review
10618                .excesses()
10619                .iter()
10620                .any(|finding| finding.subject == "CapabilityBoundingSet"),
10621            "{review}"
10622        );
10623    }
10624
10625    #[test]
10626    fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
10627        let plan = linux_plan(StartMode::Boot);
10628        let rendered = systemd_unit(&plan);
10629        let widened = edited(
10630            &rendered,
10631            "[Install]",
10632            "ListenStream=127.0.0.1:9000\n\n[Install]",
10633        );
10634        let review = review_least_privilege(
10635            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10636            &plan,
10637        );
10638        assert!(
10639            !review.is_least_privilege(),
10640            "07-security.md rule 2 forbids any inbound surface: {review}"
10641        );
10642    }
10643
10644    #[test]
10645    fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
10646        let plan = linux_plan(StartMode::Boot);
10647        let rendered = systemd_unit(&plan);
10648        let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
10649        let review = review_least_privilege(
10650            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
10651            &plan,
10652        );
10653        assert!(
10654            review.is_least_privilege(),
10655            "too little authority is not an excess: {review}"
10656        );
10657        assert!(
10658            review
10659                .findings()
10660                .iter()
10661                .any(|finding| finding.kind == FindingKind::Shortfall
10662                    && finding.detail.contains("runtime")),
10663            "{review}"
10664        );
10665    }
10666
10667    #[test]
10668    fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
10669        let plan = linux_plan(StartMode::Login);
10670        let rendered = launchd_plist(&plan);
10671        let widened = edited(
10672            &rendered,
10673            "<key>ProcessType</key>",
10674            "<key>UserName</key>\n  <string>root</string>\n  <key>ProcessType</key>",
10675        );
10676        let review = review_least_privilege(
10677            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10678            &plan,
10679        );
10680        assert!(!review.is_least_privilege(), "{review}");
10681        assert!(
10682            review
10683                .excesses()
10684                .iter()
10685                .any(|finding| finding.subject == "UserName"),
10686            "{review}"
10687        );
10688    }
10689
10690    #[test]
10691    fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
10692        let plan = linux_plan(StartMode::Boot);
10693        let rendered = launchd_plist(&plan);
10694        let widened = edited(
10695            &rendered,
10696            "<key>SessionCreate</key>\n  <false/>",
10697            "<key>SessionCreate</key>\n  <true/>",
10698        );
10699        let review = review_least_privilege(
10700            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10701            &plan,
10702        );
10703        assert!(!review.is_least_privilege(), "{review}");
10704        assert!(
10705            review
10706                .excesses()
10707                .iter()
10708                .any(|finding| finding.subject == "SessionCreate"),
10709            "{review}"
10710        );
10711    }
10712
10713    #[test]
10714    fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
10715        let plan = linux_plan(StartMode::Boot);
10716        let rendered = launchd_plist(&plan);
10717        let widened = edited(
10718            &rendered,
10719            "<key>ProcessType</key>",
10720            "<key>MachServices</key>\n  <dict/>\n  <key>ProcessType</key>",
10721        );
10722        let review = review_least_privilege(
10723            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10724            &plan,
10725        );
10726        assert!(!review.is_least_privilege(), "{review}");
10727    }
10728
10729    #[test]
10730    fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
10731        let plan = windows_plan(StartMode::Login);
10732        let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
10733        let widened = edited(
10734            &rendered,
10735            "<RunLevel>LeastPrivilege</RunLevel>",
10736            "<RunLevel>HighestAvailable</RunLevel>",
10737        );
10738        let review = review_least_privilege(
10739            &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
10740            &plan,
10741        );
10742        assert!(!review.is_least_privilege(), "{review}");
10743        assert!(
10744            review
10745                .excesses()
10746                .iter()
10747                .any(|finding| finding.subject == "RunLevel"),
10748            "{review}"
10749        );
10750    }
10751
10752    #[test]
10753    fn a_task_that_would_store_a_password_is_not_least_privilege() {
10754        let plan = windows_plan(StartMode::Login);
10755        let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
10756        let widened = edited(
10757            &rendered,
10758            "<LogonType>InteractiveToken</LogonType>",
10759            "<LogonType>Password</LogonType>",
10760        );
10761        let review = review_least_privilege(
10762            &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
10763            &plan,
10764        );
10765        assert!(!review.is_least_privilege(), "{review}");
10766    }
10767
10768    #[test]
10769    fn an_interactive_windows_service_is_not_least_privilege() {
10770        let plan = windows_plan(StartMode::Boot);
10771        let rendered = windows_service_descriptor(&plan);
10772        let widened = edited(
10773            &rendered,
10774            "ServiceType=OWN_PROCESS",
10775            "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
10776        );
10777        let review = review_least_privilege(
10778            &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
10779            &plan,
10780        );
10781        assert!(!review.is_least_privilege(), "{review}");
10782        assert!(
10783            review
10784                .excesses()
10785                .iter()
10786                .any(|finding| finding.subject == "ServiceType"),
10787            "{review}"
10788        );
10789    }
10790
10791    #[test]
10792    fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
10793        let plan = windows_plan(StartMode::Boot);
10794        let rendered = windows_service_descriptor(&plan);
10795        let changed = edited(
10796            &rendered,
10797            "Account=NT AUTHORITY\\SYSTEM",
10798            "Account=NT AUTHORITY\\LocalService",
10799        );
10800        let review = review_least_privilege(
10801            &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
10802            &plan,
10803        );
10804        // LocalService is *less* privileged, so this is not an excess. It is
10805        // still wrong, because `d2`'s DACL names SY, BA and OW and nothing else:
10806        // the daemon would start and find no credential. Reporting it as a
10807        // shortfall rather than as an excess is the difference between a check
10808        // that understands the requirement and one that counts adjectives.
10809        assert!(review.is_least_privilege(), "{review}");
10810        assert!(
10811            review.findings().iter().any(|finding| {
10812                finding.kind == FindingKind::Shortfall && finding.subject == "Account"
10813            }),
10814            "{review}"
10815        );
10816    }
10817
10818    // -----------------------------------------------------------------------
10819    // The recorded absolute path, and what became of it
10820    // -----------------------------------------------------------------------
10821
10822    #[test]
10823    fn a_recorded_path_that_is_still_there_is_current() {
10824        let host = Host::new();
10825        let state = inspect_binary(&host.binary, Some(&host.binary));
10826        assert!(!state.is_error(), "{state}");
10827        assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
10828    }
10829
10830    #[test]
10831    fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
10832        let host = Host::new();
10833        // An `npm i -g` binary lives under the active Node installation's
10834        // global prefix. Switching Node versions moves the prefix, and the
10835        // recorded path stops existing while the registration survives.
10836        let recorded = host.binary.clone();
10837        let healthy = inspect_binary(&recorded, Some(&recorded));
10838        assert!(
10839            !healthy.is_error(),
10840            "the discriminator: before the binary moves, this must be healthy"
10841        );
10842
10843        std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
10844
10845        let state = inspect_binary(&recorded, Some(&recorded));
10846        assert!(state.is_error(), "{state}");
10847        assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
10848        assert!(
10849            state.to_string().contains("npm"),
10850            "the message must name the cause an operator will not otherwise connect: {state}"
10851        );
10852    }
10853
10854    #[test]
10855    fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
10856        let root = tempfile::tempdir().expect("a temporary directory");
10857        let state = inspect_binary(root.path(), None);
10858        assert!(state.is_error(), "{state}");
10859        assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
10860    }
10861
10862    #[test]
10863    fn a_registration_naming_a_different_binary_is_a_divergence() {
10864        let host = Host::new();
10865        let other = host.binary.with_file_name("something-else");
10866        let state = inspect_binary(&host.binary, Some(&other));
10867        assert!(state.is_error(), "{state}");
10868        assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
10869    }
10870
10871    #[test]
10872    fn absence_is_reported_before_divergence() {
10873        let host = Host::new();
10874        let recorded = host.binary.clone();
10875        std::fs::remove_file(&recorded).expect("removable");
10876        let other = recorded.with_file_name("something-else");
10877        // Both faults are true at once. The operator needs to hear about the
10878        // missing file, not about a disagreement between two paths of which one
10879        // does not exist.
10880        assert!(matches!(
10881            inspect_binary(&recorded, Some(&other)),
10882            BinaryPath::Missing { .. }
10883        ));
10884    }
10885
10886    // -----------------------------------------------------------------------
10887    // The record
10888    // -----------------------------------------------------------------------
10889
10890    #[test]
10891    fn the_record_round_trips_through_toml() {
10892        let host = Host::new();
10893        let plan = InstallPlan::resolve(
10894            ServiceIdentity::product(),
10895            &host.request(StartMode::Boot),
10896            ServiceDirectories::of(&host.paths),
10897        )
10898        .expect("a resolvable plan");
10899        let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
10900        let record = InstallRecord::of(&plan, &definition, Utc::now());
10901        record.write(&host.paths).expect("a writable record");
10902        let read = InstallRecord::read(&host.paths)
10903            .expect("a readable record")
10904            .expect("a record is there");
10905        assert_eq!(read, record);
10906        assert_eq!(read.binary, host.binary);
10907        assert!(read.binary.is_absolute());
10908    }
10909
10910    #[test]
10911    fn a_unit_written_by_an_older_build_is_reported_and_a_current_one_is_not() {
10912        let host = Host::new();
10913        let identity = ServiceIdentity::product();
10914        let plan = InstallPlan::resolve(
10915            identity.clone(),
10916            &host.request(StartMode::Boot),
10917            ServiceDirectories::of(&host.paths),
10918        )
10919        .expect("a resolvable plan");
10920        let installed = systemd_unit(&plan);
10921        let definition =
10922            ServiceDefinition::from_text(DefinitionKind::SystemdUnit, installed.clone());
10923        let mut record = InstallRecord::of(&plan, &definition, Utc::now());
10924        let path = PathBuf::from("/etc/systemd/system/runner-manager.service");
10925
10926        assert_eq!(
10927            systemd_definition_drift(&identity, &record, path.clone(), &installed),
10928            None,
10929            "the record must rebuild exactly the unit `service install` wrote"
10930        );
10931
10932        record.installed_by_version = "0.4.7".into();
10933        let frozen = installed.replace(
10934            "[Service]\n",
10935            "[Service]\nLoadCredential=runner-manager.user-access-token:/var/lib/runner-manager/secrets/user-access-token\n",
10936        );
10937        let drift = systemd_definition_drift(&identity, &record, path, &frozen)
10938            .expect("an outdated unit is drift");
10939        assert_eq!(drift.installed_by_version, "0.4.7");
10940        assert_eq!(drift.rendered, installed);
10941
10942        let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
10943            .with_definition_drift(Some(drift));
10944        assert!(!status.is_healthy());
10945        assert!(
10946            status
10947                .problems()
10948                .iter()
10949                .any(|problem| problem.subject == "definition" && problem.detail.contains("0.4.7")),
10950            "{status}"
10951        );
10952    }
10953
10954    #[test]
10955    fn a_rejected_credential_keeps_its_first_moment_and_makes_the_service_unhealthy() {
10956        let host = Host::new();
10957        assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
10958        let first = Utc::now() - chrono::Duration::hours(9);
10959        record_github_credential_rejected(&host.paths, first).unwrap();
10960        record_github_credential_rejected(&host.paths, Utc::now()).unwrap();
10961        let since = github_credential_rejected_since(&host.paths).unwrap();
10962        assert_eq!(since, Some(first));
10963
10964        let status = ServiceStatus::compose(
10965            ServiceIdentity::product(),
10966            None,
10967            None,
10968            None,
10969            None,
10970            &host.paths,
10971        )
10972        .with_credential_rejection(since);
10973        assert!(!status.is_healthy());
10974        assert_eq!(status.credential_rejected_since(), Some(first));
10975        assert!(status.to_string().contains("auth login"), "{status}");
10976
10977        clear_github_credential_rejection(&host.paths).unwrap();
10978        clear_github_credential_rejection(&host.paths).unwrap();
10979        assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
10980    }
10981
10982    /// A guest that started nothing for 13 days while this said `healthy`.
10983    #[test]
10984    fn blocked_launches_make_the_service_unhealthy_with_their_remedy() {
10985        use crate::launch_health::{
10986            BlockCause, Viewpoint, clear_launches_blocked, launches_blocked,
10987            record_launches_blocked,
10988        };
10989
10990        let host = Host::new();
10991        let compose = || {
10992            ServiceStatus::compose(
10993                ServiceIdentity::product(),
10994                None,
10995                None,
10996                None,
10997                None,
10998                &host.paths,
10999            )
11000            .with_launches_blocked(launches_blocked(&host.paths, Utc::now()))
11001        };
11002        assert!(
11003            !compose()
11004                .problems()
11005                .iter()
11006                .any(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
11007        );
11008        let blocked = BlockCause::FenceHeld {
11009            directory: PathBuf::from("launch-fence"),
11010            owner: None,
11011        }
11012        .blocked_since(Utc::now(), Viewpoint::Guest);
11013        record_launches_blocked(&host.paths, &blocked, Utc::now()).unwrap();
11014
11015        let status = compose();
11016        assert!(!status.is_healthy());
11017        assert_eq!(status.launches_blocked(), Some(&blocked));
11018        let problem = status
11019            .problems()
11020            .iter()
11021            .find(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
11022            .expect("reported under its own subject");
11023        assert!(problem.detail.contains("systemctl restart"), "{status}");
11024
11025        clear_launches_blocked(&host.paths).unwrap();
11026        assert_eq!(compose().launches_blocked(), None);
11027    }
11028
11029    /// The daemon's own "I cannot read my credential", which used to exist
11030    /// only in its log while `service status` said `healthy`.
11031    #[test]
11032    fn an_unreadable_credential_keeps_its_first_moment_and_names_the_exact_command() {
11033        let host = Host::new();
11034        assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
11035        let first = Utc::now() - chrono::Duration::minutes(40);
11036        record_credential_unreadable(&host.paths, first).unwrap();
11037        record_credential_unreadable(&host.paths, Utc::now()).unwrap();
11038        let since = credential_unreadable_since(&host.paths).unwrap();
11039        assert_eq!(since, Some(first), "a restart loop must not move the start");
11040
11041        let status = ServiceStatus::compose(
11042            ServiceIdentity::product(),
11043            None,
11044            None,
11045            None,
11046            None,
11047            &host.paths,
11048        )
11049        .with_credential_unreadable(since);
11050        assert!(!status.is_healthy());
11051        let problem = status
11052            .problems()
11053            .iter()
11054            .find(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT)
11055            .expect("reported under its own subject");
11056        // No record, so the default start mode, whose exact command is named.
11057        assert!(
11058            problem
11059                .detail
11060                .contains(&sign_in_instruction(StartMode::default())),
11061            "{status}"
11062        );
11063
11064        clear_credential_unreadable(&host.paths).unwrap();
11065        clear_credential_unreadable(&host.paths).unwrap();
11066        assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
11067        let healthy = ServiceStatus::compose(
11068            ServiceIdentity::product(),
11069            None,
11070            None,
11071            None,
11072            None,
11073            &host.paths,
11074        )
11075        .with_credential_unreadable(None);
11076        assert!(
11077            !healthy
11078                .problems()
11079                .iter()
11080                .any(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT),
11081            "{healthy}"
11082        );
11083    }
11084
11085    #[test]
11086    fn a_sudo_command_names_a_binary_sudo_would_not_find() {
11087        assert_eq!(sudo_program(None), "runner-manager");
11088        assert_eq!(
11089            sudo_program(Some(Path::new("/usr/local/bin/runner-manager"))),
11090            "runner-manager"
11091        );
11092        assert_eq!(
11093            sudo_program(Some(Path::new(
11094                "/home/op/.nvm/versions/node/v26.13.0/lib/node_modules/@ivan-murzak/\
11095                 runner-manager-linux-x64/bin/runner-manager"
11096            ))),
11097            "/home/op/.nvm/versions/node/v26.13.0/lib/node_modules/@ivan-murzak/\
11098             runner-manager-linux-x64/bin/runner-manager"
11099        );
11100        assert_eq!(
11101            sudo_program(Some(Path::new("/home/o'p/my bin/runner-manager"))),
11102            "'/home/o'\\''p/my bin/runner-manager'"
11103        );
11104        assert_eq!(
11105            sudo_command("service status"),
11106            "sudo runner-manager service status"
11107        );
11108    }
11109
11110    #[test]
11111    fn the_sign_in_instruction_names_the_start_mode_it_writes() {
11112        assert!(sign_in_instruction(StartMode::Login).contains("auth login --start-at login`"));
11113        assert!(sign_in_instruction(StartMode::Boot).contains("auth login --start-at boot`"));
11114        assert_eq!(
11115            sign_in_instruction(StartMode::Boot).starts_with("`sudo "),
11116            cfg!(unix),
11117            "a machine-scoped store needs root on Unix"
11118        );
11119        assert_eq!(
11120            sign_in_instruction(StartMode::Login).contains("SSH"),
11121            cfg!(target_os = "macos"),
11122            "only the macOS login keychain is out of an SSH session's reach"
11123        );
11124    }
11125
11126    /// The record is readable by the account whose directory it is in.
11127    ///
11128    /// A boot-mode `service install` runs under `sudo`, and `NamedTempFile`
11129    /// creates at `0600`, so the record landed `root`-owned and unreadable by
11130    /// the operator — `service status` then failed on their own host with
11131    /// `Permission denied`. Ownership cannot be reproduced in an unprivileged
11132    /// test, but the mode that made ownership fatal can, and it is the half
11133    /// this code controls.
11134    #[cfg(unix)]
11135    #[test]
11136    fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
11137        use std::os::unix::fs::PermissionsExt as _;
11138
11139        let host = Host::new();
11140        let plan = InstallPlan::resolve(
11141            ServiceIdentity::product(),
11142            &host.request(StartMode::Boot),
11143            ServiceDirectories::of(&host.paths),
11144        )
11145        .expect("a resolvable plan");
11146        let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
11147        InstallRecord::of(&plan, &definition, Utc::now())
11148            .write(&host.paths)
11149            .expect("a writable record");
11150
11151        let mode = std::fs::metadata(InstallRecord::path(&host.paths))
11152            .expect("the record is there")
11153            .permissions()
11154            .mode()
11155            & 0o777;
11156        assert_eq!(
11157            mode, 0o644,
11158            "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
11159             service install` wrote, and `service status` fails on their own host. It holds no \
11160             credential and sits in a 0700 directory, so 0644 discloses nothing"
11161        );
11162    }
11163
11164    /// A record written before the service ran a copy of its own must still
11165    /// load. It reads as `None`, which is the truth about it: that registration
11166    /// names the package manager's file and cannot be upgraded under itself.
11167    #[test]
11168    fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
11169        let host = Host::new();
11170        let path = InstallRecord::path(&host.paths);
11171        std::fs::write(
11172            &path,
11173            format!(
11174                "schema_version = {RECORD_SCHEMA_VERSION}
11175service_name = \"runner-manager\"
11176                 manager = \"systemd\"
11177start_mode = \"boot\"
11178account = \"root\"
11179                 binary = \"/x\"
11180arguments = []
11181restart_delay_secs = 15
11182                 restart_reset_secs = 600
11183log_file = \"/x\"
11184                 installed_at = \"2026-01-01T00:00:00Z\"
11185installed_by_version = \"0.1.0\"
11186                 [directories]
11187config = \"/a\"
11188state = \"/b\"
11189runtime = \"/c\"
11190logs = \"/d\"
11191"
11192            ),
11193        )
11194        .expect("a writable record");
11195        let read = InstallRecord::read(&host.paths)
11196            .expect("a record missing an optional field is still readable")
11197            .expect("a record is there");
11198        assert_eq!(
11199            read.source_binary, None,
11200            "the legacy layout has no source, and must not invent one"
11201        );
11202    }
11203
11204    /// The field that makes an upgrade possible survives the write.
11205    #[test]
11206    fn a_registration_remembers_the_file_it_was_copied_from() {
11207        let host = Host::new();
11208        let source = host.binary.with_file_name("npm-installed-runner-manager");
11209        std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
11210        let plan = InstallPlan::resolve(
11211            ServiceIdentity::product(),
11212            &host.request(StartMode::Boot).copied_from(&source),
11213            ServiceDirectories::of(&host.paths),
11214        )
11215        .expect("a resolvable plan");
11216        let definition = ServiceDefinition::from_text(
11217            DefinitionKind::SystemdUnit,
11218            "[Service]
11219",
11220        );
11221        let record = InstallRecord::of(&plan, &definition, Utc::now());
11222        record.write(&host.paths).expect("a writable record");
11223
11224        let read = InstallRecord::read(&host.paths)
11225            .expect("a readable record")
11226            .expect("a record is there");
11227        assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
11228        assert_ne!(
11229            read.source_binary.as_deref(),
11230            Some(read.binary.as_path()),
11231            "the whole point is that the two are different files: one the service holds open,              one the package manager is free to replace"
11232        );
11233    }
11234
11235    #[test]
11236    fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
11237        let host = Host::new();
11238        let path = InstallRecord::path(&host.paths);
11239        std::fs::write(
11240            &path,
11241            format!(
11242                "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
11243                 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
11244                 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
11245                 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
11246                 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
11247                RECORD_SCHEMA_VERSION + 1
11248            ),
11249        )
11250        .expect("a writable record");
11251        let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
11252        assert!(
11253            matches!(error, ServiceError::RecordUnreadable { .. }),
11254            "{error}"
11255        );
11256        assert!(
11257            error.to_string().contains("service uninstall"),
11258            "the message must say how to recover: {error}"
11259        );
11260    }
11261
11262    #[test]
11263    fn no_record_is_not_an_error() {
11264        let host = Host::new();
11265        assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
11266        assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
11267    }
11268
11269    // -----------------------------------------------------------------------
11270    // The last successful GitHub contact
11271    // -----------------------------------------------------------------------
11272
11273    #[test]
11274    fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
11275        let host = Host::new();
11276        assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
11277    }
11278
11279    #[test]
11280    fn the_heartbeat_round_trips_to_the_second() {
11281        let host = Host::new();
11282        let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
11283            .expect("a valid timestamp")
11284            .with_timezone(&Utc);
11285        record_github_contact(&host.paths, at).expect("a writable heartbeat");
11286        assert_eq!(
11287            last_github_contact(&host.paths).expect("readable"),
11288            Some(at)
11289        );
11290    }
11291
11292    #[test]
11293    fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
11294        let host = Host::new();
11295        std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
11296        let error = last_github_contact(&host.paths)
11297            .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
11298        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11299    }
11300
11301    /// The surface the three-hour outage did not have.
11302    ///
11303    /// A root that refuses a launch does so before any attempt row exists, and
11304    /// the daemon's log scrubs the paths out of the sentence, so this file is
11305    /// the only place the directory and the remediation can reach the operator.
11306    #[test]
11307    fn a_runner_root_refusal_round_trips_for_service_status() {
11308        let host = Host::new();
11309        let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
11310
11311        assert!(
11312            runner_root_refusals(&host.paths)
11313                .expect("readable")
11314                .is_empty(),
11315            "no record means every policy is placing runners"
11316        );
11317
11318        record_runner_root_refusal(
11319            &host.paths,
11320            "policy-a",
11321            at,
11322            "denied_by_privacy_policy",
11323            "/Volumes/NVME/runners",
11324            "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
11325        )
11326        .expect("a writable record");
11327
11328        let refusals = runner_root_refusals(&host.paths).expect("readable");
11329        assert_eq!(refusals.len(), 1);
11330        assert_eq!(refusals[0].policy, "policy-a");
11331        assert_eq!(refusals[0].at, at);
11332        assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
11333        assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
11334        assert!(
11335            refusals[0].detail.contains("/Volumes/NVME/runners")
11336                && refusals[0].detail.contains("Full Disk Access"),
11337            "the path and the remediation are the whole point of this file: {refusals:?}"
11338        );
11339    }
11340
11341    /// The regression that would put the outage straight back.
11342    ///
11343    /// A host runs several policies and they do not share a fate. When the
11344    /// record was one host-wide slot, the policy that placed a runner deleted
11345    /// the record of the policy that could not — on the same reconcile pass —
11346    /// and `service status` reported a healthy host that had started zero
11347    /// runners for an entire target.
11348    #[test]
11349    fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
11350        let host = Host::new();
11351        let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
11352        record_runner_root_refusal(
11353            &host.paths,
11354            "broken",
11355            at,
11356            "denied_by_privacy_policy",
11357            "/Volumes/NVME/runners",
11358            "detail",
11359        )
11360        .expect("a writable record");
11361        record_runner_root_refusal(
11362            &host.paths,
11363            "also-broken",
11364            at,
11365            "not_writable",
11366            "/srv/other",
11367            "detail",
11368        )
11369        .expect("a writable record");
11370
11371        // The policy with its own working root succeeds and clears only itself.
11372        clear_runner_root_refusal(&host.paths, "healthy")
11373            .expect("clearing an absent policy is fine");
11374        clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
11375
11376        let refusals = runner_root_refusals(&host.paths).expect("readable");
11377        assert_eq!(
11378            refusals
11379                .iter()
11380                .map(|r| r.policy.as_str())
11381                .collect::<Vec<_>>(),
11382            vec!["broken"],
11383            "the policy that is still refused must keep its record"
11384        );
11385    }
11386
11387    /// Clearing is what keeps a fixed host from reporting a stale fault, and the
11388    /// file goes when the last entry does.
11389    #[test]
11390    fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
11391        let host = Host::new();
11392        record_runner_root_refusal(
11393            &host.paths,
11394            "p",
11395            Utc::now(),
11396            "not_writable",
11397            "/srv/x",
11398            "detail",
11399        )
11400        .expect("a writable record");
11401
11402        clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
11403        assert!(
11404            !root_refusal_path(&host.paths).exists(),
11405            "a host with nothing refused leaves nothing behind"
11406        );
11407        clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
11408    }
11409
11410    /// Same rule as the heartbeat: absence means "every policy is placing
11411    /// runners", so a record that cannot be parsed must not be reported as one.
11412    #[test]
11413    fn a_malformed_refusal_is_an_error_and_not_silently_none() {
11414        let host = Host::new();
11415        std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
11416        let error = runner_root_refusals(&host.paths)
11417            .expect_err("an unparseable record is not the same as no record");
11418        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11419    }
11420
11421    /// The half of this feature that turns the file into a diagnosis, and the
11422    /// half that had no test: `service status` must actually read it.
11423    ///
11424    /// A **note** and not a problem, deliberately. `service status` runs as
11425    /// whoever typed it, must stay readable on a host with nothing installed,
11426    /// and drives an exit code -- and nothing an operator can type clears this
11427    /// record, so a problem would fail that exit code on a host whose root was
11428    /// fixed but which has had no queued job since, with a printed remedy
11429    /// (`service uninstall && service install`) that does not touch `state/`.
11430    #[test]
11431    fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
11432        let host = Host::new();
11433        record_runner_root_refusal(
11434            &host.paths,
11435            "policy-a",
11436            DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
11437            "denied_by_privacy_policy",
11438            "/Volumes/NVME/runners",
11439            "Grant Full Disk Access to the program that runs the service",
11440        )
11441        .expect("a writable record");
11442
11443        let status = host.operations().status().expect("a readable status");
11444        let notes = status.notes().join("\n");
11445
11446        assert!(
11447            notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
11448            "the directory and the remediation the log had to scrub must appear here: {notes}"
11449        );
11450        assert!(
11451            notes.contains("policy-a"),
11452            "the operator has to know which target placed no runner: {notes}"
11453        );
11454        assert!(
11455            !status
11456                .problems()
11457                .iter()
11458                .any(|problem| problem.subject == "runner root"),
11459            "a record nothing an operator types can clear must not drive the exit code"
11460        );
11461    }
11462
11463    // -----------------------------------------------------------------------
11464    // -----------------------------------------------------------------------
11465    // Install
11466    // -----------------------------------------------------------------------
11467
11468    #[test]
11469    fn install_records_the_absolute_binary_path_and_the_four_directories() {
11470        let host = Host::new();
11471        let installed = host
11472            .operations()
11473            .install(&host.request(StartMode::Boot))
11474            .expect("an install against the recording controls");
11475
11476        assert_eq!(installed.record.binary, host.binary);
11477        assert!(installed.record.binary.is_absolute());
11478        assert_eq!(installed.record.start_mode, StartMode::Boot);
11479        assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
11480        assert_eq!(
11481            installed.record.directories,
11482            ServiceDirectories::of(&host.paths)
11483        );
11484        assert_eq!(
11485            installed.record.log_file,
11486            host.paths.logs_dir().join(LOG_FILE_STEM)
11487        );
11488        assert_eq!(installed.record.restart_delay_secs, 15);
11489
11490        let registrations = host.controls.registrations();
11491        assert_eq!(registrations.len(), 1);
11492        assert_eq!(registrations[0].0, StartMode::Boot);
11493        assert_eq!(registrations[0].1, "runner-manager");
11494    }
11495
11496    #[test]
11497    fn install_is_refused_while_the_single_instance_lock_is_held() {
11498        let host = Host::new();
11499        // The discriminator: with the lock free, the same call succeeds.
11500        {
11501            let operations = host.operations();
11502            operations
11503                .install(&host.request(StartMode::Boot))
11504                .expect("an install with the lock free");
11505            operations.uninstall().expect("a clean slate");
11506        }
11507
11508        let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
11509            .expect("this process takes the lock first");
11510
11511        let error = host
11512            .operations()
11513            .install(&host.request(StartMode::Boot))
11514            .expect_err("a second agent must not be registered while one is running");
11515        assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
11516        assert!(
11517            error.to_string().contains("already running"),
11518            "the message must be actionable: {error}"
11519        );
11520        assert!(
11521            host.controls.registrations().is_empty(),
11522            "a refused install must register nothing"
11523        );
11524        assert_eq!(
11525            InstallRecord::read(&host.paths).expect("readable"),
11526            None,
11527            "a refused install must write no record"
11528        );
11529    }
11530
11531    /// Installing over the same start mode replaces the registration.
11532    ///
11533    /// It used to be refused, and the refusal is what broke a real host: the
11534    /// caller has already swapped the binary the service runs by the time this
11535    /// is reached — that is the documented way to upgrade by hand — so refusing
11536    /// left a daemon running an unregistered binary and, on macOS, one the
11537    /// stored credential's keychain grant did not name.
11538    #[test]
11539    fn installing_over_the_same_start_mode_replaces_the_registration() {
11540        let host = Host::new();
11541        let operations = host.operations();
11542        operations
11543            .install(&host.request(StartMode::Boot))
11544            .expect("the first install");
11545
11546        let again = operations
11547            .install(&host.request(StartMode::Boot))
11548            .expect("an install over the same mode replaces rather than refusing");
11549
11550        assert!(
11551            again.replaced_existing,
11552            "the operator is told this replaced something rather than made it"
11553        );
11554        assert_eq!(
11555            host.controls.registrations().len(),
11556            1,
11557            "replacing must not leave two registrations behind"
11558        );
11559        assert_eq!(
11560            InstallRecord::read(&host.paths)
11561                .expect("readable")
11562                .expect("a record")
11563                .start_mode,
11564            StartMode::Boot
11565        );
11566    }
11567
11568    /// The *other* mode is still refused, because changing it moves the
11569    /// registration between two service managers and changes the account and
11570    /// the secret store with it. `set_start_mode` is what does that.
11571    #[test]
11572    fn installing_over_the_other_start_mode_is_refused() {
11573        let host = Host::new();
11574        let operations = host.operations();
11575        operations
11576            .install(&host.request(StartMode::Boot))
11577            .expect("the first install");
11578
11579        let error = operations
11580            .install(&host.request(StartMode::Login))
11581            .expect_err("a mode change is not an install");
11582        assert!(
11583            matches!(
11584                error,
11585                ServiceError::AlreadyInstalled {
11586                    existing: StartMode::Boot,
11587                    requested: StartMode::Login,
11588                    ..
11589                }
11590            ),
11591            "{error}"
11592        );
11593        assert!(
11594            !error
11595                .to_string()
11596                .contains("switch the start mode in place,"),
11597            "the old remedy named a capability no command offers; the terminal UI is where \
11598             the start mode moves: {error}"
11599        );
11600        assert_eq!(host.controls.registrations().len(), 1);
11601    }
11602
11603    #[test]
11604    fn install_rolls_back_the_registration_when_record_persistence_fails() {
11605        let host = Host::new();
11606        let record_path = InstallRecord::path(&host.paths);
11607        std::fs::create_dir(&record_path).expect("a directory blocks the record file");
11608
11609        let error = host
11610            .operations()
11611            .install(&host.request(StartMode::Boot))
11612            .expect_err("record persistence must fail");
11613
11614        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11615        assert!(
11616            host.controls.registrations().is_empty(),
11617            "a failed install must not leave a live unrecorded registration"
11618        );
11619        assert!(
11620            host.controls
11621                .calls()
11622                .iter()
11623                .any(|call| call == "uninstall runner-manager (boot)"),
11624            "the registration must be explicitly rolled back: {:?}",
11625            host.controls.calls()
11626        );
11627        assert!(
11628            !host.runner_root.as_path().exists(),
11629            "the rollback must take the runner root this install created with it; a directory \
11630             prepared for a registration that does not exist is litter, and on Windows it is \
11631             litter with a security descriptor"
11632        );
11633    }
11634
11635    // -----------------------------------------------------------------------
11636    // The runner root (b2)
11637    // -----------------------------------------------------------------------
11638
11639    #[test]
11640    fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
11641        use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
11642
11643        // The mapping `prepare_runner_root` reads, asserted against the same
11644        // function the registration's own principal comes from — which is the
11645        // point of deriving it there rather than restating it.
11646        assert_eq!(
11647            ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
11648            ServiceAccount::LocalSystem
11649        );
11650        assert_eq!(
11651            ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
11652            ServiceAccount::InvokingUser
11653        );
11654
11655        let boot = default_root_sddl(&RootAdmission::LocalSystem);
11656        assert!(!grants_broad_write(&boot), "{boot}");
11657        assert!(
11658            !boot.contains("S-1-5-21"),
11659            "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
11660        );
11661
11662        // A login task runs under a *filtered* token — `RunLevel` is
11663        // `LeastPrivilege`, see `windows_scheduled_task_xml` — in which
11664        // Administrators is deny-only. Without an ace of its own the account
11665        // the task runs as would be admitted by nothing at all.
11666        let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
11667        assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
11668        assert!(!grants_broad_write(&login), "{login}");
11669    }
11670
11671    #[test]
11672    fn an_install_reports_the_runner_root_it_prepared() {
11673        let host = Host::new();
11674        let installed = host
11675            .operations()
11676            .install(&host.request(StartMode::Boot))
11677            .expect("an install");
11678        let rendered = installed.runner_root.to_string();
11679        assert!(
11680            !rendered.contains("S-1-5-21"),
11681            "the report must add no identity to the output: {rendered}"
11682        );
11683        if cfg!(windows) {
11684            assert_eq!(
11685                installed.runner_root.path(),
11686                Some(host.runner_root.as_path())
11687            );
11688            assert!(
11689                host.runner_root.as_path().is_dir(),
11690                "the directory jobs would run in has to exist once the service is registered"
11691            );
11692        } else {
11693            assert_eq!(
11694                installed.runner_root,
11695                crate::runner_root_access::RootAccessSummary::NotApplicable,
11696                "macOS and Linux keep the runtime directory they have always used"
11697            );
11698        }
11699    }
11700
11701    #[test]
11702    fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
11703        let host = Host::new();
11704        let operations = host.operations();
11705        operations
11706            .install(&host.request(StartMode::Boot))
11707            .expect("an install at boot");
11708
11709        let change = operations
11710            .set_start_mode(StartMode::Login)
11711            .expect("a switch to login");
11712
11713        assert!(change.changed);
11714        if cfg!(windows) {
11715            assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
11716            assert!(
11717                host.runner_root.as_path().is_dir(),
11718                "the switch must not remove the directory it reconciled"
11719            );
11720        }
11721        // The operator is told, because the account that may write there has
11722        // moved with the mode.
11723        assert!(
11724            change.to_string().contains("runner root"),
11725            "{}",
11726            change.to_string()
11727        );
11728    }
11729
11730    #[test]
11731    fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
11732        let host = Host::new();
11733        let operations = host.operations();
11734        operations
11735            .install(&host.request(StartMode::Boot))
11736            .expect("an install at boot");
11737
11738        let change = operations
11739            .set_start_mode(StartMode::Boot)
11740            .expect("a switch to the mode already in force");
11741
11742        assert!(!change.changed);
11743        assert_eq!(
11744            change.runner_root,
11745            crate::runner_root_access::RootAccessSummary::NotApplicable,
11746            "nothing moves, so nothing about the root's access control has to; reconciling here \
11747             would turn a no-op command into one that can fail on a permission it does not need"
11748        );
11749    }
11750
11751    #[cfg(windows)]
11752    #[test]
11753    fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
11754        let host = Host::new();
11755        host.controls
11756            .fail_next_install(StartMode::Boot, "injected registration failure");
11757
11758        let error = host
11759            .operations()
11760            .install(&host.request(StartMode::Boot))
11761            .expect_err("the manager refuses the registration");
11762
11763        assert!(matches!(error, ServiceError::Control { .. }), "{error}");
11764        assert!(
11765            !host.runner_root.as_path().exists(),
11766            "the directory was created for a registration that does not exist"
11767        );
11768    }
11769
11770    #[cfg(windows)]
11771    #[test]
11772    fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
11773        let host = Host::new();
11774        // What a directory created below `C:\` with inheritance left on looks
11775        // like, built deliberately because a per-account `%TEMP%` never
11776        // produces one by accident.
11777        crate::runner_root_access::create_with_descriptor_for_tests(
11778            host.runner_root.as_path(),
11779            "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
11780        )
11781        .expect("a deliberately open runner root");
11782        let before = crate::runner_root_access::report(host.runner_root.as_path());
11783
11784        let error = host
11785            .operations()
11786            .install(&host.request(StartMode::Boot))
11787            .expect_err("an open runner root is refused");
11788
11789        assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
11790        assert!(
11791            error.to_string().contains("nothing was registered"),
11792            "{error}"
11793        );
11794        assert!(
11795            host.controls.registrations().is_empty(),
11796            "the refusal has to come before the platform is asked to register anything: {:?}",
11797            host.controls.calls()
11798        );
11799        assert_eq!(
11800            crate::runner_root_access::report(host.runner_root.as_path()),
11801            before,
11802            "an open directory is refused rather than tightened: its contents cannot be trusted, \
11803             so adopting it would be worse than declining it"
11804        );
11805    }
11806
11807    #[cfg(windows)]
11808    #[test]
11809    fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
11810        let host = Host::new();
11811        let operations = host.operations();
11812        operations
11813            .install(&host.request(StartMode::Boot))
11814            .expect("an install");
11815        assert!(host.runner_root.as_path().is_dir());
11816
11817        operations.uninstall().expect("an uninstall");
11818
11819        assert!(
11820            host.runner_root.as_path().is_dir(),
11821            "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
11822             runner root may hold an operator's retained workspaces."
11823        );
11824    }
11825
11826    #[test]
11827    fn install_reviews_what_it_registered() {
11828        let host = Host::new();
11829        let installed = host
11830            .operations()
11831            .install(&host.request(StartMode::Boot))
11832            .expect("an install");
11833        assert!(
11834            installed.review.is_least_privilege(),
11835            "{}",
11836            installed.review
11837        );
11838        assert!(
11839            !installed.review.controls().is_empty(),
11840            "a review that confirms nothing proves nothing: {}",
11841            installed.review
11842        );
11843        assert_eq!(
11844            installed.review.kind(),
11845            host_definition_kind(StartMode::Boot),
11846            "the review must be of the definition this host's manager was given"
11847        );
11848        assert!(
11849            !installed.review.account().justification().is_empty(),
11850            "a privileged account with no stated reason is an unreviewed one"
11851        );
11852    }
11853
11854    // -----------------------------------------------------------------------
11855    // Uninstall deletes nothing else
11856    // -----------------------------------------------------------------------
11857
11858    #[test]
11859    fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
11860        let host = Host::new();
11861        let operations = host.operations();
11862        operations
11863            .install(&host.request(StartMode::Boot))
11864            .expect("an install");
11865
11866        // The files `05-infrastructure.md` says must survive. Written *after*
11867        // the install so that the snapshot below is of a host in the state an
11868        // operator's would be in.
11869        let config = host.paths.config_dir();
11870        std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
11871        std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
11872        std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
11873        std::fs::write(
11874            host.paths
11875                .state_dir()
11876                .join("packages/2.330.0/runner.tar.gz"),
11877            b"cached package",
11878        )
11879        .expect("writable");
11880        std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
11881        std::fs::write(
11882            host.paths.state_dir().join("secrets/user-access-token"),
11883            b"a stand-in for the stored credential",
11884        )
11885        .expect("writable");
11886        std::fs::write(
11887            host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
11888            b"diagnostics",
11889        )
11890        .expect("writable");
11891
11892        let roots: Vec<PathBuf> = host
11893            .paths
11894            .all()
11895            .iter()
11896            .map(|(_, path)| (*path).to_path_buf())
11897            .collect();
11898        let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
11899        let before = snapshot(&roots);
11900
11901        // Non-vacuity: a comparison of two empty maps would pass whatever
11902        // `uninstall` did.
11903        assert!(
11904            before.len() >= 6,
11905            "the fixture must actually contain the files this test is about, got {before:#?}"
11906        );
11907        let record_path = InstallRecord::path(&host.paths);
11908        assert!(
11909            before.contains_key(&record_path),
11910            "the install record must be present before uninstall"
11911        );
11912
11913        let uninstalled = operations.uninstall().expect("an uninstall");
11914        assert!(uninstalled.removed_registration);
11915        assert!(uninstalled.removed_record);
11916
11917        let after = snapshot(&roots);
11918
11919        // Exactly one thing changed, and it is the registration's own record.
11920        let mut expected = before.clone();
11921        expected.remove(&record_path);
11922        assert_eq!(
11923            after, expected,
11924            "uninstall must remove its own record and nothing else"
11925        );
11926        assert!(
11927            !record_path.exists(),
11928            "the record itself must go, or `uninstall` did nothing at all"
11929        );
11930        assert!(
11931            uninstalled
11932                .preserved
11933                .iter()
11934                .all(|path| roots.contains(&path.as_path())),
11935            "the preserved list must name the four directories: {uninstalled}"
11936        );
11937    }
11938
11939    #[test]
11940    fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
11941        let host = Host::new();
11942        let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
11943        assert!(!uninstalled.removed_registration);
11944        assert!(!uninstalled.removed_record);
11945    }
11946
11947    #[test]
11948    fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
11949        let host = Host::new();
11950        let operations = host.operations();
11951        operations
11952            .install(&host.request(StartMode::Boot))
11953            .expect("an install");
11954        std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
11955
11956        let uninstalled = operations.uninstall().expect("an uninstall");
11957        assert!(
11958            uninstalled.removed_registration,
11959            "a lost record must not strand a registration"
11960        );
11961        assert!(host.controls.registrations().is_empty());
11962    }
11963
11964    // -----------------------------------------------------------------------
11965    // Switching start mode
11966    // -----------------------------------------------------------------------
11967
11968    #[test]
11969    fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
11970        let host = Host::new();
11971        let operations = host.operations();
11972        operations
11973            .install(&host.request(StartMode::Boot))
11974            .expect("an install at boot");
11975
11976        // The discriminator. If the switch re-resolved the binary the way
11977        // `install` does, it would either fail here or silently record the test
11978        // binary instead. Removing the file makes the difference visible.
11979        std::fs::remove_file(&host.binary).expect("the installed binary goes away");
11980
11981        let change = operations
11982            .set_start_mode(StartMode::Login)
11983            .expect("a switch that does not reinstall the product");
11984        assert!(change.changed);
11985        assert_eq!(change.from, StartMode::Boot);
11986        assert_eq!(change.to, StartMode::Login);
11987        assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
11988
11989        let record = InstallRecord::read(&host.paths)
11990            .expect("readable")
11991            .expect("a record");
11992        assert_eq!(record.start_mode, StartMode::Login);
11993        assert_eq!(
11994            record.binary, host.binary,
11995            "the recorded path must survive the switch untouched"
11996        );
11997
11998        let registrations = host.controls.registrations();
11999        assert_eq!(registrations.len(), 1, "{registrations:?}");
12000        assert_eq!(registrations[0].0, StartMode::Login);
12001        assert!(
12002            registrations[0]
12003                .2
12004                .command_line
12005                .contains(&host.binary.to_string_lossy().into_owned()),
12006            "{:?}",
12007            registrations[0].2
12008        );
12009    }
12010
12011    #[test]
12012    fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
12013        let host = Host::new();
12014        let operations = host.operations();
12015        operations
12016            .install(&host.request(StartMode::Boot))
12017            .expect("an install at boot");
12018        let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
12019        host.controls
12020            .fail_next_install(StartMode::Login, "injected target failure");
12021
12022        let error = operations
12023            .set_start_mode(StartMode::Login)
12024            .expect_err("the target manager refuses the install");
12025
12026        assert!(matches!(error, ServiceError::Control { .. }), "{error}");
12027        assert_eq!(
12028            std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
12029            record_before
12030        );
12031        let registrations = host.controls.registrations();
12032        assert_eq!(registrations.len(), 1, "{registrations:?}");
12033        assert_eq!(registrations[0].0, StartMode::Boot);
12034    }
12035
12036    #[test]
12037    fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
12038        let host = Host::new();
12039        let operations = host.operations();
12040        operations
12041            .install(&host.request(StartMode::Boot))
12042            .expect("an install at boot");
12043        let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
12044        let config = host.paths.config_dir().to_path_buf();
12045        let hidden = config.with_file_name("config-hidden-by-fault");
12046        host.controls.hide_directory_after_install(
12047            StartMode::Login,
12048            config.clone(),
12049            hidden.clone(),
12050        );
12051
12052        let error = operations
12053            .set_start_mode(StartMode::Login)
12054            .expect_err("the injected filesystem fault prevents persistence");
12055
12056        std::fs::remove_file(&config).expect("remove the injected blocker");
12057        std::fs::rename(&hidden, &config).expect("restore the record directory");
12058        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
12059        assert_eq!(
12060            std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
12061            record_before
12062        );
12063        let registrations = host.controls.registrations();
12064        assert_eq!(registrations.len(), 1, "{registrations:?}");
12065        assert_eq!(registrations[0].0, StartMode::Boot);
12066        assert!(
12067            host.controls
12068                .calls()
12069                .iter()
12070                .any(|call| call == "uninstall runner-manager (login)"),
12071            "the target must be rolled back: {:?}",
12072            host.controls.calls()
12073        );
12074    }
12075
12076    #[test]
12077    fn switching_to_the_mode_already_in_force_registers_nothing_again() {
12078        let host = Host::new();
12079        let operations = host.operations();
12080        operations
12081            .install(&host.request(StartMode::Boot))
12082            .expect("an install");
12083        let before = host.controls.calls().len();
12084
12085        let change = operations
12086            .set_start_mode(StartMode::Boot)
12087            .expect("a no-op switch");
12088        assert!(!change.changed);
12089        assert_eq!(
12090            host.controls.calls().len(),
12091            before,
12092            "a no-op switch must not touch the service manager"
12093        );
12094    }
12095
12096    #[test]
12097    fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
12098        let host = Host::new();
12099        let error = host
12100            .operations()
12101            .set_start_mode(StartMode::Login)
12102            .expect_err("there is nothing to switch");
12103        assert!(
12104            matches!(error, ServiceError::NotInstalled { .. }),
12105            "{error}"
12106        );
12107    }
12108
12109    // -----------------------------------------------------------------------
12110    // Status
12111    // -----------------------------------------------------------------------
12112
12113    #[test]
12114    fn status_reports_the_four_facts_journey_five_asks_for() {
12115        let host = Host::new();
12116        let operations = host.operations();
12117        operations
12118            .install(&host.request(StartMode::Boot))
12119            .expect("an install");
12120        let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
12121            .expect("a valid timestamp")
12122            .with_timezone(&Utc);
12123        record_github_contact(&host.paths, at).expect("a heartbeat");
12124
12125        let status = operations.status().expect("a status");
12126        assert_eq!(status.start_mode(), Some(StartMode::Boot));
12127        assert_eq!(
12128            status.binary().map(BinaryPath::recorded),
12129            Some(host.binary.as_path())
12130        );
12131        assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
12132        assert_eq!(status.last_github_contact(), Some(at));
12133        assert!(status.is_installed());
12134        assert!(status.is_healthy(), "{status}");
12135
12136        let printed = status.to_string();
12137        for fragment in [
12138            "start mode",
12139            "diagnostic log",
12140            "last GitHub contact",
12141            "binary",
12142        ] {
12143            assert!(printed.contains(fragment), "{printed}");
12144        }
12145    }
12146
12147    /// A record this account may not read is reported, and `status` keeps
12148    /// going.
12149    ///
12150    /// It used to end the command with `Permission denied`, which is exactly
12151    /// what an operator met on a boot-mode host: `sudo service install` wrote
12152    /// the record `0600` and `root`-owned, and `service status` — a command
12153    /// that asks no privilege of anybody — then printed nothing at all about a
12154    /// registration the service manager could describe perfectly well.
12155    ///
12156    /// Ownership cannot be reproduced without privileges; the mode that made it
12157    /// fatal can.
12158    #[cfg(unix)]
12159    #[test]
12160    fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
12161        use std::os::unix::fs::PermissionsExt as _;
12162
12163        // `root` is not subject to the mode bits and would read the record
12164        // regardless, leaving the assertions below testing nothing.
12165        // SAFETY: `geteuid` takes no argument and cannot fail.
12166        if unsafe { libc::geteuid() } == 0 {
12167            return;
12168        }
12169
12170        let host = Host::new();
12171        let operations = host.operations();
12172        operations
12173            .install(&host.request(StartMode::Boot))
12174            .expect("an install");
12175        assert!(
12176            operations.status().expect("a status").is_healthy(),
12177            "the discriminator: healthy before the record is made unreadable"
12178        );
12179
12180        let record = InstallRecord::path(&host.paths);
12181        std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
12182            .expect("the mode is applied");
12183
12184        let status = operations
12185            .status()
12186            .expect("a record this account may not read is reported, not thrown");
12187        assert!(status.is_installed(), "{status}");
12188        assert!(!status.is_healthy(), "{status}");
12189
12190        let printed = status.to_string();
12191        assert!(
12192            printed.contains("this account may not read it"),
12193            "the operator is told which of the two states this is: {printed}"
12194        );
12195        assert!(
12196            !printed.contains("there is no install record"),
12197            "a record that is there and unreadable is not a record that is missing, and the \
12198             missing one's remedy starts with `service uninstall`: {printed}"
12199        );
12200
12201        // Left readable so the fixture's own cleanup is not the thing that
12202        // fails on a host where TempDir removal walks the tree.
12203        std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
12204            .expect("the mode is restored");
12205    }
12206
12207    #[test]
12208    fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
12209        let host = Host::new();
12210        let operations = host.operations();
12211        operations
12212            .install(&host.request(StartMode::Boot))
12213            .expect("an install");
12214
12215        // The discriminator: healthy first, so a `is_healthy` that always
12216        // returned false could not pass this test.
12217        assert!(
12218            operations.status().expect("a status").is_healthy(),
12219            "the freshly installed host must be healthy"
12220        );
12221
12222        std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
12223
12224        let status = operations.status().expect("a status");
12225        assert!(!status.is_healthy(), "{status}");
12226        assert!(
12227            status
12228                .problems()
12229                .iter()
12230                .any(|problem| problem.subject == "binary"),
12231            "{status}"
12232        );
12233        assert!(status.to_string().contains("STALE"), "{status}");
12234    }
12235
12236    #[test]
12237    fn status_reports_a_registration_that_would_not_start_at_boot() {
12238        let host = Host::new();
12239        let operations = host.operations();
12240        operations
12241            .install(&host.request(StartMode::Boot))
12242            .expect("an install");
12243        assert!(operations.status().expect("a status").is_healthy());
12244
12245        host.controls.edit("runner-manager", |registration| {
12246            registration.starts_automatically = false;
12247        });
12248
12249        let status = operations.status().expect("a status");
12250        assert!(!status.is_healthy(), "{status}");
12251        assert!(
12252            status
12253                .problems()
12254                .iter()
12255                .any(|problem| problem.detail.contains("after a reboot")),
12256            "{status}"
12257        );
12258    }
12259
12260    #[test]
12261    fn install_starts_the_registration_and_a_later_stop_is_unhealthy() {
12262        let host = Host::new();
12263        let operations = host.operations();
12264        operations
12265            .install(&host.request(StartMode::Login))
12266            .expect("install and immediate start");
12267
12268        assert!(
12269            operations.status().expect("running status").is_running(),
12270            "install must not wait for the next login trigger"
12271        );
12272        operations.stop().expect("stop the registration");
12273        let status = operations.status().expect("stopped status");
12274        assert!(!status.is_healthy(), "{status}");
12275        assert!(
12276            status
12277                .problems()
12278                .iter()
12279                .any(|problem| problem.subject == "runtime"),
12280            "{status}"
12281        );
12282        let rendered = status.to_string();
12283        assert!(
12284            rendered.contains("runner-manager service install --start-at login"),
12285            "{rendered}"
12286        );
12287        assert!(
12288            !rendered.contains("runner-manager service start"),
12289            "{rendered}"
12290        );
12291    }
12292
12293    #[test]
12294    fn status_reports_a_restart_policy_something_else_edited() {
12295        let host = Host::new();
12296        let operations = host.operations();
12297        operations
12298            .install(&host.request(StartMode::Boot))
12299            .expect("an install");
12300        assert!(operations.status().expect("a status").is_healthy());
12301
12302        host.controls.edit("runner-manager", |registration| {
12303            registration.restart_delay = Some(Duration::from_secs(1));
12304        });
12305
12306        let status = operations.status().expect("a status");
12307        assert!(!status.is_healthy(), "{status}");
12308        assert!(
12309            status
12310                .problems()
12311                .iter()
12312                .any(|problem| problem.subject == "restart policy"),
12313            "{status}"
12314        );
12315    }
12316
12317    #[test]
12318    fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
12319        let host = Host::new();
12320        let operations = host.operations();
12321        operations
12322            .install(&host.request(StartMode::Boot))
12323            .expect("an install");
12324        let other = host.binary.with_file_name("someone-elses.exe");
12325        std::fs::write(&other, b"x").expect("writable");
12326
12327        host.controls.edit("runner-manager", |registration| {
12328            registration.command_line = quote_argument(&other.to_string_lossy());
12329        });
12330
12331        let status = operations.status().expect("a status");
12332        assert!(!status.is_healthy(), "{status}");
12333        assert!(
12334            matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
12335            "{status}"
12336        );
12337    }
12338
12339    #[test]
12340    fn status_reports_a_record_no_service_manager_knows_about() {
12341        let host = Host::new();
12342        let operations = host.operations();
12343        operations
12344            .install(&host.request(StartMode::Boot))
12345            .expect("an install");
12346        // Something removed the registration behind this product's back.
12347        for mode in [StartMode::Boot, StartMode::Login] {
12348            host.controls
12349                .control(mode)
12350                .expect("a control")
12351                .uninstall(&ServiceIdentity::product())
12352                .expect("removed");
12353        }
12354
12355        let status = operations.status().expect("a status");
12356        assert!(!status.is_healthy(), "{status}");
12357        assert!(
12358            status
12359                .problems()
12360                .iter()
12361                .any(|problem| problem.subject == "registration"),
12362            "{status}"
12363        );
12364    }
12365
12366    #[test]
12367    fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
12368        let host = Host::new();
12369        let status = host.operations().status().expect("a status");
12370        assert!(!status.is_installed());
12371        assert!(
12372            status.is_healthy(),
12373            "a host that never installed the service has no fault to report: {status}"
12374        );
12375        assert!(status.to_string().contains("installed"), "{status}");
12376    }
12377
12378    #[test]
12379    fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
12380        let host = Host::new();
12381        let operations = host.operations();
12382        operations
12383            .install(&host.request(StartMode::Login))
12384            .expect("an install at login");
12385        let status = operations.status().expect("a status");
12386        assert!(
12387            status
12388                .notes()
12389                .iter()
12390                .any(|note| note.contains("does not run until the operator signs in")),
12391            "05-infrastructure.md requires `service status` to say so: {status}"
12392        );
12393    }
12394
12395    /// A Mac that signs its runner account in automatically does resume, and
12396    /// `service status` used to tell it otherwise.
12397    #[test]
12398    fn a_mac_with_automatic_login_as_the_service_account_resumes_after_a_reboot() {
12399        use crate::unattended_login::{AutoLogin, UnattendedLogin};
12400        let automatic = UnattendedLogin {
12401            auto_login: AutoLogin::As("ivan".into()),
12402            filevault_on: Some(false),
12403        };
12404        assert_eq!(
12405            unattended_gap(StartMode::Login, "ivan", Some(&automatic)),
12406            None
12407        );
12408
12409        let off = UnattendedLogin {
12410            auto_login: AutoLogin::Off,
12411            filevault_on: Some(false),
12412        };
12413        let gap = unattended_gap(StartMode::Login, "ivan", Some(&off)).expect("a gap");
12414        assert!(gap.reason.contains("automatic login is off"), "{gap:?}");
12415        assert!(gap.reason.contains("Automatically log in as"), "{gap:?}");
12416
12417        let locked = UnattendedLogin {
12418            auto_login: AutoLogin::As("ivan".into()),
12419            filevault_on: Some(true),
12420        };
12421        let gap = unattended_gap(StartMode::Boot, "root", Some(&locked)).expect("a gap");
12422        assert!(gap.reason.contains("FileVault is on"), "{gap:?}");
12423
12424        // An unanswered probe is no gap: `host doctor` says what it could not
12425        // read.
12426        let unread = UnattendedLogin {
12427            auto_login: AutoLogin::As("ivan".into()),
12428            filevault_on: None,
12429        };
12430        assert_eq!(
12431            unattended_gap(StartMode::Login, "ivan", Some(&unread)),
12432            None
12433        );
12434
12435        // Where nothing can be probed, a login registration still says so.
12436        let gap = unattended_gap(StartMode::Login, "ivan", None).expect("a gap");
12437        assert!(
12438            gap.reason
12439                .contains("does not run until the operator signs in")
12440        );
12441        assert_eq!(unattended_gap(StartMode::Boot, "root", None), None);
12442    }
12443
12444    /// A daemon blocked in a file system call is still "running" to the
12445    /// service manager; its heartbeat is what says it is stuck.
12446    #[test]
12447    fn a_daemon_that_stopped_beating_makes_the_service_unhealthy() {
12448        let host = Host::new();
12449        let operations = host.operations();
12450        operations
12451            .install(&host.request(StartMode::Login))
12452            .expect("an install at login");
12453        let stalled_problem = |operations: &ServiceOperations| {
12454            operations
12455                .status()
12456                .expect("a status")
12457                .problems()
12458                .iter()
12459                .find(|problem| problem.subject == DAEMON_STALLED_SUBJECT)
12460                .map(|problem| problem.detail.clone())
12461        };
12462        crate::daemon_heartbeat::beat(&operations.paths, Utc::now()).unwrap();
12463        assert_eq!(stalled_problem(&operations), None, "beating");
12464
12465        crate::daemon_heartbeat::beat(
12466            &operations.paths,
12467            Utc::now() - chrono::Duration::minutes(20),
12468        )
12469        .unwrap();
12470        let detail = stalled_problem(&operations).expect("a stall is a problem");
12471        assert!(detail.contains("has made no progress since"), "{detail}");
12472        // The restart is the service manager's command for this login-mode
12473        // registration: the product has no `service stop` or `service start`.
12474        let restart = ServiceIdentity::product().restart_command(StartMode::Login);
12475        assert!(detail.contains(&restart), "{detail}");
12476        assert!(!detail.contains("runner-manager service stop"), "{detail}");
12477        assert!(!detail.contains("runner-manager service start"), "{detail}");
12478    }
12479
12480    /// Each platform's restart is its service manager's own command for the
12481    /// registration's start mode, never a `runner-manager service` subcommand
12482    /// that does not exist.
12483    #[test]
12484    fn the_restart_command_is_the_service_managers_own() {
12485        let identity = ServiceIdentity::product();
12486        let boot = identity.restart_command(StartMode::Boot);
12487        let login = identity.restart_command(StartMode::Login);
12488        for command in [&boot, &login] {
12489            assert!(!command.contains("runner-manager service"), "{command}");
12490            assert!(command.contains(identity.name()), "{command}");
12491        }
12492        assert_ne!(boot, login);
12493        if cfg!(windows) {
12494            assert!(
12495                boot.starts_with("Restart-Service -Name runner-manager"),
12496                "{boot}"
12497            );
12498            assert!(
12499                login.contains("schtasks /Run /TN runner-manager"),
12500                "{login}"
12501            );
12502        } else if cfg!(target_os = "macos") {
12503            assert!(
12504                boot.starts_with("sudo launchctl kickstart -k system/"),
12505                "{boot}"
12506            );
12507            assert!(login.starts_with("launchctl kickstart -k gui/"), "{login}");
12508        } else {
12509            assert_eq!(boot, "sudo systemctl restart runner-manager.service");
12510            assert_eq!(login, "systemctl --user restart runner-manager.service");
12511        }
12512    }
12513
12514    /// A daemon refusing every runner is not a healthy service.
12515    #[test]
12516    fn a_daemon_that_finds_the_host_unfit_makes_the_service_unhealthy() {
12517        let host = Host::new();
12518        let operations = host.operations();
12519        operations
12520            .install(&host.request(StartMode::Login))
12521            .expect("an install at login");
12522        let paths = operations.paths.clone();
12523        crate::host_fitness::record_host_unfit(
12524            &paths,
12525            &[crate::host_fitness::UnfitFinding {
12526                id: "host.runner_root_responsive".into(),
12527                detail: "macOS has not let the service list /Volumes/NVME/runners".into(),
12528                remedy: Some("click Allow".into()),
12529            }],
12530            Utc::now(),
12531        )
12532        .unwrap();
12533        let status = operations.status().expect("a status");
12534        assert!(!status.is_healthy(), "{status}");
12535        let problem = status
12536            .problems()
12537            .iter()
12538            .find(|problem| problem.subject == HOST_UNFIT_SUBJECT)
12539            .expect("the refusal is a problem");
12540        assert!(
12541            problem.detail.contains("/Volumes/NVME/runners"),
12542            "{problem:?}"
12543        );
12544        assert!(problem.detail.contains("Fix: click Allow"), "{problem:?}");
12545
12546        // A record nobody has re-stamped for a while is a stopped daemon's,
12547        // not a refusal in force.
12548        crate::host_fitness::clear_host_unfit(&paths).unwrap();
12549        crate::host_fitness::record_host_unfit(
12550            &paths,
12551            &[],
12552            Utc::now() - chrono::Duration::hours(2),
12553        )
12554        .unwrap();
12555        let status = operations.status().expect("a status");
12556        assert!(
12557            !status
12558                .problems()
12559                .iter()
12560                .any(|problem| problem.subject == HOST_UNFIT_SUBJECT),
12561            "{status}"
12562        );
12563    }
12564
12565    // -----------------------------------------------------------------------
12566    // Start and stop
12567    // -----------------------------------------------------------------------
12568
12569    #[test]
12570    fn start_and_stop_reach_the_domain_that_holds_the_registration() {
12571        let host = Host::new();
12572        let operations = host.operations();
12573        operations
12574            .install(&host.request(StartMode::Login))
12575            .expect("an install at login");
12576        operations.start().expect("a start");
12577        assert!(operations.status().expect("a status").is_running());
12578        assert!(operations.stop().expect("a stop"));
12579        assert!(!operations.status().expect("a status").is_running());
12580    }
12581
12582    #[test]
12583    fn starting_a_host_with_no_registration_is_refused() {
12584        let host = Host::new();
12585        let error = host.operations().start().expect_err("nothing to start");
12586        assert!(
12587            matches!(error, ServiceError::NotInstalled { .. }),
12588            "{error}"
12589        );
12590    }
12591
12592    // -----------------------------------------------------------------------
12593    // The seam with `d2`
12594    // -----------------------------------------------------------------------
12595
12596    /// Requirement 2 is *"an account that can **read the secret store**"*, and
12597    /// on Windows what an account may read is decided by a DACL in a file this
12598    /// task does not own. Asserting the account name alone would be asserting
12599    /// this module against itself; this reads `d2`'s protection back off a real
12600    /// store and checks that it admits the account this installer registers.
12601    ///
12602    /// No privileges are needed. `d2` documents that a rooted machine-scoped
12603    /// store *"is protected and encrypted exactly as the standard one is"* —
12604    /// the Windows backend picks its DACL from the scope, not from the site —
12605    /// so a store in a temporary directory carries the access control the real
12606    /// one does.
12607    #[cfg(windows)]
12608    #[test]
12609    fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
12610        use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
12611
12612        let root = tempfile::tempdir().expect("a temporary directory");
12613        let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
12614            .expect("a rooted machine-scoped store");
12615        store
12616            .store(&secrecy::SecretString::from("a stand-in for the token"))
12617            .expect("the store accepts a value");
12618        let protection = store.protection().expect("the DACL can be read back");
12619
12620        assert!(
12621            protection.description().contains(";;;SY)"),
12622            "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
12623             read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
12624            protection.description()
12625        );
12626        assert_eq!(
12627            ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
12628            ServiceAccount::LocalSystem,
12629            "and that is the account this installer registers, which is why SY is what matters"
12630        );
12631        assert!(
12632            !protection.readable_by_other_local_users(),
12633            "the same DACL must still exclude ordinary local users: {}",
12634            protection.description()
12635        );
12636
12637        // The other half of the analysis in `docs/service-account.md`, and the
12638        // reason LocalService and NetworkService are not used: the DACL names
12639        // three trustees and neither of them is among them.
12640        for rejected in [";;;LS)", ";;;NS)"] {
12641            assert!(
12642                !protection.description().contains(rejected),
12643                "if the store ever admitted {rejected}, the least-privilege analysis in \
12644                 docs/service-account.md would need redoing: {}",
12645                protection.description()
12646            );
12647        }
12648    }
12649
12650    // -- another installation's service ---------------------------------------
12651
12652    /// A request that records `host`'s own four directories on the command
12653    /// line, the way `service install` registers one.
12654    fn request_with_directories(host: &Host, mode: StartMode) -> InstallRequest {
12655        let mut arguments: Vec<OsString> = DAEMON_ARGUMENTS.iter().map(OsString::from).collect();
12656        arguments.extend(ServiceDirectories::of(&host.paths).arguments());
12657        host.request(mode).with_arguments(arguments)
12658    }
12659
12660    /// The operations a second account on the same machine gets: its own
12661    /// directories, and the machine's one service manager.
12662    fn second_account(account: &Host, machine: &Host) -> ServiceOperations {
12663        ServiceOperations::with_controls(
12664            account.paths.clone(),
12665            ServiceIdentity::product(),
12666            std::sync::Arc::new(machine.controls.clone()),
12667        )
12668        .with_runner_root(account.runner_root.clone())
12669    }
12670
12671    /// The owner's boot service, and a second account holding the record of
12672    /// an earlier login install of its own -- the layout an owner reported on
12673    /// a Windows host with two local accounts.
12674    fn a_machine_with_a_stale_second_account() -> (Host, Host) {
12675        let owner = Host::new();
12676        owner
12677            .operations()
12678            .install(&request_with_directories(&owner, StartMode::Boot))
12679            .expect("the owner's boot install");
12680        let earlier = Host::new();
12681        earlier
12682            .operations()
12683            .install(&request_with_directories(&earlier, StartMode::Login))
12684            .expect("the second account's earlier login install");
12685        assert!(
12686            InstallRecord::read(&earlier.paths).unwrap().is_some(),
12687            "the earlier install left its record behind"
12688        );
12689        (owner, earlier)
12690    }
12691
12692    /// The advice that, followed from the second account, breaks or replaces
12693    /// the owner's healthy service.
12694    const DESTRUCTIVE_ADVICE: [&str; 4] = [
12695        "service uninstall",
12696        "service install`",
12697        "service install --",
12698        "auth login",
12699    ];
12700
12701    #[test]
12702    fn a_service_installed_from_another_data_root_is_reported_as_that_installations() {
12703        let (owner, earlier) = a_machine_with_a_stale_second_account();
12704
12705        let status = second_account(&earlier, &owner).status().expect("a status");
12706
12707        let foreign = status.foreign().expect("the service is the owner's");
12708        assert_eq!(foreign.directories, ServiceDirectories::of(&owner.paths));
12709        assert_eq!(foreign.root, ServiceDirectories::of(&owner.paths).root());
12710        assert!(foreign.unreadable.is_none(), "{status}");
12711        assert!(
12712            status.is_healthy(),
12713            "the owner's service is healthy, and a stale record elsewhere is not its fault: \
12714             {status}"
12715        );
12716        for subject in [
12717            "binary",
12718            "start mode",
12719            "secret store",
12720            "record",
12721            "registration",
12722        ] {
12723            assert!(
12724                !status
12725                    .problems()
12726                    .iter()
12727                    .any(|problem| problem.subject == subject),
12728                "{subject} compared the second account's record against the owner's \
12729                 registration: {status}"
12730            );
12731        }
12732        let rendered = status.to_string();
12733        for advice in DESTRUCTIVE_ADVICE {
12734            assert!(!rendered.contains(advice), "{advice}: {rendered}");
12735        }
12736        assert!(rendered.contains("another installation's"), "{rendered}");
12737        assert!(
12738            status
12739                .notes()
12740                .iter()
12741                .any(|note| note.contains("unused local configuration")
12742                    && note.contains("host forget-local")),
12743            "{status}"
12744        );
12745        // What the owner recorded, not what the second account did.
12746        assert_eq!(status.start_mode(), Some(StartMode::Boot));
12747    }
12748
12749    #[test]
12750    fn a_foreign_service_whose_records_cannot_be_read_reports_only_what_the_manager_says() {
12751        let (owner, earlier) = a_machine_with_a_stale_second_account();
12752        // Stands in for a profile this account may not open: there is nothing
12753        // to read where the registration points.
12754        InstallRecord::remove(&owner.paths).expect("the owner's record removed");
12755
12756        let operations = second_account(&earlier, &owner);
12757        let status = operations.status().expect("a status");
12758        let foreign = status.foreign().expect("the service is the owner's");
12759        assert!(foreign.unreadable.is_some(), "{status}");
12760        assert!(
12761            status.is_healthy(),
12762            "running, and nothing it could judge: {status}"
12763        );
12764        let rendered = status.to_string();
12765        for advice in DESTRUCTIVE_ADVICE {
12766            assert!(!rendered.contains(advice), "{advice}: {rendered}");
12767        }
12768        assert!(rendered.contains("running; installed from"), "{rendered}");
12769        assert!(!rendered.contains("last GitHub contact"), "{rendered}");
12770
12771        // Stopped, it is a problem -- with a place to look, not a reinstall.
12772        owner.operations().stop().expect("stopped");
12773        let stopped = operations.status().expect("a status");
12774        assert!(!stopped.is_healthy(), "{stopped}");
12775        let rendered = stopped.to_string();
12776        for advice in DESTRUCTIVE_ADVICE {
12777            assert!(!rendered.contains(advice), "{advice}: {rendered}");
12778        }
12779    }
12780
12781    #[test]
12782    fn this_accounts_own_registration_still_reports_every_mismatch() {
12783        let host = Host::new();
12784        let operations = host.operations();
12785        operations
12786            .install(&request_with_directories(&host, StartMode::Boot))
12787            .expect("an install");
12788        let other = host.binary.with_file_name("someone-elses");
12789        std::fs::write(&other, b"x").expect("writable");
12790        // Something edited the registration, keeping its directories.
12791        host.controls.edit("runner-manager", |registration| {
12792            let mut arguments = split_command_line(&registration.command_line);
12793            arguments[0] = other.to_string_lossy().into_owned();
12794            registration.command_line = arguments
12795                .iter()
12796                .map(|argument| quote_argument(argument))
12797                .collect::<Vec<_>>()
12798                .join(" ");
12799            registration.start_mode = StartMode::Login;
12800        });
12801
12802        let status = operations.status().expect("a status");
12803        assert!(status.foreign().is_none(), "{status}");
12804        assert!(
12805            matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
12806            "{status}"
12807        );
12808        assert!(
12809            status
12810                .problems()
12811                .iter()
12812                .any(|problem| problem.subject == "start mode"),
12813            "{status}"
12814        );
12815        assert!(status.to_string().contains("service uninstall"), "{status}");
12816    }
12817
12818    #[test]
12819    fn directories_read_back_from_a_command_line_with_spaces() {
12820        let root = Path::new("/Users/someone/Library/Application Support/runner manager");
12821        let directories = ServiceDirectories {
12822            config: root.join("config"),
12823            state: root.join("data").join("state"),
12824            runtime: root.join("data").join("runtime"),
12825            logs: root.join("data").join("logs"),
12826        };
12827        let mut command_line = quote_argument("/usr/local/bin/runner-manager");
12828        for argument in ["daemon", "run"]
12829            .map(OsString::from)
12830            .into_iter()
12831            .chain(directories.arguments())
12832        {
12833            command_line.push(' ');
12834            command_line.push_str(&quote_argument(&argument.to_string_lossy()));
12835        }
12836        assert_eq!(
12837            ServiceDirectories::from_command_line(&command_line),
12838            Some(directories.clone())
12839        );
12840        assert_eq!(directories.root(), root);
12841        assert_eq!(
12842            ServiceDirectories::from_command_line("runner-manager daemon run"),
12843            None,
12844            "a registration that names no directories cannot be judged foreign"
12845        );
12846    }
12847
12848    #[test]
12849    fn the_owner_of_a_data_root_is_read_from_its_path() {
12850        let users = Path::new(if cfg!(windows) { r"C:\Users" } else { "/home" });
12851        let home = users.join("ivand");
12852        assert_eq!(
12853            data_root_owner(&users.join("yuriv").join("data"), Some(&home)).as_deref(),
12854            Some("yuriv")
12855        );
12856        assert_eq!(
12857            data_root_owner(
12858                Path::new(if cfg!(windows) { r"D:\rm" } else { "/opt/rm" }),
12859                Some(&home)
12860            ),
12861            None
12862        );
12863        if cfg!(windows) {
12864            assert_eq!(
12865                data_root_owner(
12866                    Path::new(r"C:\Windows\System32\config\systemprofile\AppData"),
12867                    Some(&home)
12868                )
12869                .as_deref(),
12870                Some("LocalSystem")
12871            );
12872        } else {
12873            assert_eq!(
12874                data_root_owner(Path::new("/root/.config/runner-manager"), Some(&home)).as_deref(),
12875                Some("root")
12876            );
12877        }
12878    }
12879
12880    #[test]
12881    fn forget_local_archives_the_unused_record_and_never_touches_the_service_root() {
12882        let (owner, earlier) = a_machine_with_a_stale_second_account();
12883        let owner_before = snapshot(&[owner._root.path()]);
12884        let config = earlier.paths.config_dir().to_path_buf();
12885        let record = std::fs::read(InstallRecord::path(&earlier.paths)).unwrap();
12886
12887        let forgotten = second_account(&earlier, &owner)
12888            .forget_local(Utc::now())
12889            .expect("archived");
12890
12891        assert_eq!(
12892            snapshot(&[owner._root.path()]),
12893            owner_before,
12894            "the owner's directories must not change by one byte"
12895        );
12896        let archive = forgotten.archive.expect("something was archived");
12897        assert!(archive.starts_with(&config), "{}", archive.display());
12898        assert_eq!(
12899            std::fs::read(archive.join(RECORD_FILE)).unwrap(),
12900            record,
12901            "moved, not rewritten or deleted"
12902        );
12903        assert!(InstallRecord::read(&earlier.paths).unwrap().is_none());
12904        assert_eq!(
12905            forgotten.service_root,
12906            Some(ServiceDirectories::of(&owner.paths).root())
12907        );
12908        // Afterwards the second account's status has no unused record to note.
12909        let status = second_account(&earlier, &owner).status().expect("status");
12910        assert!(
12911            !status
12912                .notes()
12913                .iter()
12914                .any(|note| note.contains("unused local configuration")),
12915            "{status}"
12916        );
12917    }
12918
12919    #[test]
12920    fn forget_local_refuses_a_configuration_a_service_runs_against() {
12921        let (owner, earlier) = a_machine_with_a_stale_second_account();
12922        let before = snapshot(&[owner._root.path()]);
12923        let refused = owner.operations().forget_local(Utc::now());
12924        assert!(
12925            matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
12926            "{refused:?}"
12927        );
12928        assert_eq!(snapshot(&[owner._root.path()]), before);
12929
12930        // Nor while an agent holds the second account's own lock.
12931        let _agent = HostLock::try_acquire(&earlier.paths, LockKind::SingleInstance)
12932            .expect("the agent's lock");
12933        let refused = second_account(&earlier, &owner).forget_local(Utc::now());
12934        assert!(
12935            matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
12936            "{refused:?}"
12937        );
12938        assert!(InstallRecord::read(&earlier.paths).unwrap().is_some());
12939    }
12940
12941    #[test]
12942    fn a_readable_foreign_service_keeps_its_diagnoses_and_loses_its_remedies() {
12943        let (owner, earlier) = a_machine_with_a_stale_second_account();
12944        // The owner's service has every problem whose remedy acts on the
12945        // directories of whoever runs it.
12946        owner.operations().stop().expect("stopped");
12947        record_github_credential_rejected(&owner.paths, Utc::now()).expect("rejected");
12948        record_credential_unreadable(&owner.paths, Utc::now()).expect("unreadable");
12949
12950        let status = second_account(&earlier, &owner).status().expect("a status");
12951        assert!(status.foreign().is_some_and(|foreign| !foreign.is_unread()));
12952        let subjects: Vec<_> = status.problems().iter().map(|p| p.subject).collect();
12953        for subject in [
12954            "runtime",
12955            "github credential",
12956            CREDENTIAL_UNREADABLE_SUBJECT,
12957        ] {
12958            assert!(subjects.contains(&subject), "{subject}: {status}");
12959        }
12960        let rendered = status.to_string();
12961        for advice in DESTRUCTIVE_ADVICE {
12962            assert!(!rendered.contains(advice), "{advice}: {rendered}");
12963        }
12964        for problem in status.problems() {
12965            assert!(problem.detail.contains("Act on it"), "{problem}");
12966        }
12967    }
12968
12969    #[test]
12970    fn no_command_of_this_account_changes_another_installations_service() {
12971        let (owner, earlier) = a_machine_with_a_stale_second_account();
12972        let operations = second_account(&earlier, &owner);
12973        let foreign = |result: Result<(), ServiceError>| {
12974            assert!(
12975                matches!(result, Err(ServiceError::ForeignInstallation { .. })),
12976                "{result:?}"
12977            );
12978        };
12979        foreign(
12980            operations
12981                .install(&request_with_directories(&earlier, StartMode::Boot))
12982                .map(drop),
12983        );
12984        foreign(operations.uninstall().map(drop));
12985        foreign(operations.set_start_mode(StartMode::Boot).map(drop));
12986        foreign(operations.start());
12987        foreign(operations.stop().map(drop));
12988
12989        let registration = owner
12990            .controls
12991            .control(StartMode::Boot)
12992            .unwrap()
12993            .query(&ServiceIdentity::product())
12994            .unwrap()
12995            .expect("the owner's registration is still there");
12996        assert_eq!(
12997            ServiceDirectories::from_command_line(&registration.command_line),
12998            Some(ServiceDirectories::of(&owner.paths)),
12999            "and still runs against the owner's directories"
13000        );
13001        assert!(registration.running, "and was never stopped");
13002        assert!(
13003            InstallRecord::read(&earlier.paths).unwrap().is_some(),
13004            "the refused uninstall kept this account's record"
13005        );
13006    }
13007
13008    #[test]
13009    fn forget_local_leaves_agent_directories_nested_in_the_configuration_alone() {
13010        // macOS puts state, runtime and logs inside the configuration
13011        // directory: `config_local_dir` and `data_local_dir` are one folder.
13012        let root = tempfile::tempdir().unwrap();
13013        let config = root.path().join("Application Support");
13014        let paths = AppPaths::from_directories(
13015            &config,
13016            config.join("state"),
13017            config.join("runtime"),
13018            config.join("logs"),
13019        );
13020        paths.create_all().unwrap();
13021        std::fs::write(config.join(RECORD_FILE), b"an earlier install").unwrap();
13022        std::fs::write(paths.logs_dir().join("runner-manager.log"), b"log").unwrap();
13023        let operations = ServiceOperations::with_controls(
13024            paths.clone(),
13025            ServiceIdentity::product(),
13026            std::sync::Arc::new(RecordingControls::new()),
13027        );
13028
13029        let forgotten = operations.forget_local(Utc::now()).expect("archived");
13030
13031        assert_eq!(forgotten.moved, vec![config.join(RECORD_FILE)]);
13032        for kept in [paths.state_dir(), paths.runtime_dir(), paths.logs_dir()] {
13033            assert!(kept.is_dir(), "{} was moved", kept.display());
13034        }
13035        assert!(paths.logs_dir().join("runner-manager.log").is_file());
13036    }
13037
13038    #[test]
13039    fn a_remedy_is_cut_from_every_detail_a_builder_writes() {
13040        for (detail, diagnosis) in [
13041            (
13042                "x holds an automatic registration, but the daemon is stopped. Run `runner-manager \
13043                 service install --start-at boot`; if the service manager denies replacement",
13044                "x holds an automatic registration, but the daemon is stopped",
13045            ),
13046            (
13047                "store -- MISMATCH. Run `auth login` again so the token is stored",
13048                "store -- MISMATCH",
13049            ),
13050            (
13051                "unit differs, so the daemon runs under outdated settings. Re-register it with \
13052                 `sudo runner-manager service install --start-at boot`",
13053                "unit differs, so the daemon runs under outdated settings",
13054            ),
13055            (
13056                "it starts no runner; the log has it. Sign in again: `runner-manager auth login`",
13057                "it starts no runner; the log has it",
13058            ),
13059            (
13060                "C:\\a\\runner-manager.exe -- STALE: moved. Run `service uninstall` and `service \
13061                 install`",
13062                "C:\\a\\runner-manager.exe -- STALE: moved",
13063            ),
13064            (
13065                "a new builder: run `runner-manager service install` here, or not",
13066                "the installation's own records report this",
13067            ),
13068        ] {
13069            assert_eq!(diagnosis_only(detail), diagnosis, "{detail}");
13070        }
13071    }
13072}