Skip to main content

runner_manager_platform/
service.rs

1// owner: d3-service-installers
2
3//! Registering `daemon run` with the operating system, so that a home host
4//! resumes work by itself after a reboot with nobody logged in.
5//!
6//! Journey 5 is the whole of the requirement: *"the machine reboots with nobody
7//! logged in; the boot-start service starts the agent, which reads the user
8//! access token from the machine-scoped secret store"*. Everything in this file
9//! exists to make that sentence true on Windows, macOS, and Linux, and to make
10//! `service status` say so — or say precisely why not.
11//!
12//! # The shape: render, then apply
13//!
14//! A service manager is the one dependency a `cargo test` run cannot have. So
15//! this module is split in two, and the split is the reason most of it is
16//! testable on a developer's laptop whatever OS that laptop runs:
17//!
18//! * **Rendering** is pure. [`ServiceDefinition`] turns an [`InstallPlan`] into
19//!   the exact text the platform consumes — a systemd unit, a launchd property
20//!   list, a Task Scheduler XML document, or a canonical descriptor of the
21//!   Windows service parameters. No `cfg`, no privileges, no I/O. A Windows
22//!   developer renders and asserts the systemd unit; a Linux CI leg renders and
23//!   asserts the launchd plist.
24//! * **Applying** is behind [`ServiceControl`], one trait with a backend per
25//!   platform and a public in-memory double ([`RecordingControls`]) that `f3`
26//!   and this crate's own tests drive without touching the host.
27//!
28//! [`ServiceOperations`] is the layer above both, and it is where the logic
29//! that is *not* platform-specific lives: refusing an install while the
30//! single-instance lock is held, recording the resolved absolute binary path,
31//! detecting a stale one, switching start mode without reinstalling, and
32//! uninstalling without deleting a byte of configuration, secrets, or cache.
33//!
34//! # Two domains, not two products
35//!
36//! `--start-at boot` and `--start-at login` are the same daemon registered in
37//! two different *domains*:
38//!
39//! | | boot | login |
40//! |---|---|---|
41//! | Windows | a service in the Service Control Manager, `LocalSystem` | a Task Scheduler task with a logon trigger, running at `LeastPrivilege` |
42//! | macOS | a LaunchDaemon in `/Library/LaunchDaemons` | a LaunchAgent in `~/Library/LaunchAgents` |
43//! | Linux | a system unit in `/etc/systemd/system` | a user unit in `~/.config/systemd/user` |
44//!
45//! The Windows row is the one that is not symmetric, and it is worth saying why
46//! rather than leaving a reader to wonder. **Windows services cannot start at
47//! logon.** Service trigger-start covers domain join, an IP address becoming
48//! available, a device arriving, a firewall port event and a group-policy
49//! change; there is no logon trigger, and there is no user-session service
50//! type this product could use instead. Task Scheduler is the mechanism Windows
51//! actually provides for "run this when the operator signs in", so that is what
52//! `--start-at login` uses there. It is registered, inspected, and removed
53//! through the same [`ServiceControl`] trait as everything else, so nothing
54//! above this module has to know.
55//!
56//! # What the account can reach, and why it is not more
57//!
58//! `05-infrastructure.md` requires *"a least-privilege account that can read the
59//! machine-scoped secret store and write its configured cache and runtime
60//! directories"*. Those two clauses pull in opposite directions on every
61//! platform, and the resolution is recorded per platform in
62//! `docs/service-account.md` and checked by [`review_least_privilege`], which
63//! reads the rendered definition back and reports anything it grants beyond the
64//! requirement.
65//!
66//! The Windows resolution is the one that surprises people. `d2` protects the
67//! machine-scoped store with `D:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;OW)` and
68//! documents that the DACL *is* the access control, because a machine-scope
69//! DPAPI blob is decryptable by any process on the host. `NT AUTHORITY\
70//! LocalService` and `NT AUTHORITY\NetworkService` — the two accounts a
71//! "least privilege service" reflex reaches for — are named by none of those
72//! three ACEs and therefore **cannot read the token at all**. Widening the DACL
73//! to reach them would grant every service on the host read access to the one
74//! credential this product holds, which is strictly worse than running as
75//! `LocalSystem`. `secrets.rs` is not this task's file and is not widened; the
76//! service runs as `LocalSystem`, and [`review_least_privilege`] records the
77//! account together with the reason it is the minimum that satisfies the
78//! requirement rather than pretending it is small.
79//!
80//! # What is deliberately not claimed
81//!
82//! **A reboot is not something a test suite can have.** Every assertion here is
83//! about configuration a boot-time start depends on — the start type, the
84//! account, the recorded absolute path, the restart policy the service manager
85//! reports back — and about a store that is readable outside any login session,
86//! which `d2` proves separately. That the machine actually comes back up and
87//! the agent actually resumes is human gate 3 in `06-migration-rollout.md`, and
88//! nothing in this file is evidence for it.
89
90use std::collections::BTreeMap;
91use std::ffi::OsString;
92use std::fmt;
93use std::path::{Path, PathBuf};
94use std::time::Duration;
95
96use chrono::{DateTime, Utc};
97use runner_manager_domain::model::StartMode;
98use runner_manager_domain::path::LocalAbsolutePath;
99use serde::{Deserialize, Serialize};
100
101use crate::lock::{HostLock, LockError, LockKind};
102use crate::paths::AppPaths;
103#[cfg(windows)]
104use crate::runner_root_access::RootAdmission;
105use crate::runner_root_access::{
106    Reversal, RootAccessChange, RootAccessError, RootAccessReport, RootAccessSummary,
107};
108
109// ---------------------------------------------------------------------------
110// Names
111// ---------------------------------------------------------------------------
112
113/// The product's own service name, on every platform that wants a short one.
114pub const SERVICE_NAME: &str = "runner-manager";
115/// Hidden CLI marker carried only by Windows boot-service registrations.
116///
117/// Application-data arguments are shared by every service manager and cannot
118/// distinguish SCM from Task Scheduler. This marker is the durable routing
119/// contract between the Windows installer and the shipping binary.
120pub const WINDOWS_SCM_HOST_ARGUMENT: &str = "--windows-service-host";
121
122/// What an operator sees in `services.msc`, `launchctl list`, or
123/// `systemctl status`.
124pub const DISPLAY_NAME: &str = "GitHub Actions Runner Manager";
125
126/// One line of explanation, for the same three places.
127pub const DESCRIPTION: &str =
128    "Starts ephemeral GitHub Actions self-hosted runners on this machine on demand.";
129
130/// The arguments the installed command line carries.
131///
132/// `05-infrastructure.md`: *"`service install` registers `daemon run` for the
133/// current host"*. Spelled once, here, so the installer and `f3`'s command
134/// surface cannot disagree about what was registered.
135pub const DAEMON_ARGUMENTS: [&str; 2] = ["daemon", "run"];
136
137/// The file `install` writes and `status` reads, inside `config/`.
138///
139/// `config/` and not `state/`: `05-infrastructure.md` gives `config/` to
140/// *"non-secret TOML"*, and this is exactly that — a record of what was
141/// registered, holding no credential. It is also the one file `uninstall`
142/// removes, which is what keeps "uninstall deletes no configuration" honest:
143/// the record is not configuration, it is the registration's own footprint.
144pub const RECORD_FILE: &str = "service.toml";
145
146/// The file the daemon touches after every successful GitHub call, inside
147/// `state/`.
148///
149/// See [`record_github_contact`] for the contract; `service status` reads it
150/// and Journey 5 step 4 requires it.
151pub const CONTACT_FILE: &str = "github-contact.toml";
152
153/// Present while GitHub is rejecting the daemon's credential, inside `state/`.
154///
155/// See [`record_github_credential_rejected`] for the contract.
156pub const CREDENTIAL_REJECTION_FILE: &str = "github-credential-rejected.toml";
157
158/// Present while the daemon cannot read its own stored credential, inside
159/// `state/`.
160///
161/// See [`record_credential_unreadable`] for the contract.
162pub const CREDENTIAL_UNREADABLE_FILE: &str = "credential-unreadable.toml";
163
164/// The `service status` subject a [`CREDENTIAL_UNREADABLE_FILE`] is reported
165/// under, which the TUI matches to offer the same command.
166pub const CREDENTIAL_UNREADABLE_SUBJECT: &str = "stored credential";
167
168/// The agent's last runner-root refusal, for `service status` to report.
169///
170/// See [`record_runner_root_refusal`] for the contract.
171pub const ROOT_REFUSAL_FILE: &str = "runner-root-refusal.toml";
172
173/// The rotating diagnostic log the **daemon** writes, inside `logs/`.
174///
175/// The appender adds its own date suffix, so this is the stem rather than a
176/// file that exists. `service status` reports the directory and the stem,
177/// because that is what an operator needs in order to find today's file and
178/// yesterday's.
179///
180/// It is [`crate::logging::SERVICE_LOG_STEM`] and not the operator's, because a
181/// boot-mode daemon runs under a different account and writes a different file.
182/// [`crate::logging::LogRole`] says why the two were separated.
183pub const LOG_FILE_STEM: &str = crate::logging::SERVICE_LOG_STEM;
184
185// ---------------------------------------------------------------------------
186// Identity
187// ---------------------------------------------------------------------------
188
189/// What the operating system calls this registration.
190///
191/// A type rather than three constants, for one reason that is not tidiness: the
192/// privileged installer tests register a **real** service on a **real** machine,
193/// and they must not be able to collide with — or remove — an operator's
194/// installation. [`ServiceIdentity::fixture`] produces a name that is
195/// unmistakably a test artefact, and every backend takes its name from here, so
196/// there is no path by which a test reaches the product's own registration.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct ServiceIdentity {
199    name: String,
200    display_name: String,
201    description: String,
202}
203
204impl ServiceIdentity {
205    /// The registration an operator installs.
206    #[must_use]
207    pub fn product() -> Self {
208        Self {
209            name: SERVICE_NAME.to_string(),
210            display_name: DISPLAY_NAME.to_string(),
211            description: DESCRIPTION.to_string(),
212        }
213    }
214
215    /// A disposable registration for a privileged test, named so that it cannot
216    /// be mistaken for — or collide with — [`ServiceIdentity::product`].
217    ///
218    /// `tag` distinguishes concurrent runs; it is reduced to ASCII alphanumerics
219    /// and `-` so that it is a legal service name, launchd label, systemd unit
220    /// name, and Task Scheduler task name at once.
221    #[must_use]
222    pub fn fixture(tag: &str) -> Self {
223        let tag: String = tag
224            .chars()
225            .map(|c| {
226                if c.is_ascii_alphanumeric() {
227                    c.to_ascii_lowercase()
228                } else {
229                    '-'
230                }
231            })
232            .collect();
233        let name = format!("{SERVICE_NAME}-selftest-{tag}");
234        Self {
235            display_name: format!("{DISPLAY_NAME} (self-test fixture {tag})"),
236            description: "Disposable fixture created by runner-manager's own installer tests. \
237                          Safe to remove."
238                .to_string(),
239            name,
240        }
241    }
242
243    /// Whether this identity is a disposable test fixture.
244    ///
245    /// The privileged tests assert this before they delete anything, which is
246    /// the mechanical half of *"never touch a service you did not create"*.
247    #[must_use]
248    pub fn is_fixture(&self) -> bool {
249        self.name.starts_with(&format!("{SERVICE_NAME}-selftest-"))
250    }
251
252    /// The Windows service name, the systemd unit stem, and the Task Scheduler
253    /// task name.
254    #[must_use]
255    pub fn name(&self) -> &str {
256        &self.name
257    }
258
259    /// The human-readable name.
260    #[must_use]
261    pub fn display_name(&self) -> &str {
262        &self.display_name
263    }
264
265    /// One line of explanation.
266    #[must_use]
267    pub fn description(&self) -> &str {
268        &self.description
269    }
270
271    /// The launchd label — reverse-domain, as launchd expects.
272    ///
273    /// Built from the same three product segments [`crate::paths`] resolves the
274    /// application-data directories with, so a label and a directory cannot
275    /// drift apart.
276    #[must_use]
277    pub fn launchd_label(&self) -> String {
278        format!(
279            "{}.{}.{}",
280            crate::paths::QUALIFIER,
281            crate::paths::ORGANIZATION,
282            self.name
283        )
284    }
285
286    /// The systemd unit file name.
287    #[must_use]
288    pub fn systemd_unit(&self) -> String {
289        format!("{}.service", self.name)
290    }
291}
292
293impl fmt::Display for ServiceIdentity {
294    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
295        f.write_str(&self.name)
296    }
297}
298
299// ---------------------------------------------------------------------------
300// Restart policy
301// ---------------------------------------------------------------------------
302
303/// The restart-on-failure policy, and the bound on how fast it may retry.
304///
305/// `05-infrastructure.md` item 3: *"set a restart-on-failure policy with bounded
306/// delay"*. Both halves matter and they are different requirements. A service
307/// that does not come back after a crash defeats Journey 5; a service that comes
308/// back instantly, forever, turns one bad configuration into a fork bomb against
309/// GitHub's rate limit. So the delay has a floor as well as a ceiling, and the
310/// floor is what `does not restart-loop faster than that bound` is measured
311/// against.
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub struct RestartPolicy {
314    delay: Duration,
315    reset_after: Duration,
316}
317
318impl RestartPolicy {
319    /// The shortest delay this will accept.
320    ///
321    /// One second is not a tuning preference. Below it, launchd's own throttle
322    /// and systemd's start-limit logic both take over and the configured number
323    /// stops being the number in force, so a policy under this floor would be a
324    /// value `service status` reported and the platform ignored.
325    pub const MIN_DELAY: Duration = Duration::from_secs(1);
326
327    /// The longest delay this will accept. Past five minutes an operator
328    /// watching a restart would reasonably conclude the service is simply gone.
329    pub const MAX_DELAY: Duration = Duration::from_secs(300);
330
331    /// What `service install` uses when nothing is said.
332    pub const DEFAULT_DELAY: Duration = Duration::from_secs(15);
333
334    /// How long the service must stay up before its failure count is forgotten.
335    pub const DEFAULT_RESET_AFTER: Duration = Duration::from_secs(600);
336
337    /// # Errors
338    ///
339    /// [`ServiceError::RestartDelay`] when `delay` is outside
340    /// [`RestartPolicy::MIN_DELAY`]..=[`RestartPolicy::MAX_DELAY`], or when
341    /// `reset_after` is not longer than `delay` — a reset window shorter than
342    /// the delay can never elapse between two restarts, so the failure count
343    /// would reset on every attempt and no start-limit could ever trip.
344    pub fn new(delay: Duration, reset_after: Duration) -> Result<Self, ServiceError> {
345        if delay < Self::MIN_DELAY || delay > Self::MAX_DELAY {
346            return Err(ServiceError::RestartDelay {
347                requested_secs: delay.as_secs(),
348                min_secs: Self::MIN_DELAY.as_secs(),
349                max_secs: Self::MAX_DELAY.as_secs(),
350            });
351        }
352        if reset_after <= delay {
353            return Err(ServiceError::RestartResetWindow {
354                reset_secs: reset_after.as_secs(),
355                delay_secs: delay.as_secs(),
356            });
357        }
358        Ok(Self { delay, reset_after })
359    }
360
361    /// How long the service manager waits before restarting a failed service.
362    #[must_use]
363    pub const fn delay(&self) -> Duration {
364        self.delay
365    }
366
367    /// How long the service must run before its failure count resets.
368    #[must_use]
369    pub const fn reset_after(&self) -> Duration {
370        self.reset_after
371    }
372
373    /// The delay a given manager can actually express.
374    ///
375    /// Three of the four take seconds and enforce exactly what they are given.
376    /// **Windows Task Scheduler does not**: `RestartOnFailure/Interval` is
377    /// expressed in whole minutes with a one-minute floor, and it *rejects* the
378    /// registration outright rather than rounding — `PT15S` comes back as
379    /// "The task XML contains a value which is incorrectly formatted or out of
380    /// range", which is how this was found.
381    ///
382    /// So the interval is rounded **up**, never down, and never below one
383    /// minute. The direction is the point: the requirement is that the service
384    /// *"does not restart-loop faster than that bound"*, and a delay longer
385    /// than the configured one still satisfies it, while a shorter one would
386    /// not. `service status` reports the difference as a note so that an
387    /// operator reading `15s` in the record and `60s` from the manager is told
388    /// why rather than left to wonder.
389    #[must_use]
390    pub const fn effective_delay(&self, kind: DefinitionKind) -> Duration {
391        match kind {
392            DefinitionKind::WindowsScheduledTask => {
393                let seconds = self.delay.as_secs();
394                let minutes = seconds.div_ceil(60);
395                Duration::from_secs(if minutes == 0 { 60 } else { minutes * 60 })
396            }
397            _ => self.delay,
398        }
399    }
400}
401
402impl Default for RestartPolicy {
403    fn default() -> Self {
404        Self {
405            delay: Self::DEFAULT_DELAY,
406            reset_after: Self::DEFAULT_RESET_AFTER,
407        }
408    }
409}
410
411impl fmt::Display for RestartPolicy {
412    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
413        write!(
414            f,
415            "restart after {}s, failure count resets after {}s",
416            self.delay.as_secs(),
417            self.reset_after.as_secs()
418        )
419    }
420}
421
422// ---------------------------------------------------------------------------
423// The account
424// ---------------------------------------------------------------------------
425
426/// The account a registration runs under.
427///
428/// Not an operator choice. It is a function of the start mode and the platform,
429/// because the start mode decides which secret store the daemon must read and
430/// the store's own access control decides which accounts can read it. See
431/// `docs/service-account.md`, and [`review_least_privilege`] for the check.
432#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
433#[serde(rename_all = "snake_case")]
434pub enum ServiceAccount {
435    /// `NT AUTHORITY\SYSTEM`. The only stock Windows account named by the
436    /// machine-scoped store's DACL.
437    LocalSystem,
438    /// `root`. What a LaunchDaemon and a systemd system unit run as, and what
439    /// the macOS System Keychain's root-only master key requires.
440    Root,
441    /// The operator's own account, for a login-mode registration.
442    InvokingUser,
443}
444
445impl ServiceAccount {
446    /// The account a given kind of definition obliges.
447    ///
448    /// Keyed on the definition rather than on `cfg!(windows)`, and the
449    /// difference is not academic: a Windows developer rendering the launchd
450    /// property list would otherwise write `UserName = NT AUTHORITY\SYSTEM`
451    /// into it, which is not an account macOS has. A definition's account is a
452    /// property of the definition, and the whole point of rendering being pure
453    /// is that any host can render any platform's and get the right answer.
454    #[must_use]
455    pub const fn for_definition(kind: DefinitionKind, mode: StartMode) -> Self {
456        match (kind, mode) {
457            (DefinitionKind::WindowsService, _) => Self::LocalSystem,
458            (DefinitionKind::WindowsScheduledTask, _) | (_, StartMode::Login) => Self::InvokingUser,
459            (_, StartMode::Boot) => Self::Root,
460        }
461    }
462
463    /// The account the given start mode obliges on the platform this binary was
464    /// built for.
465    #[must_use]
466    pub const fn for_start_mode(mode: StartMode) -> Self {
467        Self::for_definition(host_definition_kind(mode), mode)
468    }
469
470    /// How the platform spells it.
471    #[must_use]
472    pub const fn as_str(&self) -> &'static str {
473        match self {
474            Self::LocalSystem => "NT AUTHORITY\\SYSTEM",
475            Self::Root => "root",
476            Self::InvokingUser => "the invoking user",
477        }
478    }
479
480    /// Why this is the *minimum* account that can do the job, not merely the
481    /// convenient one.
482    ///
483    /// Printed by `service status` and by [`review_least_privilege`], because a
484    /// privileged account with no stated reason is indistinguishable from a
485    /// privileged account nobody thought about.
486    #[must_use]
487    pub const fn justification(&self) -> &'static str {
488        match self {
489            Self::LocalSystem => {
490                "the machine-scoped store's DACL names SY, BA and OW only; LocalService and \
491                 NetworkService cannot read it, and widening the DACL to reach them would grant \
492                 every service on this host read access to the one credential this product holds"
493            }
494            Self::Root => {
495                "a boot-time registration runs outside every login session: on macOS the System \
496                 Keychain is unlocked by /var/db/SystemKey, which is root-only, and on Linux the \
497                 machine-scoped store is a 0600 file under /var/lib that only root can open \
498                 before a session exists"
499            }
500            Self::InvokingUser => {
501                "a login-mode registration reads the user-scoped store, which is deliberately \
502                 readable by exactly one account and needs no elevation at all"
503            }
504        }
505    }
506
507    /// Whether registering under this account needs administrative rights.
508    #[must_use]
509    pub const fn needs_elevation(&self) -> bool {
510        matches!(self, Self::LocalSystem | Self::Root)
511    }
512}
513
514impl fmt::Display for ServiceAccount {
515    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
516        f.write_str(self.as_str())
517    }
518}
519
520// ---------------------------------------------------------------------------
521// Errors
522// ---------------------------------------------------------------------------
523
524/// Something went wrong installing, inspecting, or removing the registration.
525///
526/// Every variant is formatted straight into an operator-facing message, so each
527/// one says what to do next rather than only what failed. No variant carries a
528/// secret: this module never reads the token, only the *location* `d2` publishes
529/// for exactly this purpose.
530#[derive(Debug, thiserror::Error)]
531pub enum ServiceError {
532    /// The single-instance lock is held, so a second agent must not be
533    /// registered.
534    ///
535    /// `05-infrastructure.md` item 1. The message is `d1`'s, verbatim, because
536    /// `d1` already worked out what to tell an operator who lost that race.
537    #[error("cannot install the service while an agent is already running on this host: {source}")]
538    LockHeld {
539        /// What `d1` said, including who holds it.
540        #[source]
541        source: Box<LockError>,
542    },
543
544    /// The lock could not be inspected at all, which is not the same as it being
545    /// free.
546    #[error(
547        "cannot tell whether an agent is already running on this host, so the service was not \
548         installed: {source}. Fix the reported problem with the state directory and try again."
549    )]
550    LockUnreadable {
551        /// The underlying failure.
552        #[source]
553        source: Box<LockError>,
554    },
555
556    /// The runner root this registration would run jobs under could not be
557    /// created, inspected, or given the access the registration's account needs.
558    ///
559    /// Reported *before* anything is registered. A registration whose workspaces
560    /// would land in a directory ordinary local users can write is a
561    /// registration that should not exist, and `04-security-recovery.md` asks
562    /// for exactly that refusal rather than a warning.
563    #[error(
564        "this registration would run jobs under the default runner root, and that root could \
565         not be prepared, so nothing was registered: {source}"
566    )]
567    RunnerRoot {
568        /// What [`crate::runner_root_access`] reported, including the remedy.
569        #[source]
570        source: Box<RootAccessError>,
571    },
572
573    /// The path of the running binary could not be resolved.
574    #[error(
575        "cannot resolve the absolute path of this executable, so there is nothing to register: \
576         {detail}. Run the installer from the installed binary rather than through a shell \
577         function or a wrapper that replaces argv[0]."
578    )]
579    BinaryPath {
580        /// What the platform reported.
581        detail: String,
582    },
583
584    /// The binary named for the registration is not there.
585    #[error(
586        "{} is not a file, so registering it would create a service that cannot start. Install \
587         the product first, then run `service install` from the installed binary.",
588        path.display()
589    )]
590    BinaryMissing {
591        /// The path that was to be registered.
592        path: PathBuf,
593    },
594
595    /// The requested restart delay is outside the bound.
596    #[error(
597        "a restart delay of {requested_secs}s is outside the supported range \
598         {min_secs}s-{max_secs}s. Below the floor the platform's own throttle overrides the \
599         value, so `service status` would report a delay that is not the one in force."
600    )]
601    RestartDelay {
602        /// What was asked for.
603        requested_secs: u64,
604        /// The floor.
605        min_secs: u64,
606        /// The ceiling.
607        max_secs: u64,
608    },
609
610    /// The failure-count reset window is not longer than the restart delay.
611    #[error(
612        "a failure-count reset window of {reset_secs}s is not longer than the {delay_secs}s \
613         restart delay, so the count would reset between every pair of restarts and no \
614         start limit could ever apply."
615    )]
616    RestartResetWindow {
617        /// What was asked for.
618        reset_secs: u64,
619        /// The delay it has to exceed.
620        delay_secs: u64,
621    },
622
623    /// A registration is already there, in the **other** start mode.
624    ///
625    /// Only the other one. A registration in the mode being asked for is
626    /// replaced rather than refused — see [`Operations::install`], and the
627    /// outage that taught us why.
628    #[error(
629        "{name} is already registered to start at {existing}, and this asks for {requested}. \
630         Changing the start mode moves the registration between two different service \
631         managers, so it is not something an install does by itself: run `service uninstall` \
632         first, or change the start mode in the terminal UI (`runner-manager tui`), which \
633         switches it in place and keeps the registration running throughout."
634    )]
635    AlreadyInstalled {
636        /// Which registration.
637        name: String,
638        /// The mode it currently carries.
639        existing: StartMode,
640        /// The mode this install asked for.
641        requested: StartMode,
642    },
643
644    /// No registration is there.
645    #[error("{name} is not registered on this host, so there is nothing to {operation}.")]
646    NotInstalled {
647        /// Which registration.
648        name: String,
649        /// What was being attempted.
650        operation: &'static str,
651    },
652
653    /// The record `install` wrote could not be read or written.
654    #[error("cannot {operation} the service record {}: {detail}", path.display())]
655    Record {
656        /// Read, write, or remove.
657        operation: &'static str,
658        /// The record file.
659        path: PathBuf,
660        /// The underlying failure.
661        detail: String,
662    },
663
664    /// The record is there but is not one this version wrote.
665    #[error(
666        "the service record {} was not written by this product, or was written by a version \
667         this one cannot read: {detail}. Run `service uninstall` and `service install` again; \
668         neither touches configuration, secrets, or the cache.",
669        path.display()
670    )]
671    RecordUnreadable {
672        /// The record file.
673        path: PathBuf,
674        /// What was wrong with it.
675        detail: String,
676    },
677
678    /// The record is there and **this account** may not read it.
679    ///
680    /// Its own variant rather than a [`ServiceError::Record`] with a
681    /// `Permission denied` in the detail, because it is the one record failure
682    /// that is not a fault in the record: `service status` reports it and
683    /// carries on, where every other read failure ends the command. See
684    /// [`Operations::status`].
685    ///
686    /// A version before this one wrote the record at `0600` through a temporary
687    /// file, so a boot-mode `sudo service install` left it owned by `root` and
688    /// unreadable by the operator whose profile it is in. `service status` then
689    /// failed on their own host, with a remedy naming the command that had just
690    /// failed.
691    #[error(
692        "the service record {} is there and this account may not read it: {detail}. It was \
693         written by whichever account installed the service -- on a boot-mode host, `sudo \
694         service install`. Running `service install` again rewrites it readable; until then \
695         only what the service manager itself reports is available.",
696        path.display()
697    )]
698    RecordNotPermitted {
699        /// The record file.
700        path: PathBuf,
701        /// What the operating system said.
702        detail: String,
703    },
704
705    /// The application-data directories could not be resolved or created.
706    #[error("cannot prepare this host's application-data directories: {source}")]
707    Paths {
708        /// The underlying failure.
709        #[source]
710        source: Box<crate::paths::PathsError>,
711    },
712
713    /// The platform's service manager refused, or could not be reached.
714    #[error("cannot {operation} {name} through {manager}: {detail}")]
715    Control {
716        /// What was being attempted.
717        operation: &'static str,
718        /// Which registration.
719        name: String,
720        /// Which service manager.
721        manager: &'static str,
722        /// What it said.
723        detail: String,
724    },
725
726    /// An operation failed and its compensating action failed too.
727    #[error(
728        "cannot {operation} {name}: {cause}. The attempted rollback also failed: {rollback}. \
729         Inspect `service status` before retrying."
730    )]
731    Rollback {
732        /// The transaction that could not be completed.
733        operation: &'static str,
734        /// Which registration was involved.
735        name: String,
736        /// The original failure.
737        cause: String,
738        /// The failure while restoring the previous state.
739        rollback: String,
740    },
741
742    /// The operation needs administrative rights it does not have.
743    #[error("{operation} {name} needs administrative rights: {detail}. {remedy}")]
744    NeedsElevation {
745        /// What was being attempted.
746        operation: &'static str,
747        /// Which registration.
748        name: String,
749        /// What the platform said.
750        detail: String,
751        /// How to get them, in this platform's own terms.
752        remedy: &'static str,
753    },
754}
755
756// ---------------------------------------------------------------------------
757// Directories
758// ---------------------------------------------------------------------------
759
760/// The four directories the registration was installed against.
761///
762/// Recorded rather than re-derived, because `05-infrastructure.md` item 2 speaks
763/// of the *configured* cache and runtime directories: the account the service
764/// runs under and the account that ran `service install` do not always resolve
765/// the same platform-standard locations, and a claim about what the service can
766/// write is only checkable against a specific set of paths.
767#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
768pub struct ServiceDirectories {
769    /// Non-secret TOML and the SQLite database.
770    pub config: PathBuf,
771    /// The agent lock, the attempt journal, and the runner package cache.
772    pub state: PathBuf,
773    /// Per-attempt disposable runner workspaces.
774    pub runtime: PathBuf,
775    /// Rotating redacted diagnostics.
776    pub logs: PathBuf,
777}
778
779impl ServiceDirectories {
780    /// Snapshots the four directories a resolved [`AppPaths`] names.
781    #[must_use]
782    pub fn of(paths: &AppPaths) -> Self {
783        Self {
784            config: paths.config_dir().to_path_buf(),
785            state: paths.state_dir().to_path_buf(),
786            runtime: paths.runtime_dir().to_path_buf(),
787            logs: paths.logs_dir().to_path_buf(),
788        }
789    }
790
791    /// The four, in the order `05-infrastructure.md` lists them.
792    #[must_use]
793    pub fn all(&self) -> [&Path; 4] {
794        [
795            self.config.as_path(),
796            self.state.as_path(),
797            self.runtime.as_path(),
798            self.logs.as_path(),
799        ]
800    }
801
802    /// The rotating diagnostic log's stem, which is what `service status`
803    /// reports and what `05-infrastructure.md` item 4 requires be preserved.
804    #[must_use]
805    pub fn log_file(&self) -> PathBuf {
806        self.logs.join(LOG_FILE_STEM)
807    }
808}
809
810// ---------------------------------------------------------------------------
811// The install plan
812// ---------------------------------------------------------------------------
813
814/// What `service install` was asked for, before anything has been resolved.
815///
816/// Separate from [`InstallPlan`] because a request is the operator's words and
817/// a plan is what the host actually supports: the plan carries a *resolved
818/// absolute* binary path, the account the start mode obliges, and the four
819/// directories the registration is installed against, none of which the caller
820/// supplies.
821#[derive(Debug, Clone, PartialEq, Eq)]
822pub struct InstallRequest {
823    start_mode: StartMode,
824    binary: Option<PathBuf>,
825    source_binary: Option<PathBuf>,
826    arguments: Vec<OsString>,
827    restart: RestartPolicy,
828    on_demand: bool,
829}
830
831impl InstallRequest {
832    /// Register the binary that is running this call.
833    #[must_use]
834    pub fn new(start_mode: StartMode) -> Self {
835        Self {
836            start_mode,
837            binary: None,
838            source_binary: None,
839            arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
840            restart: RestartPolicy::default(),
841            on_demand: false,
842        }
843    }
844
845    /// Registers the service but does not ask the manager to start it by
846    /// itself.
847    ///
848    /// Production never uses this: a boot-mode registration that does not start
849    /// at boot is the failure `service status` reports. **The privileged
850    /// installer tests use it for every fixture they create**, so that a
851    /// registration which somehow escaped its cleanup guard cannot start with
852    /// the owner's machine on the next reboot. A leaked service is bad; a
853    /// leaked service that runs is worse, and the difference costs one flag.
854    #[must_use]
855    pub const fn started_on_demand(mut self) -> Self {
856        self.on_demand = true;
857        self
858    }
859
860    /// Register a named binary instead of the running one.
861    ///
862    /// The privileged installer tests use this to register a fixture service
863    /// host. Production does not: `05-infrastructure.md` item 6 is specifically
864    /// about the path of *the running binary*, and letting an operator name a
865    /// different one would defeat the stale-path detection it asks for.
866    #[must_use]
867    pub fn for_binary(mut self, binary: impl Into<PathBuf>) -> Self {
868        self.binary = Some(binary.into());
869        self
870    }
871
872    /// Where the registered binary was copied from.
873    ///
874    /// # Why a registration has a source at all
875    ///
876    /// A service registered directly against a package manager's own file can
877    /// never be upgraded on Windows, because the running service holds that
878    /// file open and `npm i -g` cannot replace it. What it does instead is
879    /// worse than failing: it rewrites the package metadata, reports success,
880    /// and leaves the old executable in place — so the operator is told the new
881    /// version is installed while the old one keeps running. That was observed,
882    /// twice in a row, before this existed.
883    ///
884    /// So the service runs a copy the product owns, and this records where that
885    /// copy came from. The source is what a package manager updates, what
886    /// [`inspect_binary`] watches for item 6's stale-path case, and what the
887    /// daemon compares its own version against to know an upgrade is waiting.
888    #[must_use]
889    pub fn copied_from(mut self, source: impl Into<PathBuf>) -> Self {
890        self.source_binary = Some(source.into());
891        self
892    }
893
894    /// Replace the registered arguments. Defaults to [`DAEMON_ARGUMENTS`].
895    #[must_use]
896    pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
897    where
898        I: IntoIterator<Item = S>,
899        S: Into<OsString>,
900    {
901        self.arguments = arguments.into_iter().map(Into::into).collect();
902        self
903    }
904
905    /// Replace the restart-on-failure policy. Defaults to
906    /// [`RestartPolicy::default`].
907    #[must_use]
908    pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
909        self.restart = restart;
910        self
911    }
912
913    /// The start mode asked for.
914    #[must_use]
915    pub const fn start_mode(&self) -> StartMode {
916        self.start_mode
917    }
918}
919
920/// Everything a backend needs, resolved and validated.
921///
922/// Constructing one is where `05-infrastructure.md` item 6 is actually
923/// satisfied: the binary path is made absolute and is confirmed to be a file
924/// *before* anything is registered, so a registration that would fail at boot
925/// is refused at install time instead.
926#[derive(Debug, Clone, PartialEq, Eq)]
927pub struct InstallPlan {
928    identity: ServiceIdentity,
929    start_mode: StartMode,
930    binary: PathBuf,
931    source_binary: Option<PathBuf>,
932    arguments: Vec<OsString>,
933    account: ServiceAccount,
934    restart: RestartPolicy,
935    directories: ServiceDirectories,
936    secret_guard: Option<PathBuf>,
937    on_demand: bool,
938}
939
940impl InstallPlan {
941    /// Resolves a request against this host.
942    ///
943    /// # Errors
944    ///
945    /// [`ServiceError::BinaryPath`] when the running executable cannot be
946    /// located, and [`ServiceError::BinaryMissing`] when the path that would be
947    /// registered is not a file.
948    pub fn resolve(
949        identity: ServiceIdentity,
950        request: &InstallRequest,
951        directories: ServiceDirectories,
952    ) -> Result<Self, ServiceError> {
953        let binary = match &request.binary {
954            Some(named) => absolute(named)?,
955            None => running_executable()?,
956        };
957        if !binary.is_file() {
958            return Err(ServiceError::BinaryMissing { path: binary });
959        }
960        // `d2` publishes the guard file the store lives in so the systemd
961        // sandbox can admit only its parent for atomic token rotation. A store
962        // that cannot be resolved is not fatal here; it merely cannot receive
963        // that narrow writable-path exception.
964        let secret_guard = crate::secrets::PlatformSecretStore::for_start_mode(request.start_mode)
965            .ok()
966            .map(|store| store.guard());
967        Ok(Self {
968            identity,
969            start_mode: request.start_mode,
970            binary,
971            source_binary: request.source_binary.clone(),
972            arguments: request.arguments.clone(),
973            account: ServiceAccount::for_start_mode(request.start_mode),
974            restart: request.restart,
975            directories,
976            secret_guard,
977            on_demand: request.on_demand,
978        })
979    }
980
981    /// Builds a plan from values a caller already holds, without touching the
982    /// filesystem.
983    ///
984    /// This is what makes the renderers testable on a host that has none of the
985    /// paths involved: a Windows developer can render the systemd unit for
986    /// `/opt/runner-manager/bin/runner-manager` and assert every line of it.
987    #[must_use]
988    pub fn unchecked(
989        identity: ServiceIdentity,
990        start_mode: StartMode,
991        binary: impl Into<PathBuf>,
992        directories: ServiceDirectories,
993    ) -> Self {
994        Self {
995            identity,
996            start_mode,
997            binary: binary.into(),
998            source_binary: None,
999            arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
1000            account: ServiceAccount::for_start_mode(start_mode),
1001            restart: RestartPolicy::default(),
1002            directories,
1003            secret_guard: None,
1004            on_demand: false,
1005        }
1006    }
1007
1008    /// Registers without arming the manager's automatic start. See
1009    /// [`InstallRequest::started_on_demand`].
1010    #[must_use]
1011    pub const fn started_on_demand(mut self) -> Self {
1012        self.on_demand = true;
1013        self
1014    }
1015
1016    /// Whether the manager was asked not to start this by itself.
1017    #[must_use]
1018    pub const fn is_on_demand(&self) -> bool {
1019        self.on_demand
1020    }
1021
1022    /// Names the file the secret store lives in, so systemd can permit atomic
1023    /// replacement of it under `ProtectSystem=strict`.
1024    ///
1025    /// [`InstallPlan::resolve`] fills this from `d2`; a test names it directly
1026    /// so that a Windows or macOS CI leg can assert the Linux unit's writable
1027    /// path for a store that platform does not have.
1028    #[must_use]
1029    pub fn with_secret_guard(mut self, guard: impl Into<PathBuf>) -> Self {
1030        self.secret_guard = Some(guard.into());
1031        self
1032    }
1033
1034    /// The file the machine-scoped secret store lives in, when one resolved.
1035    #[must_use]
1036    pub fn secret_guard(&self) -> Option<&Path> {
1037        self.secret_guard.as_deref()
1038    }
1039
1040    /// Overrides the restart policy on an already-built plan.
1041    #[must_use]
1042    pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1043        self.restart = restart;
1044        self
1045    }
1046
1047    /// Overrides the registered arguments on an already-built plan.
1048    #[must_use]
1049    pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1050    where
1051        I: IntoIterator<Item = S>,
1052        S: Into<OsString>,
1053    {
1054        self.arguments = arguments.into_iter().map(Into::into).collect();
1055        self
1056    }
1057
1058    /// What the operating system calls this registration.
1059    #[must_use]
1060    pub const fn identity(&self) -> &ServiceIdentity {
1061        &self.identity
1062    }
1063
1064    /// Boot or login.
1065    #[must_use]
1066    pub const fn start_mode(&self) -> StartMode {
1067        self.start_mode
1068    }
1069
1070    /// The resolved absolute path of the binary to register.
1071    #[must_use]
1072    pub fn binary(&self) -> &Path {
1073        &self.binary
1074    }
1075
1076    /// Where [`Self::binary`] was copied from, when it is a copy.
1077    ///
1078    /// `None` is the legacy layout: the registration names the file a package
1079    /// manager owns, and cannot be upgraded while it runs. See
1080    /// [`InstallRequest::copied_from`].
1081    #[must_use]
1082    pub fn source_binary(&self) -> Option<&Path> {
1083        self.source_binary.as_deref()
1084    }
1085
1086    /// The arguments it is registered with.
1087    #[must_use]
1088    pub fn arguments(&self) -> &[OsString] {
1089        &self.arguments
1090    }
1091
1092    /// The account it runs under.
1093    #[must_use]
1094    pub const fn account(&self) -> &ServiceAccount {
1095        &self.account
1096    }
1097
1098    /// The restart-on-failure policy.
1099    #[must_use]
1100    pub const fn restart(&self) -> RestartPolicy {
1101        self.restart
1102    }
1103
1104    /// The four directories it was installed against.
1105    #[must_use]
1106    pub const fn directories(&self) -> &ServiceDirectories {
1107        &self.directories
1108    }
1109
1110    /// The command line as one string, quoted the way each platform expects.
1111    #[must_use]
1112    pub fn command_line(&self) -> String {
1113        let mut out = quote_argument(&self.binary.to_string_lossy());
1114        for argument in &self.arguments {
1115            out.push(' ');
1116            out.push_str(&quote_argument(&argument.to_string_lossy()));
1117        }
1118        out
1119    }
1120}
1121
1122/// Undoes a runner-root change, and says what could not be undone.
1123///
1124/// `None` when the rollback was complete — either because there was nothing to
1125/// undo, or because the directory this operation created was removed again and
1126/// the descriptor it replaced was written back. `Some` is the "report any
1127/// non-reversible existing directory state explicitly" half of the requirement,
1128/// and the caller folds it into the failure it was already reporting.
1129fn retained_runner_root(change: &RootAccessChange) -> Option<String> {
1130    let reversal = change.revert();
1131    matches!(reversal, Reversal::Retained { .. }).then(|| reversal.to_string())
1132}
1133
1134/// The error a failed step returns once both of its rollbacks have been
1135/// attempted.
1136///
1137/// `retained` is [`retained_runner_root`]'s answer and `rollback` is what
1138/// undoing the *registration* reported — whatever it reports on success, which
1139/// a failure has no use for. Both arrive already evaluated, because each may be
1140/// attempted exactly once and the order is the caller's to choose.
1141///
1142/// A complete rollback leaves `cause` exactly as it was: the operator's problem
1143/// is what failed, not the tidying up afterwards. An incomplete one is promoted
1144/// to [`ServiceError::Rollback`], which is the variant that exists to say "this
1145/// failed *and* something is left behind" — and it names everything that did.
1146fn rolled_back<T>(
1147    retained: Option<String>,
1148    rollback: Result<T, ServiceError>,
1149    operation: &'static str,
1150    identity: &ServiceIdentity,
1151    cause: ServiceError,
1152) -> ServiceError {
1153    let left_behind = match (rollback.err(), retained) {
1154        (Some(rollback), Some(retained)) => Some(format!("{rollback}; {retained}")),
1155        (Some(rollback), None) => Some(rollback.to_string()),
1156        (None, retained) => retained,
1157    };
1158    match left_behind {
1159        Some(rollback) => ServiceError::Rollback {
1160            operation,
1161            name: identity.name().to_string(),
1162            cause: cause.to_string(),
1163            rollback,
1164        },
1165        None => cause,
1166    }
1167}
1168
1169/// [`rolled_back`] for a failure with nothing registered yet, where the runner
1170/// root is the only thing there is to undo.
1171fn undo_runner_root(
1172    change: &RootAccessChange,
1173    operation: &'static str,
1174    identity: &ServiceIdentity,
1175    cause: ServiceError,
1176) -> ServiceError {
1177    rolled_back(
1178        retained_runner_root(change),
1179        Ok(()),
1180        operation,
1181        identity,
1182        cause,
1183    )
1184}
1185
1186/// Makes a path absolute without resolving symlinks.
1187///
1188/// Symbolic links are deliberately left alone. A Homebrew installation puts a
1189/// stable link at `/opt/homebrew/bin/runner-manager` and moves the file it
1190/// points at on every upgrade; recording the link's target would make an
1191/// ordinary `brew upgrade` look exactly like the npm failure this module exists
1192/// to detect, and recording the link records the thing the operator installed.
1193///
1194/// Linux is the one platform where this is not fully in the caller's hands:
1195/// `std::env::current_exe` reads `/proc/self/exe`, which the kernel has already
1196/// resolved. There the recorded path is the real file rather than the shim —
1197/// which is still correct for the npm case, because a real file under a Node
1198/// prefix disappears with the prefix.
1199fn absolute(path: &Path) -> Result<PathBuf, ServiceError> {
1200    std::path::absolute(path).map_err(|error| ServiceError::BinaryPath {
1201        detail: format!("{} could not be made absolute: {error}", path.display()),
1202    })
1203}
1204
1205/// The absolute path of the executable running this call.
1206fn running_executable() -> Result<PathBuf, ServiceError> {
1207    let raw = std::env::current_exe().map_err(|error| ServiceError::BinaryPath {
1208        detail: error.to_string(),
1209    })?;
1210    absolute(&raw)
1211}
1212
1213/// Quotes one command-line argument when it needs it.
1214///
1215/// The rule is Windows', because Windows is the platform where a command line is
1216/// a single string the callee re-splits, and because `windows-service` applies
1217/// exactly this rule when it builds `lpBinaryPathName`. The Unix backends embed
1218/// arguments in a plist array and in a systemd `ExecStart=`, both of which
1219/// accept the same quoting, so one rule serves all three rather than three
1220/// nearly-identical ones.
1221pub(crate) fn quote_argument(argument: &str) -> String {
1222    if !argument.is_empty() && !argument.contains([' ', '"', '\t', '\n']) {
1223        return argument.to_string();
1224    }
1225    let mut out = String::with_capacity(argument.len() + 2);
1226    out.push('"');
1227    let mut backslashes = 0usize;
1228    for c in argument.chars() {
1229        match c {
1230            '\\' => {
1231                backslashes += 1;
1232                out.push('\\');
1233            }
1234            '"' => {
1235                // Every backslash immediately before a quote must be doubled,
1236                // and the quote itself escaped.
1237                for _ in 0..=backslashes {
1238                    out.push('\\');
1239                }
1240                out.push('"');
1241                backslashes = 0;
1242            }
1243            other => {
1244                backslashes = 0;
1245                out.push(other);
1246            }
1247        }
1248    }
1249    // Trailing backslashes would otherwise escape the closing quote.
1250    for _ in 0..backslashes {
1251        out.push('\\');
1252    }
1253    out.push('"');
1254    out
1255}
1256
1257/// Reads the executable back out of a command line quoted by
1258/// [`quote_argument`].
1259///
1260/// `service status` needs this because the Windows Service Control Manager
1261/// stores the binary and its arguments as one string and hands the whole thing
1262/// back from `QueryServiceConfigW`. Comparing a record against a registration
1263/// means splitting that string the same way Windows itself does.
1264///
1265/// Returns `None` for an empty or whitespace-only command line, which is the
1266/// only input with no first argument to find.
1267#[must_use]
1268pub fn executable_from_command_line(command_line: &str) -> Option<PathBuf> {
1269    let trimmed = command_line.trim_start();
1270    if trimmed.is_empty() {
1271        return None;
1272    }
1273    let mut out = String::new();
1274    let mut chars = trimmed.chars().peekable();
1275    let quoted = chars.peek() == Some(&'"');
1276    if quoted {
1277        chars.next();
1278        let mut backslashes = 0usize;
1279        for c in chars {
1280            match c {
1281                '\\' => {
1282                    backslashes += 1;
1283                }
1284                '"' => {
1285                    // `2n` backslashes then a quote closes the argument; `2n+1`
1286                    // is a literal quote inside it.
1287                    out.extend(std::iter::repeat_n('\\', backslashes / 2));
1288                    if backslashes.is_multiple_of(2) {
1289                        break;
1290                    }
1291                    backslashes = 0;
1292                    out.push('"');
1293                }
1294                other => {
1295                    out.extend(std::iter::repeat_n('\\', backslashes));
1296                    backslashes = 0;
1297                    out.push(other);
1298                }
1299            }
1300        }
1301    } else {
1302        for c in chars {
1303            if c == ' ' || c == '\t' {
1304                break;
1305            }
1306            out.push(c);
1307        }
1308    }
1309    if out.is_empty() {
1310        None
1311    } else {
1312        Some(PathBuf::from(out))
1313    }
1314}
1315
1316// ---------------------------------------------------------------------------
1317// Rendered definitions
1318// ---------------------------------------------------------------------------
1319
1320/// Which of the four things a platform reads.
1321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1322pub enum DefinitionKind {
1323    /// The parameters `CreateServiceW` is called with, rendered as a canonical
1324    /// descriptor so that they can be reviewed and asserted like the other
1325    /// three. The Service Control Manager has no file.
1326    WindowsService,
1327    /// A Task Scheduler XML document, for `--start-at login` on Windows.
1328    WindowsScheduledTask,
1329    /// A launchd property list — a LaunchDaemon at boot, a LaunchAgent at login.
1330    LaunchdPlist,
1331    /// A systemd unit — a system unit at boot, a user unit at login.
1332    SystemdUnit,
1333}
1334
1335impl DefinitionKind {
1336    /// What to call the thing that reads it, in an operator-facing message.
1337    #[must_use]
1338    pub const fn manager(self) -> &'static str {
1339        match self {
1340            Self::WindowsService => "the Windows Service Control Manager",
1341            Self::WindowsScheduledTask => "Windows Task Scheduler",
1342            Self::LaunchdPlist => "launchd",
1343            Self::SystemdUnit => "systemd",
1344        }
1345    }
1346}
1347
1348impl fmt::Display for DefinitionKind {
1349    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1350        f.write_str(self.manager())
1351    }
1352}
1353
1354/// One platform's definition of the registration, as text.
1355///
1356/// Producing this is pure: no `cfg`, no privileges, no filesystem. That is what
1357/// lets every leg of the CI matrix assert every platform's definition, instead
1358/// of each leg asserting only its own and the other two being reviewed by
1359/// reading.
1360#[derive(Debug, Clone, PartialEq, Eq)]
1361pub struct ServiceDefinition {
1362    kind: DefinitionKind,
1363    text: String,
1364    install_path: Option<PathBuf>,
1365}
1366
1367impl ServiceDefinition {
1368    /// Which platform this is for.
1369    #[must_use]
1370    pub const fn kind(&self) -> DefinitionKind {
1371        self.kind
1372    }
1373
1374    /// The definition itself.
1375    #[must_use]
1376    pub fn text(&self) -> &str {
1377        &self.text
1378    }
1379
1380    /// Where the platform expects the file, when the platform reads a file.
1381    ///
1382    /// `None` for [`DefinitionKind::WindowsService`], whose "definition" is a
1383    /// set of arguments to `CreateServiceW`, and for a Task Scheduler document,
1384    /// which is handed to `schtasks` from a temporary file and is not stored
1385    /// where it was written.
1386    #[must_use]
1387    pub fn install_path(&self) -> Option<&Path> {
1388        self.install_path.as_deref()
1389    }
1390
1391    /// The definition this host's own service manager would be given.
1392    ///
1393    /// The value-level twin of [`host_definition_kind`]. Rendering needs no
1394    /// privileges and no service manager, so anything that wants to *see* what
1395    /// an install would register — `f3`'s `service install --dry-run`, a
1396    /// support bundle, [`RecordingControls`] — can have it without registering
1397    /// anything.
1398    ///
1399    /// # Errors
1400    ///
1401    /// [`ServiceError::Control`] when a Windows logon-triggered registration is
1402    /// asked for and the session reports no account to register it for.
1403    pub fn for_host(plan: &InstallPlan) -> Result<Self, ServiceError> {
1404        Ok(match host_definition_kind(plan.start_mode()) {
1405            DefinitionKind::WindowsService => Self::windows_service(plan),
1406            DefinitionKind::WindowsScheduledTask => {
1407                Self::windows_scheduled_task(plan, &TaskPrincipal::current()?)
1408            }
1409            DefinitionKind::LaunchdPlist => Self::launchd(plan, host_home().as_deref()),
1410            DefinitionKind::SystemdUnit => Self::systemd(plan, host_home().as_deref()),
1411        })
1412    }
1413
1414    /// The Windows service parameters, as a canonical descriptor.
1415    #[must_use]
1416    pub fn windows_service(plan: &InstallPlan) -> Self {
1417        Self {
1418            kind: DefinitionKind::WindowsService,
1419            text: windows_service_descriptor(plan),
1420            install_path: None,
1421        }
1422    }
1423
1424    /// A Task Scheduler document for `--start-at login`.
1425    #[must_use]
1426    pub fn windows_scheduled_task(plan: &InstallPlan, principal: &TaskPrincipal) -> Self {
1427        Self {
1428            kind: DefinitionKind::WindowsScheduledTask,
1429            text: windows_scheduled_task_xml(plan, principal),
1430            install_path: None,
1431        }
1432    }
1433
1434    /// A launchd property list.
1435    ///
1436    /// `home` is only consulted for `--start-at login`, where the file belongs
1437    /// in the operator's own `~/Library/LaunchAgents`.
1438    #[must_use]
1439    pub fn launchd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1440        let file = format!("{}.plist", plan.identity().launchd_label());
1441        let install_path = match plan.start_mode() {
1442            StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
1443            StartMode::Login => home.map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file)),
1444        };
1445        Self {
1446            kind: DefinitionKind::LaunchdPlist,
1447            text: launchd_plist(plan),
1448            install_path,
1449        }
1450    }
1451
1452    /// Wraps text this module did not render.
1453    ///
1454    /// Two callers need it and both matter. A test builds a deliberately
1455    /// widened definition and watches [`review_least_privilege`] reject it —
1456    /// without which the review would be a function nobody had ever seen fail.
1457    /// And `service status` can review the definition **actually on disk**,
1458    /// which on Linux and macOS an operator is free to edit after installation.
1459    #[must_use]
1460    pub fn from_text(kind: DefinitionKind, text: impl Into<String>) -> Self {
1461        Self {
1462            kind,
1463            text: text.into(),
1464            install_path: None,
1465        }
1466    }
1467
1468    /// A systemd unit.
1469    ///
1470    /// `home` is only consulted for `--start-at login`, where the unit belongs
1471    /// in the operator's own `~/.config/systemd/user`.
1472    #[must_use]
1473    pub fn systemd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1474        let file = plan.identity().systemd_unit();
1475        let install_path = match plan.start_mode() {
1476            StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
1477            StartMode::Login => home.map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file)),
1478        };
1479        Self {
1480            kind: DefinitionKind::SystemdUnit,
1481            text: systemd_unit(plan),
1482            install_path,
1483        }
1484    }
1485}
1486
1487/// Where a LaunchDaemon lives.
1488pub const LAUNCH_DAEMONS_DIR: &str = "/Library/LaunchDaemons";
1489/// Where a LaunchAgent lives, under the operator's home directory.
1490pub const LAUNCH_AGENTS_SUBDIR: &str = "Library/LaunchAgents";
1491/// Where a systemd system unit lives.
1492pub const SYSTEMD_SYSTEM_DIR: &str = "/etc/systemd/system";
1493/// Where a systemd user unit lives, under the operator's home directory.
1494pub const SYSTEMD_USER_SUBDIR: &str = ".config/systemd/user";
1495
1496/// The product's own page, cited by every definition so an operator who finds
1497/// one on a host can find out what put it there.
1498const DOCUMENTATION: &str = "https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI";
1499
1500/// How many consecutive failures the platform tolerates before it stops
1501/// retrying.
1502///
1503/// The companion to [`RestartPolicy::reset_after`]: the delay bounds how *fast*
1504/// a restart may come, and this bounds how *many* come before the host gives
1505/// up and leaves the failure visible instead of hiding it behind an endless
1506/// retry.
1507pub const START_LIMIT_BURST: u32 = 5;
1508
1509// -- systemd -----------------------------------------------------------------
1510
1511/// Renders the systemd unit for this plan.
1512///
1513/// # Why the sandbox is this tight, and what it costs
1514///
1515/// `05-infrastructure.md` item 2 ends *"and write its configured cache and
1516/// runtime directories — and no more"*. `ProtectSystem=strict` plus an explicit
1517/// `ReadWritePaths` is what "and no more" means on Linux: everything outside the
1518/// recorded application-data directories and the credential store directory
1519/// is read-only to this unit. The latter must be writable because refresh-token
1520/// rotation atomically replaces the credential file.
1521///
1522/// **The runner inherits it.** The agent spawns the GitHub Actions runner as a
1523/// child, so a workflow running on this host also runs inside this sandbox: it
1524/// cannot write outside those directories and its private `/tmp`, and
1525/// `NoNewPrivileges=yes` means it cannot `sudo`. `07-security.md` assumes a
1526/// hostile workflow may run here, so that is the intended direction — but it is
1527/// a real behavioural limit and `docs/service-account.md` states it where an
1528/// operator will find it.
1529#[must_use]
1530pub fn systemd_unit(plan: &InstallPlan) -> String {
1531    let identity = plan.identity();
1532    let restart = plan.restart();
1533    let directories = plan.directories();
1534
1535    let mut out = String::new();
1536    out.push_str("[Unit]\n");
1537    out.push_str(&format!("Description={}\n", identity.display_name()));
1538    out.push_str(&format!("Documentation={DOCUMENTATION}\n"));
1539    out.push_str("After=network-online.target\n");
1540    out.push_str("Wants=network-online.target\n");
1541    // In `[Unit]` rather than `[Service]`: systemd moved these in v229 and only
1542    // accepts them here without a deprecation warning.
1543    out.push_str(&format!(
1544        "StartLimitIntervalSec={}\n",
1545        restart.reset_after().as_secs()
1546    ));
1547    out.push_str(&format!("StartLimitBurst={START_LIMIT_BURST}\n"));
1548
1549    out.push_str("\n[Service]\n");
1550    out.push_str("Type=simple\n");
1551    // A runner is a child of the daemon. In particular, systemd must not
1552    // signal it merely because a cooperative daemon handover replaces the
1553    // manager process; startup recovery supervises any survivor instead.
1554    out.push_str("KillMode=process\n");
1555    out.push_str(&format!("ExecStart={}\n", plan.command_line()));
1556    out.push_str(&format!(
1557        "WorkingDirectory={}\n",
1558        directories.state.display()
1559    ));
1560    out.push_str(&format!("SyslogIdentifier={identity}\n"));
1561    out.push_str("Restart=on-failure\n");
1562    out.push_str(&format!("RestartSec={}\n", restart.delay().as_secs()));
1563
1564    out.push_str("\n# Least privilege. See docs/service-account.md.\n");
1565    for directive in SYSTEMD_HARDENING {
1566        out.push_str(directive);
1567        out.push('\n');
1568    }
1569    let mut writable = directories.all().to_vec();
1570    if let Some(secret_directory) = plan.secret_guard().and_then(Path::parent) {
1571        writable.push(secret_directory);
1572    }
1573    out.push_str(&format!(
1574        "ReadWritePaths={}\n",
1575        writable
1576            .iter()
1577            .map(|path| quote_argument(&path.to_string_lossy()))
1578            .collect::<Vec<_>>()
1579            .join(" ")
1580    ));
1581
1582    out.push_str("\n[Install]\n");
1583    out.push_str(match plan.start_mode() {
1584        StartMode::Boot => "WantedBy=multi-user.target\n",
1585        StartMode::Login => "WantedBy=default.target\n",
1586    });
1587    out
1588}
1589
1590/// The hardening directives every unit carries, in the order they are rendered.
1591///
1592/// A constant rather than a sequence of `push_str` calls so that
1593/// [`review_least_privilege`] can be written against the same list the renderer
1594/// emits: a directive dropped from the unit is a directive the review then
1595/// reports as missing, rather than one that silently stops being checked.
1596pub const SYSTEMD_HARDENING: [&str; 13] = [
1597    "NoNewPrivileges=yes",
1598    "CapabilityBoundingSet=",
1599    "AmbientCapabilities=",
1600    "PrivateTmp=yes",
1601    "PrivateDevices=yes",
1602    "ProtectSystem=strict",
1603    "ProtectKernelTunables=yes",
1604    "ProtectKernelModules=yes",
1605    "ProtectControlGroups=yes",
1606    "RestrictNamespaces=yes",
1607    "RestrictRealtime=yes",
1608    "RestrictSUIDSGID=yes",
1609    "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1610];
1611
1612// -- launchd -----------------------------------------------------------------
1613
1614/// The `ProcessType` every rendered plist carries.
1615///
1616/// # Why not `Background`
1617///
1618/// launchd applies a job's `ProcessType` to the whole process tree it starts,
1619/// not only to the daemon. Under `Background` every runner the daemon launched
1620/// ran at base priority 4, on efficiency cores with throttled I/O, so each job
1621/// took 4 to 20 times as long as on a classic runner.
1622///
1623/// A child cannot leave it. This is the process type launchd chose at spawn
1624/// (`spawn type = background (5)` in `launchctl print`), not the
1625/// `PRIO_DARWIN_BG` flag `setpriority` controls: measured on macOS 27, a child
1626/// of a `Background` agent reads `getpriority(PRIO_DARWIN_PROCESS) == 0`, and
1627/// `setpriority(PRIO_DARWIN_PROCESS, 0, 0)` returns success and leaves it at
1628/// 4, before `exec` and after it alike. 0.4.31 tried exactly that.
1629///
1630/// The measured alternatives, as the base priority of a runner the agent
1631/// starts: `Background` 4, `Adaptive` 4, `Standard` (the default when the key
1632/// is absent) 20, `Interactive` 31 (the same as a classic runner, and the value
1633/// GitHub's own runner service template uses). The daemon itself mostly waits,
1634/// so it costs nothing to run it at that priority too.
1635pub const LAUNCHD_PROCESS_TYPE: &str = "Interactive";
1636
1637/// Renders the launchd property list for this plan.
1638///
1639/// `KeepAlive` is a dictionary rather than `<true/>` on purpose: the
1640/// requirement is *restart on failure*, and a bare `KeepAlive` also restarts a
1641/// job that exited cleanly, which would turn a deliberate `service stop` into a
1642/// fight with launchd.
1643#[must_use]
1644pub fn launchd_plist(plan: &InstallPlan) -> String {
1645    let identity = plan.identity();
1646    let directories = plan.directories();
1647    let mut out = String::new();
1648    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
1649    out.push_str(
1650        "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
1651         \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
1652    );
1653    out.push_str("<plist version=\"1.0\">\n<dict>\n");
1654    out.push_str(&plist_string("Label", &identity.launchd_label()));
1655
1656    out.push_str("  <key>ProgramArguments</key>\n  <array>\n");
1657    out.push_str(&format!(
1658        "    <string>{}</string>\n",
1659        xml_escape(&plan.binary().to_string_lossy())
1660    ));
1661    for argument in plan.arguments() {
1662        out.push_str(&format!(
1663            "    <string>{}</string>\n",
1664            xml_escape(&argument.to_string_lossy())
1665        ));
1666    }
1667    out.push_str("  </array>\n");
1668
1669    out.push_str("  <key>RunAtLoad</key>\n  <true/>\n");
1670    out.push_str("  <key>KeepAlive</key>\n  <dict>\n");
1671    out.push_str("    <key>SuccessfulExit</key>\n    <false/>\n");
1672    out.push_str("  </dict>\n");
1673    out.push_str(&format!(
1674        "  <key>ThrottleInterval</key>\n  <integer>{}</integer>\n",
1675        plan.restart().delay().as_secs()
1676    ));
1677    out.push_str(&plist_string("ProcessType", LAUNCHD_PROCESS_TYPE));
1678    out.push_str(&plist_string(
1679        "WorkingDirectory",
1680        &directories.state.to_string_lossy(),
1681    ));
1682    out.push_str(&plist_string(
1683        "StandardOutPath",
1684        &directories
1685            .logs
1686            .join("runner-manager.launchd.out.log")
1687            .to_string_lossy(),
1688    ));
1689    out.push_str(&plist_string(
1690        "StandardErrorPath",
1691        &directories
1692            .logs
1693            .join("runner-manager.launchd.err.log")
1694            .to_string_lossy(),
1695    ));
1696
1697    match plan.start_mode() {
1698        StartMode::Boot => {
1699            // A LaunchDaemon runs as root unless told otherwise, and here it
1700            // must: the System Keychain's master key is root-only.
1701            out.push_str(&plist_string(
1702                "UserName",
1703                ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
1704                    .as_str(),
1705            ));
1706            // A daemon has no user session and must not be given one.
1707            out.push_str("  <key>SessionCreate</key>\n  <false/>\n");
1708        }
1709        StartMode::Login => {
1710            // A LaunchAgent already runs as the operator. Naming a `UserName`
1711            // here would be asking launchd for an account switch a login-mode
1712            // registration has no reason to want.
1713        }
1714    }
1715
1716    out.push_str("</dict>\n</plist>\n");
1717    out
1718}
1719
1720/// One `<key>`/`<string>` pair, indented as the rest of the plist is.
1721fn plist_string(key: &str, value: &str) -> String {
1722    format!(
1723        "  <key>{}</key>\n  <string>{}</string>\n",
1724        xml_escape(key),
1725        xml_escape(value)
1726    )
1727}
1728
1729// -- Windows Task Scheduler --------------------------------------------------
1730
1731/// The account a Task Scheduler task runs as.
1732///
1733/// Task Scheduler, unlike the other three managers, needs the principal spelled
1734/// out in the document. Isolating the one impure step keeps
1735/// [`windows_scheduled_task_xml`] a pure function that every CI leg can assert.
1736#[derive(Debug, Clone, PartialEq, Eq)]
1737pub struct TaskPrincipal {
1738    user_id: String,
1739}
1740
1741impl TaskPrincipal {
1742    /// The account running this call, as `DOMAIN\user`.
1743    ///
1744    /// # Errors
1745    ///
1746    /// [`ServiceError::BinaryPath`] is not the right shape here, so this
1747    /// reports [`ServiceError::Control`] naming what the environment failed to
1748    /// say.
1749    pub fn current() -> Result<Self, ServiceError> {
1750        let user = std::env::var("USERNAME")
1751            .ok()
1752            .filter(|value| !value.trim().is_empty());
1753        let Some(user) = user else {
1754            return Err(ServiceError::Control {
1755                operation: "identify the account for",
1756                name: SERVICE_NAME.to_string(),
1757                manager: "Windows Task Scheduler",
1758                detail: "this session reports no %USERNAME%, so there is no principal to \
1759                         register a logon-triggered task for"
1760                    .to_string(),
1761            });
1762        };
1763        let domain = std::env::var("USERDOMAIN")
1764            .ok()
1765            .filter(|value| !value.trim().is_empty());
1766        Ok(Self {
1767            user_id: match domain {
1768                Some(domain) => format!("{domain}\\{user}"),
1769                None => user,
1770            },
1771        })
1772    }
1773
1774    /// A named principal, for tests and for a caller that already knows.
1775    #[must_use]
1776    pub fn named(user_id: impl Into<String>) -> Self {
1777        Self {
1778            user_id: user_id.into(),
1779        }
1780    }
1781
1782    /// `DOMAIN\user`.
1783    #[must_use]
1784    pub fn user_id(&self) -> &str {
1785        &self.user_id
1786    }
1787}
1788
1789/// Renders the Task Scheduler document for a `--start-at login` registration.
1790///
1791/// `RunLevel` is `LeastPrivilege`, which is the whole of Windows' answer to
1792/// item 2 in this domain: the task runs with the operator's ordinary filtered
1793/// token and never with an elevated one, whatever the operator's group
1794/// membership.
1795#[must_use]
1796pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
1797    let identity = plan.identity();
1798    let user = xml_escape(principal.user_id());
1799    let arguments = plan
1800        .arguments()
1801        .iter()
1802        .map(|argument| quote_argument(&argument.to_string_lossy()))
1803        .collect::<Vec<_>>()
1804        .join(" ");
1805    let mut out = String::new();
1806    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
1807    out.push_str(
1808        "<Task version=\"1.4\" \
1809         xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
1810    );
1811    out.push_str("  <RegistrationInfo>\n");
1812    out.push_str(&format!(
1813        "    <Description>{}</Description>\n",
1814        xml_escape(identity.description())
1815    ));
1816    out.push_str(&format!(
1817        "    <URI>\\{}</URI>\n",
1818        xml_escape(identity.name())
1819    ));
1820    out.push_str("  </RegistrationInfo>\n");
1821
1822    out.push_str("  <Triggers>\n    <LogonTrigger>\n");
1823    out.push_str("      <Enabled>true</Enabled>\n");
1824    out.push_str(&format!("      <UserId>{user}</UserId>\n"));
1825    out.push_str("    </LogonTrigger>\n  </Triggers>\n");
1826
1827    out.push_str("  <Principals>\n    <Principal id=\"Author\">\n");
1828    out.push_str(&format!("      <UserId>{user}</UserId>\n"));
1829    out.push_str("      <LogonType>InteractiveToken</LogonType>\n");
1830    out.push_str("      <RunLevel>LeastPrivilege</RunLevel>\n");
1831    out.push_str("    </Principal>\n  </Principals>\n");
1832
1833    out.push_str("  <Settings>\n");
1834    // One agent per host is `d1`'s lock; saying so here means Task Scheduler
1835    // refuses the second start rather than starting a process that then loses
1836    // the race and exits.
1837    out.push_str("    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
1838    out.push_str("    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
1839    out.push_str("    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
1840    out.push_str("    <AllowHardTerminate>true</AllowHardTerminate>\n");
1841    out.push_str("    <StartWhenAvailable>true</StartWhenAvailable>\n");
1842    out.push_str("    <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
1843    out.push_str("    <IdleSettings>\n");
1844    out.push_str("      <StopOnIdleEnd>false</StopOnIdleEnd>\n");
1845    out.push_str("      <RestartOnIdle>false</RestartOnIdle>\n");
1846    out.push_str("    </IdleSettings>\n");
1847    out.push_str("    <AllowStartOnDemand>true</AllowStartOnDemand>\n");
1848    out.push_str("    <Enabled>true</Enabled>\n");
1849    out.push_str("    <Hidden>false</Hidden>\n");
1850    out.push_str("    <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
1851    out.push_str("    <WakeToRun>false</WakeToRun>\n");
1852    // A daemon has no natural end, so any limit here would be a scheduled kill.
1853    out.push_str("    <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
1854    out.push_str("    <Priority>7</Priority>\n");
1855    out.push_str("    <RestartOnFailure>\n");
1856    out.push_str(&format!(
1857        "      <Interval>{}</Interval>\n",
1858        iso8601_minutes(
1859            plan.restart()
1860                .effective_delay(DefinitionKind::WindowsScheduledTask)
1861        )
1862    ));
1863    out.push_str(&format!("      <Count>{START_LIMIT_BURST}</Count>\n"));
1864    out.push_str("    </RestartOnFailure>\n");
1865    out.push_str("  </Settings>\n");
1866
1867    out.push_str("  <Actions Context=\"Author\">\n    <Exec>\n");
1868    out.push_str(&format!(
1869        "      <Command>{}</Command>\n",
1870        xml_escape(&plan.binary().to_string_lossy())
1871    ));
1872    if !arguments.is_empty() {
1873        out.push_str(&format!(
1874            "      <Arguments>{}</Arguments>\n",
1875            xml_escape(&arguments)
1876        ));
1877    }
1878    out.push_str(&format!(
1879        "      <WorkingDirectory>{}</WorkingDirectory>\n",
1880        xml_escape(&plan.directories().state.to_string_lossy())
1881    ));
1882    out.push_str("    </Exec>\n  </Actions>\n");
1883    out.push_str("</Task>\n");
1884    out
1885}
1886
1887/// `PT<n>M`, which is the only shape Task Scheduler accepts for a restart
1888/// interval. See [`RestartPolicy::effective_delay`].
1889fn iso8601_minutes(duration: Duration) -> String {
1890    format!("PT{}M", duration.as_secs() / 60)
1891}
1892
1893/// Escapes the five XML entities. Applied to every value that reaches a plist
1894/// or a task document, because a Windows account name may legitimately contain
1895/// `&` and a path may contain `<`.
1896///
1897/// `pub(crate)` rather than private because [`crate::wsl::task`] renders a
1898/// second kind of Task Scheduler document and must escape it the same way; two
1899/// escapers in one crate is exactly how one of them ends up subtly different.
1900pub(crate) fn xml_escape(value: &str) -> String {
1901    let mut out = String::with_capacity(value.len());
1902    for c in value.chars() {
1903        match c {
1904            '&' => out.push_str("&amp;"),
1905            '<' => out.push_str("&lt;"),
1906            '>' => out.push_str("&gt;"),
1907            '"' => out.push_str("&quot;"),
1908            '\'' => out.push_str("&apos;"),
1909            other => out.push(other),
1910        }
1911    }
1912    out
1913}
1914
1915/// The inverse of [`xml_escape`], for reading a definition back off disk.
1916///
1917/// `&amp;` is replaced last, which is the whole subtlety: replacing it first
1918/// would turn a literal `&amp;amp;` into `&` in two passes instead of `&amp;`
1919/// in one.
1920fn xml_unescape(value: &str) -> String {
1921    value
1922        .replace("&lt;", "<")
1923        .replace("&gt;", ">")
1924        .replace("&quot;", "\"")
1925        .replace("&apos;", "'")
1926        .replace("&amp;", "&")
1927}
1928
1929// -- Windows Service Control Manager -----------------------------------------
1930
1931/// How `CreateServiceW` should be called, in a form that can be reviewed,
1932/// asserted, and printed on any platform.
1933///
1934/// The Windows backend builds its `ServiceInfo` from **this** value rather than
1935/// from the plan, so the descriptor a Linux CI leg asserts and the parameters a
1936/// Windows host registers cannot drift apart.
1937#[derive(Debug, Clone, PartialEq, Eq)]
1938pub struct WindowsServiceSpec {
1939    /// The service name.
1940    pub name: String,
1941    /// What `services.msc` shows.
1942    pub display_name: String,
1943    /// The one-line description.
1944    pub description: String,
1945    /// `true` for `SERVICE_AUTO_START`, `false` for `SERVICE_DEMAND_START`.
1946    pub automatic_start: bool,
1947    /// The account, or `None` for `LocalSystem`, which is what
1948    /// `CreateServiceW` takes a null `lpServiceStartName` to mean.
1949    pub account: Option<String>,
1950    /// The full launch command, quoted.
1951    pub command_line: String,
1952    /// The restart-on-failure policy.
1953    pub restart: RestartPolicy,
1954}
1955
1956/// Derives the Windows service parameters from a plan.
1957#[must_use]
1958pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
1959    WindowsServiceSpec {
1960        name: plan.identity().name().to_string(),
1961        display_name: plan.identity().display_name().to_string(),
1962        description: plan.identity().description().to_string(),
1963        // Only a boot-mode registration is a service at all; a login-mode one
1964        // is a scheduled task. So an automatic start is the only kind here, and
1965        // the field exists because the privileged tests register an on-demand
1966        // fixture rather than one that starts with the test machine.
1967        automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
1968        account: match ServiceAccount::for_definition(
1969            DefinitionKind::WindowsService,
1970            plan.start_mode(),
1971        ) {
1972            // `None` means LocalSystem to `CreateServiceW`, and naming it
1973            // explicitly would be one more string to spell right.
1974            ServiceAccount::LocalSystem => None,
1975            other => Some(other.as_str().to_string()),
1976        },
1977        command_line: plan.command_line(),
1978        restart: plan.restart(),
1979    }
1980}
1981
1982/// Renders the descriptor [`review_least_privilege`] reads and `service status`
1983/// can print.
1984#[must_use]
1985fn windows_service_descriptor(plan: &InstallPlan) -> String {
1986    let spec = windows_service_spec(plan);
1987    let mut out = String::new();
1988    out.push_str("[windows-service]\n");
1989    out.push_str(&format!("Name={}\n", spec.name));
1990    out.push_str(&format!("DisplayName={}\n", spec.display_name));
1991    out.push_str(&format!("Description={}\n", spec.description));
1992    // OWN_PROCESS and never INTERACTIVE_PROCESS: an interactive service would
1993    // put a process this product controls on the operator's desktop, which is
1994    // both deprecated by Windows and more than the requirement asks for.
1995    out.push_str("ServiceType=OWN_PROCESS\n");
1996    out.push_str(&format!(
1997        "StartType={}\n",
1998        if spec.automatic_start {
1999            "AutoStart"
2000        } else {
2001            "OnDemand"
2002        }
2003    ));
2004    out.push_str("ErrorControl=Normal\n");
2005    out.push_str(&format!(
2006        "Account={}\n",
2007        spec.account
2008            .as_deref()
2009            .unwrap_or(ServiceAccount::LocalSystem.as_str())
2010    ));
2011    out.push_str(&format!("CommandLine={}\n", spec.command_line));
2012    out.push_str(&format!(
2013        "FailureActionRestartDelaySecs={}\n",
2014        spec.restart.delay().as_secs()
2015    ));
2016    out.push_str(&format!(
2017        "FailureActionsResetPeriodSecs={}\n",
2018        spec.restart.reset_after().as_secs()
2019    ));
2020    // Without this flag the Service Control Manager applies failure actions
2021    // only to a crash, and a daemon that exits non-zero after failing to reach
2022    // GitHub is not a crash.
2023    out.push_str("FailureActionsOnNonCrashFailures=true\n");
2024    out.push_str(&format!(
2025        "ReadWritePaths={}\n",
2026        plan.directories()
2027            .all()
2028            .iter()
2029            .map(|path| quote_argument(&path.to_string_lossy()))
2030            .collect::<Vec<_>>()
2031            .join(" ")
2032    ));
2033    out
2034}
2035
2036// ---------------------------------------------------------------------------
2037// The least-privilege review
2038// ---------------------------------------------------------------------------
2039
2040/// Whether a finding is about too much authority or too little.
2041#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2042pub enum FindingKind {
2043    /// The definition grants more than `05-infrastructure.md` item 2 asks for.
2044    /// This is what makes a review fail.
2045    Excess,
2046    /// The definition does not grant something the daemon needs, or does not
2047    /// state something the review cannot verify without.
2048    Shortfall,
2049}
2050
2051impl fmt::Display for FindingKind {
2052    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2053        f.write_str(match self {
2054            Self::Excess => "excess",
2055            Self::Shortfall => "shortfall",
2056        })
2057    }
2058}
2059
2060/// One thing the review has to say about a definition.
2061#[derive(Debug, Clone, PartialEq, Eq)]
2062pub struct PrivilegeFinding {
2063    /// Excess or shortfall.
2064    pub kind: FindingKind,
2065    /// The directive, key, or element it is about.
2066    pub subject: String,
2067    /// What is wrong with it, in terms an operator can act on.
2068    pub detail: String,
2069}
2070
2071impl fmt::Display for PrivilegeFinding {
2072    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2073        write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2074    }
2075}
2076
2077/// What a definition actually grants, measured against the requirement.
2078///
2079/// `05-infrastructure.md` item 2 and `07-security.md`'s release gate
2080/// (*"Service account permissions are documented and verified least
2081/// privilege"*) are the same requirement stated twice, and this is the
2082/// verification half. The documentation half is `docs/service-account.md`.
2083///
2084/// The review reads the **rendered text**, not the plan it came from. That is
2085/// deliberate and it is the only version of this check worth having: a review
2086/// that re-derived its expectations from the same values the renderer used
2087/// would agree with the renderer by construction and could never fail. Reading
2088/// the text means a hand-edited unit on a real host is reviewed as it is, and
2089/// means a test can widen a definition and watch this say so.
2090#[derive(Debug, Clone, PartialEq, Eq)]
2091pub struct PrivilegeReview {
2092    kind: DefinitionKind,
2093    account: ServiceAccount,
2094    controls: Vec<String>,
2095    findings: Vec<PrivilegeFinding>,
2096}
2097
2098impl PrivilegeReview {
2099    /// Whether the definition grants nothing beyond the requirement.
2100    #[must_use]
2101    pub fn is_least_privilege(&self) -> bool {
2102        !self
2103            .findings
2104            .iter()
2105            .any(|finding| finding.kind == FindingKind::Excess)
2106    }
2107
2108    /// Everything the review found, excesses and shortfalls together.
2109    #[must_use]
2110    pub fn findings(&self) -> &[PrivilegeFinding] {
2111        &self.findings
2112    }
2113
2114    /// Only the excesses — the findings that make [`Self::is_least_privilege`]
2115    /// false.
2116    #[must_use]
2117    pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2118        self.findings
2119            .iter()
2120            .filter(|finding| finding.kind == FindingKind::Excess)
2121            .collect()
2122    }
2123
2124    /// The controls the definition was confirmed to carry.
2125    ///
2126    /// Present so that a passing review says *what it checked* rather than only
2127    /// that it passed. A check that reports nothing when it succeeds is
2128    /// indistinguishable from a check that did not run.
2129    #[must_use]
2130    pub fn controls(&self) -> &[String] {
2131        &self.controls
2132    }
2133
2134    /// The account the registration runs under, and why it is the minimum.
2135    #[must_use]
2136    pub const fn account(&self) -> &ServiceAccount {
2137        &self.account
2138    }
2139
2140    /// Which platform's definition was reviewed.
2141    #[must_use]
2142    pub const fn kind(&self) -> DefinitionKind {
2143        self.kind
2144    }
2145}
2146
2147impl fmt::Display for PrivilegeReview {
2148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2149        writeln!(
2150            f,
2151            "{} runs as {} ({})",
2152            self.kind,
2153            self.account,
2154            self.account.justification()
2155        )?;
2156        for control in &self.controls {
2157            writeln!(f, "  confirmed  {control}")?;
2158        }
2159        for finding in &self.findings {
2160            writeln!(f, "  {finding}")?;
2161        }
2162        if self.is_least_privilege() {
2163            write!(f, "  verdict    least privilege")
2164        } else {
2165            write!(
2166                f,
2167                "  verdict    NOT least privilege: {} excess(es)",
2168                self.excesses().len()
2169            )
2170        }
2171    }
2172}
2173
2174/// Reads a definition back and reports what it grants.
2175///
2176/// `plan` supplies only the two things the text cannot: the four directories
2177/// the registration is *allowed* to write, and the start mode, which decides
2178/// which account is the minimum.
2179#[must_use]
2180pub fn review_least_privilege(
2181    definition: &ServiceDefinition,
2182    plan: &InstallPlan,
2183) -> PrivilegeReview {
2184    let mut controls = Vec::new();
2185    let mut findings = Vec::new();
2186    match definition.kind() {
2187        DefinitionKind::SystemdUnit => {
2188            review_systemd(definition.text(), plan, &mut controls, &mut findings);
2189        }
2190        DefinitionKind::LaunchdPlist => {
2191            review_launchd(definition.text(), plan, &mut controls, &mut findings);
2192        }
2193        DefinitionKind::WindowsScheduledTask => {
2194            review_scheduled_task(definition.text(), &mut controls, &mut findings);
2195        }
2196        DefinitionKind::WindowsService => {
2197            review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2198        }
2199    }
2200    PrivilegeReview {
2201        kind: definition.kind(),
2202        // The definition's account, not the plan's: a Linux CI leg reviewing the
2203        // Windows descriptor must report `LocalSystem`, which is the account
2204        // that descriptor registers, and not `root`, which is the account this
2205        // leg's own host would use.
2206        account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2207        controls,
2208        findings,
2209    }
2210}
2211
2212/// Every writable path a definition may name, as the strings it names them by.
2213fn permitted_paths(kind: DefinitionKind, plan: &InstallPlan) -> Vec<String> {
2214    let mut permitted = plan
2215        .directories()
2216        .all()
2217        .iter()
2218        .map(|path| path.to_string_lossy().into_owned())
2219        .collect::<Vec<_>>();
2220    if kind == DefinitionKind::SystemdUnit
2221        && let Some(secret_directory) = plan.secret_guard().and_then(Path::parent)
2222    {
2223        permitted.push(secret_directory.to_string_lossy().into_owned());
2224    }
2225    permitted
2226}
2227
2228/// Whether two path spellings name the same place on **this host's**
2229/// filesystem. Case-insensitive on Windows, exact elsewhere.
2230///
2231/// For a rendered definition use [`same_path_for`] instead: which comparison is
2232/// right there follows the platform the definition is *for*, and every leg of
2233/// the CI matrix reviews all four.
2234fn same_path_text(left: &str, right: &str) -> bool {
2235    if cfg!(windows) {
2236        left.eq_ignore_ascii_case(right)
2237    } else {
2238        left == right
2239    }
2240}
2241
2242/// Whether two path spellings name the same place on the platform a given
2243/// definition targets.
2244fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
2245    match kind {
2246        DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
2247            left.eq_ignore_ascii_case(right)
2248        }
2249        DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
2250    }
2251}
2252
2253/// Checks a `ReadWritePaths`-style list against the permitted directories.
2254fn review_writable_paths(
2255    kind: DefinitionKind,
2256    subject: &str,
2257    listed: &[String],
2258    plan: &InstallPlan,
2259    controls: &mut Vec<String>,
2260    findings: &mut Vec<PrivilegeFinding>,
2261) {
2262    let permitted = permitted_paths(kind, plan);
2263    for entry in listed {
2264        if !permitted
2265            .iter()
2266            .any(|allowed| same_path_for(kind, allowed, entry))
2267        {
2268            findings.push(PrivilegeFinding {
2269                kind: FindingKind::Excess,
2270                subject: subject.to_string(),
2271                detail: format!(
2272                    "{entry} is writable but is not one of this registration's required paths"
2273                ),
2274            });
2275        }
2276    }
2277    for allowed in &permitted {
2278        if !listed
2279            .iter()
2280            .any(|entry| same_path_for(kind, allowed, entry))
2281        {
2282            findings.push(PrivilegeFinding {
2283                kind: FindingKind::Shortfall,
2284                subject: subject.to_string(),
2285                detail: format!(
2286                    "{allowed} is one of this registration's directories but is not writable, \
2287                     so the daemon cannot use it"
2288                ),
2289            });
2290        }
2291    }
2292    if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
2293        controls.push(format!(
2294            "{subject} names exactly the required application-data and credential paths"
2295        ));
2296    }
2297}
2298
2299/// The one inbound-surface rule, applied to whichever platform names it.
2300///
2301/// `07-security.md` handling rule 2: *"The product exposes no inbound HTTP,
2302/// socket, or RPC surface anywhere, in any command."* A definition that asks the
2303/// service manager to open a socket or publish a Mach service on the daemon's
2304/// behalf would create exactly that surface without a line of product code
2305/// changing, which is why it is checked here and not only in review.
2306fn review_inbound_surface(
2307    text: &str,
2308    markers: &[(&str, &str)],
2309    controls: &mut Vec<String>,
2310    findings: &mut Vec<PrivilegeFinding>,
2311) {
2312    let mut clean = true;
2313    for (marker, detail) in markers {
2314        if text.contains(marker) {
2315            clean = false;
2316            findings.push(PrivilegeFinding {
2317                kind: FindingKind::Excess,
2318                subject: (*marker).to_string(),
2319                detail: (*detail).to_string(),
2320            });
2321        }
2322    }
2323    if clean {
2324        controls.push(
2325            "no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
2326        );
2327    }
2328}
2329
2330fn review_systemd(
2331    text: &str,
2332    plan: &InstallPlan,
2333    controls: &mut Vec<String>,
2334    findings: &mut Vec<PrivilegeFinding>,
2335) {
2336    let directives = ini_directives(text, "Service");
2337    for expected in SYSTEMD_HARDENING {
2338        let (key, value) = expected
2339            .split_once('=')
2340            .expect("every hardening directive is written as key=value");
2341        match directives.get(key) {
2342            Some(actual) if actual == value => controls.push((*expected).to_string()),
2343            Some(actual) => findings.push(PrivilegeFinding {
2344                kind: FindingKind::Excess,
2345                subject: key.to_string(),
2346                detail: format!(
2347                    "is `{actual}`, not `{value}`, so the unit keeps authority the \
2348                                 requirement does not ask for"
2349                ),
2350            }),
2351            None => findings.push(PrivilegeFinding {
2352                kind: FindingKind::Excess,
2353                subject: key.to_string(),
2354                detail: format!(
2355                    "is absent, so the unit inherits systemd's default rather than `{value}`"
2356                ),
2357            }),
2358        }
2359    }
2360
2361    match directives.get("ReadWritePaths") {
2362        Some(value) => {
2363            let listed = split_quoted(value);
2364            review_writable_paths(
2365                DefinitionKind::SystemdUnit,
2366                "ReadWritePaths",
2367                &listed,
2368                plan,
2369                controls,
2370                findings,
2371            );
2372        }
2373        None => findings.push(PrivilegeFinding {
2374            kind: FindingKind::Shortfall,
2375            subject: "ReadWritePaths".to_string(),
2376            detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
2377                .to_string(),
2378        }),
2379    }
2380
2381    // `User=` on a system unit is an escalation only in the other direction, so
2382    // what is checked is the pair systemd actually treats as privileged.
2383    if directives.contains_key("PrivateUsers")
2384        && directives.get("PrivateUsers") == Some(&"no".to_string())
2385    {
2386        findings.push(PrivilegeFinding {
2387            kind: FindingKind::Excess,
2388            subject: "PrivateUsers".to_string(),
2389            detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
2390                .to_string(),
2391        });
2392    }
2393
2394    review_inbound_surface(
2395        text,
2396        &[
2397            (
2398                "ListenStream=",
2399                "asks systemd to open a listening socket for this service, which \
2400                 07-security.md rule 2 forbids the product to have",
2401            ),
2402            (
2403                "ListenDatagram=",
2404                "asks systemd to open a listening socket for this service, which \
2405                 07-security.md rule 2 forbids the product to have",
2406            ),
2407        ],
2408        controls,
2409        findings,
2410    );
2411}
2412
2413fn review_launchd(
2414    text: &str,
2415    plan: &InstallPlan,
2416    controls: &mut Vec<String>,
2417    findings: &mut Vec<PrivilegeFinding>,
2418) {
2419    // A scheduling class is not an authority, so no value here is an excess.
2420    // Anything but `Interactive` is a shortfall: launchd applies it to every
2421    // runner the daemon starts. See `LAUNCHD_PROCESS_TYPE`.
2422    match plist_string_value(text, "ProcessType") {
2423        Some(value) if value == LAUNCHD_PROCESS_TYPE => {
2424            controls.push(format!("ProcessType={LAUNCHD_PROCESS_TYPE}"));
2425        }
2426        found => findings.push(PrivilegeFinding {
2427            kind: FindingKind::Shortfall,
2428            subject: "ProcessType".to_string(),
2429            detail: format!(
2430                "is {}, and launchd applies it to every runner the daemon starts, so their jobs \
2431                 run below normal priority. The daemon rewrites it to `{LAUNCHD_PROCESS_TYPE}` \
2432                 when it next starts with no runner; `service status` says how to restart it",
2433                found.map_or_else(
2434                    || "absent (launchd's `Standard`)".to_string(),
2435                    |value| format!("`{value}`")
2436                )
2437            ),
2438        }),
2439    }
2440
2441    match plan.start_mode() {
2442        StartMode::Boot => {
2443            if plist_bool_value(text, "SessionCreate") == Some(true) {
2444                findings.push(PrivilegeFinding {
2445                    kind: FindingKind::Excess,
2446                    subject: "SessionCreate".to_string(),
2447                    detail: "asks launchd to create a security session for a job that runs \
2448                             outside every login session and has no use for one"
2449                        .to_string(),
2450                });
2451            } else {
2452                controls.push("SessionCreate is not requested".to_string());
2453            }
2454            match plist_string_value(text, "UserName").as_deref() {
2455                Some("root") => {
2456                    controls.push("UserName=root, stated rather than inherited".to_string())
2457                }
2458                Some(other) => findings.push(PrivilegeFinding {
2459                    kind: FindingKind::Shortfall,
2460                    subject: "UserName".to_string(),
2461                    detail: format!(
2462                        "is `{other}`, which cannot unlock the System Keychain: \
2463                         /var/db/SystemKey is root-only, so the daemon would start and then \
2464                         find no credential"
2465                    ),
2466                }),
2467                None => findings.push(PrivilegeFinding {
2468                    kind: FindingKind::Shortfall,
2469                    subject: "UserName".to_string(),
2470                    detail: "is absent, so the account is launchd's implicit default and this \
2471                             review cannot confirm it"
2472                        .to_string(),
2473                }),
2474            }
2475        }
2476        StartMode::Login => {
2477            if let Some(named) = plist_string_value(text, "UserName") {
2478                findings.push(PrivilegeFinding {
2479                    kind: FindingKind::Excess,
2480                    subject: "UserName".to_string(),
2481                    detail: format!(
2482                        "names `{named}` in a LaunchAgent, which already runs as the operator; \
2483                         naming an account here asks launchd for a switch a login-mode \
2484                         registration has no reason to want"
2485                    ),
2486                });
2487            } else {
2488                controls
2489                    .push("no UserName: the agent runs as the operator and no other".to_string());
2490            }
2491        }
2492    }
2493
2494    review_inbound_surface(
2495        text,
2496        &[
2497            (
2498                "<key>Sockets</key>",
2499                "asks launchd to open a socket for this job, which 07-security.md rule 2 \
2500                 forbids the product to have",
2501            ),
2502            (
2503                "<key>MachServices</key>",
2504                "publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
2505                 forbids the product to have",
2506            ),
2507        ],
2508        controls,
2509        findings,
2510    );
2511}
2512
2513fn review_scheduled_task(
2514    text: &str,
2515    controls: &mut Vec<String>,
2516    findings: &mut Vec<PrivilegeFinding>,
2517) {
2518    match xml_value(text, "RunLevel").as_deref() {
2519        Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
2520        Some(other) => findings.push(PrivilegeFinding {
2521            kind: FindingKind::Excess,
2522            subject: "RunLevel".to_string(),
2523            detail: format!(
2524                "is `{other}`, so the task runs with an elevated token whenever the operator is \
2525                 an administrator"
2526            ),
2527        }),
2528        None => findings.push(PrivilegeFinding {
2529            kind: FindingKind::Excess,
2530            subject: "RunLevel".to_string(),
2531            detail: "is absent, so Task Scheduler decides the token rather than the definition"
2532                .to_string(),
2533        }),
2534    }
2535
2536    match xml_value(text, "LogonType").as_deref() {
2537        Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
2538        Some(other) => findings.push(PrivilegeFinding {
2539            kind: FindingKind::Excess,
2540            subject: "LogonType".to_string(),
2541            detail: format!(
2542                "is `{other}`, which asks Windows to store or synthesise a credential for this \
2543                 task; an interactive token needs neither"
2544            ),
2545        }),
2546        None => findings.push(PrivilegeFinding {
2547            kind: FindingKind::Shortfall,
2548            subject: "LogonType".to_string(),
2549            detail: "is absent, so this review cannot confirm that no credential is stored"
2550                .to_string(),
2551        }),
2552    }
2553}
2554
2555fn review_windows_service(
2556    text: &str,
2557    plan: &InstallPlan,
2558    controls: &mut Vec<String>,
2559    findings: &mut Vec<PrivilegeFinding>,
2560) {
2561    let directives = ini_directives(text, "windows-service");
2562
2563    match directives.get("ServiceType").map(String::as_str) {
2564        Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
2565        Some(other) => findings.push(PrivilegeFinding {
2566            kind: FindingKind::Excess,
2567            subject: "ServiceType".to_string(),
2568            detail: format!(
2569                "is `{other}`; an interactive or shared-process service reaches further than a \
2570                 daemon that only talks to GitHub over HTTPS"
2571            ),
2572        }),
2573        None => findings.push(PrivilegeFinding {
2574            kind: FindingKind::Shortfall,
2575            subject: "ServiceType".to_string(),
2576            detail: "is absent, so this review cannot confirm the service is not interactive"
2577                .to_string(),
2578        }),
2579    }
2580
2581    // The account is not a free choice on Windows: `d2`'s DACL decides it. What
2582    // the review can check is that the definition names the one account that
2583    // DACL admits, and no broader one.
2584    match directives.get("Account").map(String::as_str) {
2585        Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
2586            controls.push(format!(
2587                "Account={account}: the only stock account the machine-scoped store's DACL \
2588                 (SY, BA, OW) admits"
2589            ));
2590        }
2591        Some(other) => findings.push(PrivilegeFinding {
2592            kind: FindingKind::Shortfall,
2593            subject: "Account".to_string(),
2594            detail: format!(
2595                "is `{other}`, which the machine-scoped store's DACL does not name, so the \
2596                 daemon would start and then find no credential. Widening that DACL is not this \
2597                 registration's to do: an ACE reaching `{other}` would also reach every other \
2598                 service running under it"
2599            ),
2600        }),
2601        None => findings.push(PrivilegeFinding {
2602            kind: FindingKind::Shortfall,
2603            subject: "Account".to_string(),
2604            detail: "is absent, so this review cannot confirm which account was registered"
2605                .to_string(),
2606        }),
2607    }
2608
2609    match directives.get("ReadWritePaths") {
2610        Some(value) => {
2611            let listed = split_quoted(value);
2612            review_writable_paths(
2613                DefinitionKind::WindowsService,
2614                "ReadWritePaths",
2615                &listed,
2616                plan,
2617                controls,
2618                findings,
2619            );
2620        }
2621        None => findings.push(PrivilegeFinding {
2622            kind: FindingKind::Shortfall,
2623            subject: "ReadWritePaths".to_string(),
2624            detail: "is absent, so the directories the service was installed against are not \
2625                     recorded"
2626                .to_string(),
2627        }),
2628    }
2629}
2630
2631// -- parsing helpers ---------------------------------------------------------
2632
2633/// Reads `key=value` lines out of one `[section]` of an INI-shaped document.
2634///
2635/// systemd's unit format and this module's Windows descriptor are both this
2636/// shape. A repeated key takes its last value, which is systemd's own rule for
2637/// every directive here.
2638fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
2639    let mut out = BTreeMap::new();
2640    let mut inside = false;
2641    for line in text.lines() {
2642        let line = line.trim();
2643        if line.starts_with('[') && line.ends_with(']') {
2644            inside = &line[1..line.len() - 1] == section;
2645            continue;
2646        }
2647        if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2648            continue;
2649        }
2650        if let Some((key, value)) = line.split_once('=') {
2651            out.insert(key.trim().to_string(), value.trim().to_string());
2652        }
2653    }
2654    out
2655}
2656
2657/// Splits a whitespace-separated list that may contain arguments quoted by
2658/// [`quote_argument`].
2659fn split_quoted(value: &str) -> Vec<String> {
2660    let mut out = Vec::new();
2661    let mut rest = value.trim();
2662    while !rest.is_empty() {
2663        if rest.starts_with('"') {
2664            // `executable_from_command_line` already implements Windows' quoting
2665            // rules; reuse it rather than write a second, subtly different one.
2666            if let Some(parsed) = executable_from_command_line(rest) {
2667                out.push(parsed.to_string_lossy().into_owned());
2668            }
2669            // Advance past the closing quote.
2670            let mut depth = 0usize;
2671            let mut end = rest.len();
2672            for (index, c) in rest.char_indices() {
2673                match c {
2674                    '\\' => depth += 1,
2675                    '"' => {
2676                        if depth.is_multiple_of(2) && index > 0 {
2677                            end = index + 1;
2678                            break;
2679                        }
2680                        depth = 0;
2681                    }
2682                    _ => depth = 0,
2683                }
2684            }
2685            rest = rest[end..].trim_start();
2686        } else {
2687            let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
2688            out.push(rest[..end].to_string());
2689            rest = rest[end..].trim_start();
2690        }
2691    }
2692    out
2693}
2694
2695/// The text between the **first** `<tag>` and its `</tag>`, unescaped, for the
2696/// small, known documents this module renders. Not a general XML parser and
2697/// does not pretend to be one.
2698///
2699/// "First" matters for a document that repeats a tag: a Task Scheduler task has
2700/// an `<Enabled>` in its trigger *and* one in its settings, so a caller after
2701/// the second one passes the slice that starts at `<Settings>`.
2702///
2703/// `pub(crate)` rather than private because [`crate::wsl::task`] reads back a
2704/// second kind of Task Scheduler document; one reader for both is the same
2705/// argument [`xml_escape`] makes for one escaper.
2706pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
2707    let open = format!("<{tag}>");
2708    let close = format!("</{tag}>");
2709    let start = text.find(&open)? + open.len();
2710    let end = text[start..].find(&close)? + start;
2711    Some(xml_unescape(text[start..end].trim()))
2712}
2713
2714/// Whether Task Scheduler will launch a login task automatically.
2715///
2716/// Its `/Query /XML` output normalises away values that equal the schema
2717/// default. In particular, an enabled task commonly has no `<Enabled>` node at
2718/// all, so only an explicit `false` means disabled.
2719#[cfg(any(windows, test))]
2720fn windows_login_task_starts_automatically(document: &str) -> bool {
2721    document.contains("<LogonTrigger>")
2722        && xml_value(document, "Enabled").as_deref() != Some("false")
2723}
2724
2725/// The `<string>` that follows `<key>key</key>` in a property list.
2726fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
2727    let marker = format!("<key>{key}</key>");
2728    let start = text.find(&marker)? + marker.len();
2729    Some(text[start..].trim_start())
2730}
2731
2732pub fn plist_string_value(text: &str, key: &str) -> Option<String> {
2733    let rest = plist_value_after_key(text, key)?;
2734    if !rest.starts_with("<string>") {
2735        return None;
2736    }
2737    xml_value(rest, "string")
2738}
2739
2740fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
2741    let rest = plist_value_after_key(text, key)?;
2742    if rest.starts_with("<true/>") {
2743        Some(true)
2744    } else if rest.starts_with("<false/>") {
2745        Some(false)
2746    } else {
2747        None
2748    }
2749}
2750
2751// ---------------------------------------------------------------------------
2752// The install record
2753// ---------------------------------------------------------------------------
2754
2755/// The version of [`InstallRecord`] on disk.
2756///
2757/// Bumped when a record written by an older version can no longer be read.
2758/// `service status` reports an unreadable record as a problem with a remedy
2759/// rather than failing, because the remedy — uninstall and install again —
2760/// touches no configuration, no secret and no cache.
2761pub const RECORD_SCHEMA_VERSION: u32 = 1;
2762
2763/// What `service install` wrote down, and what `service status` reads back.
2764///
2765/// This is the *record* half of `05-infrastructure.md` item 6. The service
2766/// manager also knows the binary path, and the two are compared: a registration
2767/// whose command line no longer matches the record is a registration something
2768/// else has edited, and saying so is more useful than picking one of them to
2769/// believe.
2770///
2771/// It holds no credential. The nearest thing is the *location* of the secret
2772/// store, which `d2` publishes for `host show` to print, and even that is
2773/// derived rather than stored.
2774#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2775pub struct InstallRecord {
2776    /// [`RECORD_SCHEMA_VERSION`] at the time of writing.
2777    pub schema_version: u32,
2778    /// What the operating system calls the registration.
2779    pub service_name: String,
2780    /// Which service manager holds it.
2781    pub manager: String,
2782    /// Boot or login.
2783    pub start_mode: StartMode,
2784    /// The account it runs under.
2785    pub account: ServiceAccount,
2786    /// **The resolved absolute path of the binary at install time.** Item 6.
2787    pub binary: PathBuf,
2788    /// Where [`Self::binary`] was copied from, when it is a copy.
2789    ///
2790    /// `#[serde(default)]` rather than a schema bump: a record written before
2791    /// this existed is still readable, and reads as `None`. That is not a
2792    /// silent downgrade — `None` means the registration names a package
2793    /// manager's own file, which is the layout that cannot be upgraded while
2794    /// the service runs, and `service status` says so by name.
2795    #[serde(default)]
2796    pub source_binary: Option<PathBuf>,
2797    /// The arguments it was registered with.
2798    pub arguments: Vec<String>,
2799    /// The restart-on-failure delay.
2800    pub restart_delay_secs: u64,
2801    /// How long it must run before the failure count resets.
2802    pub restart_reset_secs: u64,
2803    /// The rotating diagnostic log's stem. Item 4.
2804    pub log_file: PathBuf,
2805    /// Whether the manager was asked not to start this by itself. Always
2806    /// `false` for a registration an operator made; see
2807    /// [`InstallRequest::started_on_demand`].
2808    ///
2809    /// `#[serde(default)]` so that a record missing the field is rejected by
2810    /// the schema check below, with its remedy, rather than by a parse error
2811    /// that names a field an operator has never heard of.
2812    #[serde(default)]
2813    pub starts_on_demand: bool,
2814    /// Where the platform's definition file was written, when there is one.
2815    pub definition_path: Option<PathBuf>,
2816    /// When the registration was made.
2817    pub installed_at: DateTime<Utc>,
2818    /// Which build of the product made it.
2819    pub installed_by_version: String,
2820    // A TOML table has to follow every scalar at its level, so this field is
2821    // last by necessity rather than by taste.
2822    /// The four directories the registration was installed against. Item 2's
2823    /// *"configured cache and runtime directories"*.
2824    pub directories: ServiceDirectories,
2825}
2826
2827impl InstallRecord {
2828    /// Where the record lives, given this host's directories.
2829    #[must_use]
2830    pub fn path(paths: &AppPaths) -> PathBuf {
2831        paths.config_dir().join(RECORD_FILE)
2832    }
2833
2834    /// Builds the record for a plan that has just been applied.
2835    #[must_use]
2836    pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
2837        Self {
2838            schema_version: RECORD_SCHEMA_VERSION,
2839            service_name: plan.identity().name().to_string(),
2840            manager: definition.kind().manager().to_string(),
2841            start_mode: plan.start_mode(),
2842            account: plan.account().clone(),
2843            binary: plan.binary().to_path_buf(),
2844            arguments: plan
2845                .arguments()
2846                .iter()
2847                .map(|argument| argument.to_string_lossy().into_owned())
2848                .collect(),
2849            restart_delay_secs: plan.restart().delay().as_secs(),
2850            restart_reset_secs: plan.restart().reset_after().as_secs(),
2851            starts_on_demand: plan.is_on_demand(),
2852            source_binary: plan.source_binary().map(Path::to_path_buf),
2853            log_file: plan.directories().log_file(),
2854            definition_path: definition.install_path().map(Path::to_path_buf),
2855            installed_at: at,
2856            installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
2857            directories: plan.directories().clone(),
2858        }
2859    }
2860
2861    /// The plan this record describes, as this build would register it.
2862    ///
2863    /// The directories, binary, arguments and restart policy are the recorded
2864    /// ones; everything else -- hardening, the secret store's guard -- is what
2865    /// this build renders. That difference is the point: it is how a
2866    /// registration written by an older build is compared against, and
2867    /// repaired to, the current definition.
2868    #[must_use]
2869    pub fn plan(&self, identity: ServiceIdentity) -> InstallPlan {
2870        let plan = InstallPlan::unchecked(
2871            identity,
2872            self.start_mode,
2873            self.binary.clone(),
2874            self.directories.clone(),
2875        )
2876        .with_arguments(self.arguments.clone())
2877        .with_restart(self.restart());
2878        let plan = if self.starts_on_demand {
2879            plan.started_on_demand()
2880        } else {
2881            plan
2882        };
2883        match crate::secrets::PlatformSecretStore::for_start_mode(self.start_mode) {
2884            Ok(store) => plan.with_secret_guard(store.guard()),
2885            Err(_) => plan,
2886        }
2887    }
2888
2889    /// The restart policy this record describes, or [`RestartPolicy::default`]
2890    /// when the recorded numbers are outside the supported range — which can
2891    /// only happen to a record something else has edited.
2892    #[must_use]
2893    pub fn restart(&self) -> RestartPolicy {
2894        RestartPolicy::new(
2895            Duration::from_secs(self.restart_delay_secs),
2896            Duration::from_secs(self.restart_reset_secs),
2897        )
2898        .unwrap_or_default()
2899    }
2900
2901    /// Reads the record, or reports that there is none.
2902    ///
2903    /// `Ok(None)` means no registration was made by this product on this host.
2904    ///
2905    /// # Errors
2906    ///
2907    /// [`ServiceError::Record`] when the file exists and cannot be read,
2908    /// [`ServiceError::RecordNotPermitted`] when it exists and this account may
2909    /// not read it, and [`ServiceError::RecordUnreadable`] when it is not a
2910    /// record this version understands.
2911    pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
2912        let path = Self::path(paths);
2913        let text = match std::fs::read_to_string(&path) {
2914            Ok(text) => text,
2915            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2916            // Told apart from every other read failure because `status` carries
2917            // on through this one alone. See `ServiceError::RecordNotPermitted`.
2918            Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
2919                return Err(ServiceError::RecordNotPermitted {
2920                    path,
2921                    detail: error.to_string(),
2922                });
2923            }
2924            Err(error) => {
2925                return Err(ServiceError::Record {
2926                    operation: "read",
2927                    path,
2928                    detail: error.to_string(),
2929                });
2930            }
2931        };
2932        let record: Self =
2933            toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
2934                path: path.clone(),
2935                detail: error.to_string(),
2936            })?;
2937        if record.schema_version != RECORD_SCHEMA_VERSION {
2938            return Err(ServiceError::RecordUnreadable {
2939                path,
2940                detail: format!(
2941                    "it declares schema version {} and this build reads version {}",
2942                    record.schema_version, RECORD_SCHEMA_VERSION
2943                ),
2944            });
2945        }
2946        Ok(Some(record))
2947    }
2948
2949    /// Writes the record, replacing whatever was there.
2950    ///
2951    /// # Errors
2952    ///
2953    /// [`ServiceError::Record`].
2954    pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
2955        use std::io::Write as _;
2956
2957        let path = Self::path(paths);
2958        let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
2959            operation: "encode",
2960            path: path.clone(),
2961            detail: error.to_string(),
2962        })?;
2963        if let Some(parent) = path.parent() {
2964            std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
2965                operation: "write",
2966                path: path.clone(),
2967                detail: error.to_string(),
2968            })?;
2969        }
2970        let parent = path.parent().ok_or_else(|| ServiceError::Record {
2971            operation: "write",
2972            path: path.clone(),
2973            detail: "the record path has no parent directory".to_string(),
2974        })?;
2975        let mut temporary =
2976            tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
2977                operation: "write",
2978                path: path.clone(),
2979                detail: error.to_string(),
2980            })?;
2981        temporary
2982            .write_all(text.as_bytes())
2983            .and_then(|()| temporary.as_file().sync_all())
2984            .map_err(|error| ServiceError::Record {
2985                operation: "write",
2986                path: path.clone(),
2987                detail: error.to_string(),
2988            })?;
2989        // ------------------------------------------------------------------
2990        // THE RECORD IS READABLE BY THE ACCOUNT WHOSE DIRECTORY IT IS IN.
2991        // ------------------------------------------------------------------
2992        // `NamedTempFile` creates at `0600`, which is right for a temporary
2993        // file and wrong for this one. A boot-mode `service install` runs under
2994        // `sudo`, so `0600` made the record `root`-owned and unreadable by the
2995        // operator — and `service status`, which asks no privilege of anybody,
2996        // failed on their own host with `Permission denied` and a remedy that
2997        // named the command that had just failed.
2998        //
2999        // `0644` does not widen anything: `RECORD_FILE`'s own documentation is
3000        // that this is *"non-secret TOML"* holding no credential, and
3001        // `AppPaths::create_all` puts it in a `0700` directory, so no other
3002        // local account can reach it whatever its own mode says. It is the same
3003        // arrangement the rotating diagnostics already have.
3004        #[cfg(unix)]
3005        {
3006            use std::os::unix::fs::PermissionsExt as _;
3007
3008            temporary
3009                .as_file()
3010                .set_permissions(std::fs::Permissions::from_mode(0o644))
3011                .map_err(|error| ServiceError::Record {
3012                    operation: "write",
3013                    path: path.clone(),
3014                    detail: error.to_string(),
3015                })?;
3016        }
3017        temporary
3018            .persist(&path)
3019            .map(|_| ())
3020            .map_err(|error| ServiceError::Record {
3021                operation: "write",
3022                path,
3023                detail: error.error.to_string(),
3024            })
3025    }
3026
3027    /// Removes the record. Returns whether there was one.
3028    ///
3029    /// # Errors
3030    ///
3031    /// [`ServiceError::Record`].
3032    pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
3033        let path = Self::path(paths);
3034        match std::fs::remove_file(&path) {
3035            Ok(()) => Ok(true),
3036            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
3037            Err(error) => Err(ServiceError::Record {
3038                operation: "remove",
3039                path,
3040                detail: error.to_string(),
3041            }),
3042        }
3043    }
3044}
3045
3046// ---------------------------------------------------------------------------
3047// The last successful GitHub contact
3048// ---------------------------------------------------------------------------
3049
3050/// The heartbeat file's own schema version.
3051const CONTACT_SCHEMA_VERSION: u32 = 1;
3052
3053#[derive(Debug, Clone, Serialize, Deserialize)]
3054struct ContactRecord {
3055    schema_version: u32,
3056    last_success: DateTime<Utc>,
3057}
3058
3059/// Records that GitHub was reached successfully, for `service status` to
3060/// report.
3061///
3062/// # The contract, for `f3` and for the agent
3063///
3064/// Journey 5 step 4 requires `service status` to report *"the last successful
3065/// GitHub contact"*, and `service status` runs in the operator's terminal while
3066/// the daemon runs in a service. They share no memory, so the fact has to be on
3067/// disk, and this is the file. **The daemon calls this after each successful
3068/// GitHub call; nothing else writes it.**
3069///
3070/// It is a single timestamp and deliberately not a log: an operator asking this
3071/// question wants to know whether the agent is alive and reaching GitHub *now*,
3072/// and a value that is minutes old answers it. Writing it is a whole-file
3073/// replace through a temporary in the same directory, so a status command can
3074/// never read a half-written timestamp.
3075///
3076/// # Errors
3077///
3078/// [`ServiceError::Record`] when `state/` cannot be written.
3079pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3080    write_state_record(
3081        &contact_path(paths),
3082        &ContactRecord {
3083            schema_version: CONTACT_SCHEMA_VERSION,
3084            last_success: at,
3085        },
3086    )
3087}
3088
3089/// Reads the last successful GitHub contact, or reports that none was recorded.
3090///
3091/// `Ok(None)` is the honest answer on a host whose agent has never run, and it
3092/// is what `service status` prints rather than a zero timestamp.
3093///
3094/// # Errors
3095///
3096/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3097/// A malformed heartbeat is reported rather than treated as absence: absence
3098/// means *"the agent has never reached GitHub"*, and reporting a parse failure
3099/// as that would be a wrong answer to the question Journey 5 asks.
3100pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3101    Ok(read_state_record::<ContactRecord>(&contact_path(paths))?.map(|record| record.last_success))
3102}
3103
3104/// Where the heartbeat lives.
3105#[must_use]
3106pub fn contact_path(paths: &AppPaths) -> PathBuf {
3107    paths.state_dir().join(CONTACT_FILE)
3108}
3109
3110/// Writes a small TOML record under `state/`, through a temporary in the same
3111/// directory and a rename, so a status command never reads half of one.
3112fn write_state_record(path: &Path, record: &impl Serialize) -> Result<(), ServiceError> {
3113    let failed = |detail: String| ServiceError::Record {
3114        operation: "write",
3115        path: path.to_path_buf(),
3116        detail,
3117    };
3118    let text = toml::to_string_pretty(record).map_err(|error| failed(error.to_string()))?;
3119    let directory = path.parent().unwrap_or_else(|| Path::new("."));
3120    std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3121    let temporary = path.with_extension("toml.new");
3122    std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3123    std::fs::rename(&temporary, path).map_err(|error| failed(error.to_string()))
3124}
3125
3126/// Reads a record [`write_state_record`] wrote. `Ok(None)` when there is none;
3127/// one that exists and does not parse is an error, never absence.
3128fn read_state_record<T: serde::de::DeserializeOwned>(
3129    path: &Path,
3130) -> Result<Option<T>, ServiceError> {
3131    let failed = |detail: String| ServiceError::Record {
3132        operation: "read",
3133        path: path.to_path_buf(),
3134        detail,
3135    };
3136    let text = match std::fs::read_to_string(path) {
3137        Ok(text) => text,
3138        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3139        Err(error) => return Err(failed(error.to_string())),
3140    };
3141    toml::from_str(&text)
3142        .map(Some)
3143        .map_err(|error| failed(error.to_string()))
3144}
3145
3146/// Removes a record; one that is not there is already removed.
3147fn remove_state_record(path: &Path) -> Result<(), ServiceError> {
3148    match std::fs::remove_file(path) {
3149        Ok(()) => Ok(()),
3150        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3151        Err(error) => Err(ServiceError::Record {
3152            operation: "remove",
3153            path: path.to_path_buf(),
3154            detail: error.to_string(),
3155        }),
3156    }
3157}
3158
3159// ---------------------------------------------------------------------------
3160// The credential GitHub rejected
3161// ---------------------------------------------------------------------------
3162
3163#[derive(Debug, Serialize, Deserialize)]
3164#[serde(deny_unknown_fields)]
3165struct CredentialRejectionRecord {
3166    schema_version: u32,
3167    rejected_since: DateTime<Utc>,
3168}
3169
3170/// Records that GitHub rejected the daemon's credential, keeping the first
3171/// moment of an ongoing rejection.
3172///
3173/// # Why a file of its own
3174///
3175/// A rejected credential used to be visible only in the daemon's log: the
3176/// contact heartbeat simply stopped moving, `service status` still printed
3177/// `healthy`, and the TUI -- which authenticates with its own, perhaps freshly
3178/// renewed, copy -- showed nothing at all while the service started no runner
3179/// for hours. Separate from [`CONTACT_FILE`] so that an older build reading
3180/// that file is not handed a field it refuses.
3181///
3182/// # Errors
3183///
3184/// [`ServiceError::Record`] when `state/` cannot be written.
3185pub fn record_github_credential_rejected(
3186    paths: &AppPaths,
3187    at: DateTime<Utc>,
3188) -> Result<(), ServiceError> {
3189    if github_credential_rejected_since(paths)
3190        .ok()
3191        .flatten()
3192        .is_some()
3193    {
3194        return Ok(());
3195    }
3196    write_state_record(
3197        &credential_rejection_path(paths),
3198        &CredentialRejectionRecord {
3199            schema_version: CONTACT_SCHEMA_VERSION,
3200            rejected_since: at,
3201        },
3202    )
3203}
3204
3205/// Clears a recorded rejection once GitHub accepts the credential again.
3206///
3207/// # Errors
3208///
3209/// [`ServiceError::Record`] when the record exists and cannot be removed.
3210pub fn clear_github_credential_rejection(paths: &AppPaths) -> Result<(), ServiceError> {
3211    remove_state_record(&credential_rejection_path(paths))
3212}
3213
3214/// Since when GitHub has been rejecting the daemon's credential, if it is.
3215///
3216/// # Errors
3217///
3218/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3219pub fn github_credential_rejected_since(
3220    paths: &AppPaths,
3221) -> Result<Option<DateTime<Utc>>, ServiceError> {
3222    Ok(
3223        read_state_record::<CredentialRejectionRecord>(&credential_rejection_path(paths))?
3224            .map(|record| record.rejected_since),
3225    )
3226}
3227
3228/// Where the rejection record lives.
3229#[must_use]
3230pub fn credential_rejection_path(paths: &AppPaths) -> PathBuf {
3231    paths.state_dir().join(CREDENTIAL_REJECTION_FILE)
3232}
3233
3234#[derive(Debug, Serialize, Deserialize)]
3235#[serde(deny_unknown_fields)]
3236struct CredentialUnreadableRecord {
3237    schema_version: u32,
3238    unreadable_since: DateTime<Utc>,
3239}
3240
3241/// Records that the daemon could not read its stored credential, keeping the
3242/// first moment of an ongoing failure.
3243///
3244/// # Why a file of its own
3245///
3246/// A daemon that cannot read its credential exits, and the service manager
3247/// starts it again a few seconds later, for ever. `service status` reads the
3248/// registration, which is fine, and the heartbeat, which merely stops moving --
3249/// so it said `healthy` while a macOS host that had just been upgraded was
3250/// locked out of its own keychain item and started no runner. The reason was
3251/// only in the daemon's log. The daemon is the one process whose view of the
3252/// store matters, and it shares no memory with `service status`, so it says
3253/// so on disk; [`clear_credential_unreadable`] removes it as soon as a read
3254/// succeeds.
3255///
3256/// # Errors
3257///
3258/// [`ServiceError::Record`] when `state/` cannot be written.
3259pub fn record_credential_unreadable(
3260    paths: &AppPaths,
3261    at: DateTime<Utc>,
3262) -> Result<(), ServiceError> {
3263    if credential_unreadable_since(paths).ok().flatten().is_some() {
3264        return Ok(());
3265    }
3266    write_state_record(
3267        &credential_unreadable_path(paths),
3268        &CredentialUnreadableRecord {
3269            schema_version: CONTACT_SCHEMA_VERSION,
3270            unreadable_since: at,
3271        },
3272    )
3273}
3274
3275/// Clears a recorded failure once the daemon reads its credential again.
3276///
3277/// # Errors
3278///
3279/// [`ServiceError::Record`] when the record exists and cannot be removed.
3280pub fn clear_credential_unreadable(paths: &AppPaths) -> Result<(), ServiceError> {
3281    remove_state_record(&credential_unreadable_path(paths))
3282}
3283
3284/// Since when the daemon has been unable to read its stored credential, if it
3285/// has.
3286///
3287/// # Errors
3288///
3289/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3290pub fn credential_unreadable_since(
3291    paths: &AppPaths,
3292) -> Result<Option<DateTime<Utc>>, ServiceError> {
3293    Ok(
3294        read_state_record::<CredentialUnreadableRecord>(&credential_unreadable_path(paths))?
3295            .map(|record| record.unreadable_since),
3296    )
3297}
3298
3299/// Where the unreadable-credential record lives.
3300#[must_use]
3301pub fn credential_unreadable_path(paths: &AppPaths) -> PathBuf {
3302    paths.state_dir().join(CREDENTIAL_UNREADABLE_FILE)
3303}
3304
3305/// How to sign this host in again for `mode`: the exact command, in
3306/// backticks, and where to run it when that matters.
3307///
3308/// A machine-scoped store needs privilege: `sudo` on Unix, an elevated
3309/// terminal on Windows. A login-mode store on macOS is the login keychain,
3310/// which is unlocked for the desktop session only, so an SSH session cannot
3311/// write it.
3312#[must_use]
3313pub fn sign_in_instruction(mode: StartMode) -> String {
3314    let (command, place) = match mode {
3315        StartMode::Boot if cfg!(unix) => ("sudo runner-manager auth login --start-at boot", ""),
3316        StartMode::Boot => (
3317            "runner-manager auth login --start-at boot",
3318            " from an elevated terminal",
3319        ),
3320        StartMode::Login => (
3321            "runner-manager auth login --start-at login",
3322            MACOS_LOGIN_PLACE,
3323        ),
3324    };
3325    format!("`{command}`{place}")
3326}
3327
3328/// Where a login-mode sign-in has to happen on macOS, as a phrase to follow
3329/// the command; nothing elsewhere.
3330pub const MACOS_LOGIN_PLACE: &str = if cfg!(target_os = "macos") {
3331    " in a Terminal in this Mac's desktop session (an SSH session cannot unlock the login \
3332     keychain)"
3333} else {
3334    ""
3335};
3336
3337// ---------------------------------------------------------------------------
3338// A definition written by an older build
3339// ---------------------------------------------------------------------------
3340
3341/// An installed service definition that differs from what this build renders
3342/// for the same recorded installation.
3343///
3344/// # Why this exists
3345///
3346/// An upgrade replaces the binary a registration runs and nothing else. A
3347/// systemd unit written by 0.4.7 still carried `LoadCredential=`, which hands
3348/// the daemon a copy of the credential frozen at service start, and lacked
3349/// write access to the credential store. Every release after the fix kept
3350/// running under that unit: the daemon could neither renew the token nor see
3351/// one renewed by anybody else, and stopped starting runners the moment the
3352/// frozen access token expired.
3353#[derive(Debug, Clone, PartialEq, Eq)]
3354pub struct DefinitionDrift {
3355    /// The definition file on disk.
3356    pub path: PathBuf,
3357    /// The build that wrote it, as its install record says.
3358    pub installed_by_version: String,
3359    /// What this build renders in its place.
3360    pub rendered: String,
3361    /// The start mode it was installed for.
3362    pub start_mode: StartMode,
3363    /// Which manager reads the definition.
3364    pub manager: DefinitionKind,
3365}
3366
3367/// Compares the installed definition against this build's rendering.
3368///
3369/// A systemd unit is compared byte for byte. A launchd plist is compared on
3370/// `ProcessType` alone (see [`launchd_definition_drift`]). `None` for every
3371/// other manager, for a definition that cannot be read, and for one that
3372/// already matches. The file is read through `controls`, so a double never
3373/// compares against a real host's definition.
3374fn definition_drift(
3375    controls: &dyn ControlFactory,
3376    identity: &ServiceIdentity,
3377    record: &InstallRecord,
3378) -> Option<DefinitionDrift> {
3379    let compare = match host_definition_kind(record.start_mode) {
3380        DefinitionKind::SystemdUnit => systemd_definition_drift,
3381        DefinitionKind::LaunchdPlist => launchd_definition_drift,
3382        DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => return None,
3383    };
3384    let path = record.definition_path.clone()?;
3385    let installed = controls.installed_definition(&path)?;
3386    compare(identity, record, path, &installed)
3387}
3388
3389/// A plist whose `ProcessType` is not [`LAUNCHD_PROCESS_TYPE`], with the plist
3390/// this build renders in its place.
3391///
3392/// Only that one key, unlike the systemd comparison. The daemon repairs this
3393/// drift by itself (`daemon.rs`, `converge_launchd_definition`), so a
3394/// whole-file comparison would make it rewrite and reload over every edit an
3395/// operator made to the plist. A plist that does carry the wrong value is
3396/// replaced whole, as `service install` would replace it.
3397fn launchd_definition_drift(
3398    identity: &ServiceIdentity,
3399    record: &InstallRecord,
3400    path: PathBuf,
3401    installed: &str,
3402) -> Option<DefinitionDrift> {
3403    (plist_string_value(installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE)).then(
3404        || DefinitionDrift {
3405            path,
3406            installed_by_version: record.installed_by_version.clone(),
3407            rendered: launchd_plist(&record.plan(identity.clone())),
3408            start_mode: record.start_mode,
3409            manager: DefinitionKind::LaunchdPlist,
3410        },
3411    )
3412}
3413
3414fn systemd_definition_drift(
3415    identity: &ServiceIdentity,
3416    record: &InstallRecord,
3417    path: PathBuf,
3418    installed: &str,
3419) -> Option<DefinitionDrift> {
3420    let rendered = systemd_unit(&record.plan(identity.clone()));
3421    (installed != rendered).then(|| DefinitionDrift {
3422        path,
3423        installed_by_version: record.installed_by_version.clone(),
3424        rendered,
3425        start_mode: record.start_mode,
3426        manager: DefinitionKind::SystemdUnit,
3427    })
3428}
3429
3430// ---------------------------------------------------------------------------
3431// The last runner-root refusal
3432// ---------------------------------------------------------------------------
3433
3434const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
3435
3436#[derive(Debug, Clone, Serialize, Deserialize)]
3437struct RootRefusalFile {
3438    schema_version: u32,
3439    /// Keyed by policy, because the fact is per-policy and not per-host.
3440    #[serde(default)]
3441    refusals: BTreeMap<String, RootRefusalEntry>,
3442}
3443
3444#[derive(Debug, Clone, Serialize, Deserialize)]
3445struct RootRefusalEntry {
3446    at: DateTime<Utc>,
3447    kind: String,
3448    root: String,
3449    detail: String,
3450}
3451
3452/// One policy's last runner-root refusal, as `service status` reports it.
3453#[derive(Debug, Clone, PartialEq, Eq)]
3454pub struct RunnerRootRefusal {
3455    /// The policy that placed no runner.
3456    pub policy: String,
3457    /// When it was last refused.
3458    pub at: DateTime<Utc>,
3459    /// The closed-vocabulary cause, as `RunnerRootError::kind` names it.
3460    pub kind: String,
3461    /// The root it was refused.
3462    pub root: String,
3463    /// The refusal in full, including the remediation. Not redacted: this file
3464    /// is read by a command in the operator's terminal, not by the log sink.
3465    pub detail: String,
3466}
3467
3468/// Records that one policy could not use its runner root.
3469///
3470/// # The contract, and why a file
3471///
3472/// A launch the runner root refuses fails before any attempt row exists, so the
3473/// journal cannot carry it and no screen can show it. The daemon's own log
3474/// cannot either: it redacts every field it does not allow-list and scrubs
3475/// anything path-shaped out of the ones it does, which is most of a
3476/// `RunnerRootError`. So the sentence an operator needs — the directory and the
3477/// remediation, verbatim — has nowhere to go, and the failure reads
3478/// `runner_start_failed reason=other` once per poll for as long as it lasts.
3479/// That is not hypothetical: it cost three hours.
3480///
3481/// **Keyed by policy, and that is load-bearing.** A host runs several policies
3482/// and they do not share a fate: `allocate_persistent_slot` tolerates an
3483/// unresolvable host default, so a repository with its own persistent root
3484/// places runners happily while an ephemeral policy on a withheld volume places
3485/// none. A single host-wide slot would have the working policy clear the broken
3486/// one's record on the same reconcile pass, and `service status` would report a
3487/// healthy host that had started zero runners for an entire target.
3488///
3489/// **The daemon writes this when a root refuses a launch and clears the policy's
3490/// entry when one succeeds; nothing else writes it.** It keeps the last refusal
3491/// per policy rather than a history, for the reason [`record_github_contact`] is
3492/// a single timestamp: the operator is asking whether runners can be placed
3493/// *now*.
3494///
3495/// Written whole through a temporary in the same directory, so a status command
3496/// cannot read half a record.
3497///
3498/// # Errors
3499///
3500/// [`ServiceError::Record`] when `state/` cannot be read or written.
3501pub fn record_runner_root_refusal(
3502    paths: &AppPaths,
3503    policy: &str,
3504    at: DateTime<Utc>,
3505    kind: &str,
3506    root: &str,
3507    detail: &str,
3508) -> Result<(), ServiceError> {
3509    let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
3510        schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
3511        refusals: BTreeMap::new(),
3512    });
3513    file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
3514    file.refusals.insert(
3515        policy.to_owned(),
3516        RootRefusalEntry {
3517            at,
3518            kind: kind.to_owned(),
3519            root: root.to_owned(),
3520            detail: detail.to_owned(),
3521        },
3522    );
3523    write_refusal_file(paths, &file)
3524}
3525
3526/// Clears one policy's entry, because that policy placed a runner.
3527///
3528/// Removes the file once the last entry goes, so a host that is working leaves
3529/// nothing behind for a later `service status` to report. A policy with no entry
3530/// is not an error: this is called on every successful placement, and almost
3531/// every one of those follows another success.
3532///
3533/// # Errors
3534///
3535/// [`ServiceError::Record`] when `state/` cannot be read or written.
3536pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
3537    let Some(mut file) = read_refusal_file(paths)? else {
3538        return Ok(());
3539    };
3540    if file.refusals.remove(policy).is_none() {
3541        return Ok(());
3542    }
3543    if file.refusals.is_empty() {
3544        let path = root_refusal_path(paths);
3545        return match std::fs::remove_file(&path) {
3546            Ok(()) => Ok(()),
3547            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3548            Err(error) => Err(ServiceError::Record {
3549                operation: "remove",
3550                path,
3551                detail: error.to_string(),
3552            }),
3553        };
3554    }
3555    write_refusal_file(paths, &file)
3556}
3557
3558/// Every policy whose runner root refused it and has not since succeeded.
3559///
3560/// Ordered by policy, so two runs of `service status` on an unchanged host
3561/// print the same thing in the same order.
3562///
3563/// # Errors
3564///
3565/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
3566/// Reported rather than treated as absence, for the reason
3567/// [`last_github_contact`] gives: absence means "every policy is placing
3568/// runners", and answering a parse failure with that would be a wrong answer.
3569pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
3570    Ok(read_refusal_file(paths)?
3571        .map(|file| {
3572            file.refusals
3573                .into_iter()
3574                .map(|(policy, entry)| RunnerRootRefusal {
3575                    policy,
3576                    at: entry.at,
3577                    kind: entry.kind,
3578                    root: entry.root,
3579                    detail: entry.detail,
3580                })
3581                .collect()
3582        })
3583        .unwrap_or_default())
3584}
3585
3586fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
3587    read_state_record(&root_refusal_path(paths))
3588}
3589
3590fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
3591    write_state_record(&root_refusal_path(paths), file)
3592}
3593
3594/// Where the refusals live.
3595#[must_use]
3596pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
3597    paths.state_dir().join(ROOT_REFUSAL_FILE)
3598}
3599
3600// ---------------------------------------------------------------------------
3601// The recorded binary path
3602// ---------------------------------------------------------------------------
3603
3604/// What became of the absolute path `install` recorded.
3605///
3606/// `05-infrastructure.md` item 6 in one type. Three of the four variants are
3607/// errors, and `service status` reports them as errors rather than as health —
3608/// which is the whole point, because the npm case produces a service that looks
3609/// installed, is registered, and cannot start.
3610#[derive(Debug, Clone, PartialEq, Eq)]
3611pub enum BinaryPath {
3612    /// The recorded path is a file, and the service manager still starts it.
3613    Current {
3614        /// The path.
3615        path: PathBuf,
3616    },
3617    /// **Nothing is at the recorded path.**
3618    ///
3619    /// This is the npm upgrade: `npm i -g @ivan-murzak/runner-manager` puts the binary under
3620    /// the active Node installation's global prefix, and switching Node versions
3621    /// with `nvm`, `fnm`, or `volta` moves that prefix. The service is still
3622    /// registered, still set to start at boot, and starts nothing.
3623    Missing {
3624        /// The path the record names.
3625        recorded: PathBuf,
3626    },
3627    /// Something is at the recorded path, but it is not a file the service
3628    /// manager could start.
3629    NotExecutable {
3630        /// The path the record names.
3631        recorded: PathBuf,
3632        /// What is there instead.
3633        detail: String,
3634    },
3635    /// The record and the service manager name different binaries.
3636    Diverged {
3637        /// What the record says.
3638        recorded: PathBuf,
3639        /// What the service manager is registered to start.
3640        registered: PathBuf,
3641    },
3642}
3643
3644impl BinaryPath {
3645    /// Whether this is a state `service status` must report as an error.
3646    #[must_use]
3647    pub const fn is_error(&self) -> bool {
3648        !matches!(self, Self::Current { .. })
3649    }
3650
3651    /// The path the record names, whatever state it is in.
3652    #[must_use]
3653    pub fn recorded(&self) -> &Path {
3654        match self {
3655            Self::Current { path } => path,
3656            Self::Missing { recorded }
3657            | Self::NotExecutable { recorded, .. }
3658            | Self::Diverged { recorded, .. } => recorded,
3659        }
3660    }
3661}
3662
3663impl fmt::Display for BinaryPath {
3664    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3665        match self {
3666            Self::Current { path } => write!(f, "{}", path.display()),
3667            Self::Missing { recorded } => write!(
3668                f,
3669                "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
3670                 package manager that moved the binary is the usual cause; an `npm i -g` \
3671                 installation moves with the active Node version. Run `service install` again \
3672                 from the binary that is now installed.",
3673                recorded.display()
3674            ),
3675            Self::NotExecutable { recorded, detail } => write!(
3676                f,
3677                "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
3678                 from the installed binary.",
3679                recorded.display()
3680            ),
3681            Self::Diverged {
3682                recorded,
3683                registered,
3684            } => write!(
3685                f,
3686                "{} -- STALE: the service manager is registered to start {} instead. Something \
3687                 has edited the registration since it was installed. Run `service uninstall` and \
3688                 `service install`; neither touches configuration, secrets, or the cache.",
3689                recorded.display(),
3690                registered.display()
3691            ),
3692        }
3693    }
3694}
3695
3696/// Decides what became of a recorded path.
3697///
3698/// `registered` is what the service manager says it will start, when the
3699/// platform can be asked. Order matters and is deliberate: **absence is checked
3700/// first**, because a missing binary is the failure item 6 exists for and an
3701/// operator hearing "the registration disagrees with the record" about a file
3702/// that is not there would be sent to the wrong problem.
3703#[must_use]
3704pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
3705    match std::fs::metadata(recorded) {
3706        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3707            return BinaryPath::Missing {
3708                recorded: recorded.to_path_buf(),
3709            };
3710        }
3711        Err(error) => {
3712            return BinaryPath::NotExecutable {
3713                recorded: recorded.to_path_buf(),
3714                detail: format!("it cannot be inspected ({error})"),
3715            };
3716        }
3717        Ok(metadata) if !metadata.is_file() => {
3718            return BinaryPath::NotExecutable {
3719                recorded: recorded.to_path_buf(),
3720                detail: "what is there is not a file".to_string(),
3721            };
3722        }
3723        Ok(_) => {}
3724    }
3725    if let Some(registered) = registered
3726        && !same_path_text(&recorded.to_string_lossy(), &registered.to_string_lossy())
3727    {
3728        return BinaryPath::Diverged {
3729            recorded: recorded.to_path_buf(),
3730            registered: registered.to_path_buf(),
3731        };
3732    }
3733    BinaryPath::Current {
3734        path: recorded.to_path_buf(),
3735    }
3736}
3737
3738// ---------------------------------------------------------------------------
3739// The control seam
3740// ---------------------------------------------------------------------------
3741
3742/// What a service manager says about a registration it holds.
3743#[derive(Debug, Clone, PartialEq, Eq)]
3744pub struct Registration {
3745    /// Which manager holds it, which is also what decides the start mode:
3746    /// a Windows service is a boot registration and a Task Scheduler task is a
3747    /// login one; a LaunchDaemon and a system unit are boot, a LaunchAgent and
3748    /// a user unit are login.
3749    pub manager: DefinitionKind,
3750    /// The start mode the *domain* it was found in implies.
3751    pub start_mode: StartMode,
3752    /// The full command line, as the manager stores it.
3753    pub command_line: String,
3754    /// The account it runs under, when the manager reports one.
3755    pub account: Option<String>,
3756    /// Whether it is running now.
3757    pub running: bool,
3758    /// Whether it will start by itself.
3759    ///
3760    /// Distinct from [`Registration::start_mode`]: a Windows service can be
3761    /// registered in the boot domain and still be set to `demand` start, which
3762    /// is a service that exists, looks installed, and does not come back after
3763    /// a reboot.
3764    pub starts_automatically: bool,
3765    /// The restart-on-failure delay the manager reports, when it reports one.
3766    ///
3767    /// The *delay* rather than the whole [`RestartPolicy`], because that is the
3768    /// half every one of the four managers can be asked for. The failure-count
3769    /// reset window has no representation at all in Task Scheduler or launchd,
3770    /// so a `RestartPolicy` read back from them would carry one number the
3771    /// manager reported and one this module invented — and a comparison against
3772    /// an invented number is a comparison that cannot fail.
3773    pub restart_delay: Option<Duration>,
3774}
3775
3776impl Registration {
3777    /// The executable the manager will start, parsed out of the command line.
3778    #[must_use]
3779    pub fn binary(&self) -> Option<PathBuf> {
3780        executable_from_command_line(&self.command_line)
3781    }
3782}
3783
3784/// One platform's service manager, for one start-mode domain.
3785///
3786/// Every method takes the identity rather than storing it, so one control can
3787/// answer about the product's registration and about a test fixture's without
3788/// either being able to reach the other by accident.
3789pub trait ServiceControl: fmt::Debug {
3790    /// Which manager this is.
3791    fn manager(&self) -> DefinitionKind;
3792
3793    /// Registers the plan, returning the definition that was applied.
3794    ///
3795    /// # Errors
3796    ///
3797    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`].
3798    fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
3799
3800    /// Deregisters. Returns whether there was a registration to remove.
3801    ///
3802    /// **Removes the registration and nothing else.** No implementation of this
3803    /// method may delete a directory, a database, a secret, or a cache;
3804    /// `05-infrastructure.md` item 5 is a property of every backend, not a
3805    /// check somewhere above them.
3806    ///
3807    /// # Errors
3808    ///
3809    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`].
3810    fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3811
3812    /// What the manager knows about this registration, if anything.
3813    ///
3814    /// # Errors
3815    ///
3816    /// [`ServiceError::Control`].
3817    fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
3818
3819    /// Starts it now, without waiting for the next boot or logon.
3820    ///
3821    /// # Errors
3822    ///
3823    /// [`ServiceError::Control`], [`ServiceError::NeedsElevation`], or
3824    /// [`ServiceError::NotInstalled`].
3825    fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
3826
3827    /// Stops it. Returns whether it was running.
3828    ///
3829    /// # Errors
3830    ///
3831    /// As [`ServiceControl::start`].
3832    fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3833}
3834
3835/// Chooses the control for a start mode.
3836///
3837/// The indirection is what makes [`ServiceOperations`] testable: production
3838/// hands it [`HostControls`], every test in this file hands it
3839/// [`RecordingControls`], and neither knows the difference.
3840pub trait ControlFactory: fmt::Debug + Send + Sync {
3841    /// The control for one start-mode domain.
3842    ///
3843    /// # Errors
3844    ///
3845    /// [`ServiceError::Control`] when this host has no manager for that domain.
3846    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
3847
3848    /// The text of an installed definition file, for drift detection. `None`
3849    /// when there is none to read. The default reads nothing, so a double
3850    /// never reports the real host's unit as its own.
3851    fn installed_definition(&self, _path: &Path) -> Option<String> {
3852        None
3853    }
3854}
3855
3856/// The real service managers of the host this binary was built for.
3857#[derive(Debug, Clone, Copy, Default)]
3858pub struct HostControls;
3859
3860// ---------------------------------------------------------------------------
3861// The operations
3862// ---------------------------------------------------------------------------
3863
3864/// What `install` did.
3865#[derive(Debug, Clone)]
3866pub struct Installed {
3867    /// The plan that was applied, including the resolved absolute binary path.
3868    pub plan: InstallPlan,
3869    /// The definition the platform was given.
3870    pub definition: ServiceDefinition,
3871    /// The record that was written.
3872    pub record: InstallRecord,
3873    /// What the definition grants, measured against the requirement.
3874    pub review: PrivilegeReview,
3875    /// What was done to the runner root the registration will run jobs under.
3876    ///
3877    /// Named trustees rather than SIDs, so printing it adds no identity to the
3878    /// output. See [`crate::runner_root_access`].
3879    pub runner_root: RootAccessSummary,
3880    /// Whether this replaced a registration that was already there, rather than
3881    /// making a new one.
3882    ///
3883    /// Reported rather than glossed over: "Service installed" on a host that
3884    /// had one already reads as though nothing had been taken away and put
3885    /// back, and what was taken away and put back is the thing an operator
3886    /// watching a running agent wants to know about.
3887    pub replaced_existing: bool,
3888}
3889
3890/// What `uninstall` did — and, as importantly, what it did not.
3891#[derive(Debug, Clone, PartialEq, Eq)]
3892pub struct Uninstalled {
3893    /// Whether a registration was removed.
3894    pub removed_registration: bool,
3895    /// Whether the install record was removed.
3896    pub removed_record: bool,
3897    /// Whether a definition file was removed, and which.
3898    pub removed_definition: Option<PathBuf>,
3899    /// The directories that were **left exactly as they were**.
3900    ///
3901    /// `05-infrastructure.md` item 5 stated as a value rather than as a
3902    /// promise: `service uninstall` prints this list, so an operator can see
3903    /// that the configuration, the SQLite database, the stored token and the
3904    /// runner package cache are all still there.
3905    pub preserved: Vec<PathBuf>,
3906}
3907
3908impl fmt::Display for Uninstalled {
3909    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3910        if self.removed_registration {
3911            writeln!(f, "The service registration was removed.")?;
3912        } else {
3913            writeln!(f, "There was no service registration to remove.")?;
3914        }
3915        writeln!(f, "Nothing else was deleted. These are untouched:")?;
3916        for path in &self.preserved {
3917            writeln!(f, "  {}", path.display())?;
3918        }
3919        write!(
3920            f,
3921            "The stored GitHub token is untouched too; `auth logout` is what purges it."
3922        )
3923    }
3924}
3925
3926/// What `set_start_mode` did.
3927#[derive(Debug, Clone, PartialEq, Eq)]
3928pub struct StartModeChange {
3929    /// What it was.
3930    pub from: StartMode,
3931    /// What it is now.
3932    pub to: StartMode,
3933    /// `false` when the registration was already in the requested mode.
3934    pub changed: bool,
3935    /// The secret store the new mode obliges, so a caller can tell an operator
3936    /// whether the token has to move too.
3937    pub store_scope: crate::secrets::SecretScope,
3938    /// What the mode change did to the runner root's access control.
3939    ///
3940    /// A mode change moves the account the daemon runs as, and the runner root
3941    /// admits that account by name — so the two move together or the new
3942    /// registration cannot write its own workspaces.
3943    pub runner_root: RootAccessSummary,
3944}
3945
3946impl fmt::Display for StartModeChange {
3947    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3948        if !self.changed {
3949            return write!(f, "The service already starts at {}.", self.to);
3950        }
3951        write!(
3952            f,
3953            "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
3954             if the token was stored under the other scope, run `auth login` again. {}",
3955            self.to, self.from, self.store_scope, self.runner_root
3956        )
3957    }
3958}
3959
3960/// Install, uninstall, inspect, and switch start mode.
3961///
3962/// This is the whole of the library contract `f3` builds `service install`,
3963/// `service uninstall` and `service status` on. Nothing here prints; every
3964/// operation returns a value with a [`fmt::Display`] a command can write, so the
3965/// same facts are available to the TUI and to a JSON document without being
3966/// re-derived from text.
3967#[derive(Debug, Clone)]
3968pub struct ServiceOperations {
3969    paths: AppPaths,
3970    identity: ServiceIdentity,
3971    controls: std::sync::Arc<dyn ControlFactory>,
3972    runner_root: Option<LocalAbsolutePath>,
3973}
3974
3975impl ServiceOperations {
3976    /// Operates on this host's real service managers.
3977    #[must_use]
3978    pub fn on_this_host(paths: AppPaths) -> Self {
3979        Self::with_controls(
3980            paths,
3981            ServiceIdentity::product(),
3982            std::sync::Arc::new(HostControls),
3983        )
3984    }
3985
3986    /// Operates on the controls a caller supplies.
3987    ///
3988    /// Both other arguments are explicit for the same reason: the privileged
3989    /// installer tests register a real service under a
3990    /// [`ServiceIdentity::fixture`] name, against a disposable
3991    /// [`AppPaths::rooted_at`] tree, and must not be able to touch an operator's
3992    /// installation even by mistake.
3993    #[must_use]
3994    pub fn with_controls(
3995        paths: AppPaths,
3996        identity: ServiceIdentity,
3997        controls: std::sync::Arc<dyn ControlFactory>,
3998    ) -> Self {
3999        Self {
4000            paths,
4001            identity,
4002            controls,
4003            runner_root: None,
4004        }
4005    }
4006
4007    /// Points runner-root preparation at a directory the caller owns, for the
4008    /// **current account**.
4009    ///
4010    /// **Only a [`ServiceIdentity::fixture`] registration is allowed to move
4011    /// it, and the guard is here rather than at the call site.** A product
4012    /// registration ignores the override entirely and resolves
4013    /// [`crate::runner_root::default_runner_root`] itself, which is what keeps
4014    /// "custom roots are never re-ACLed" true no matter what a caller passes.
4015    ///
4016    /// It exists because the directory the product uses is
4017    /// `%SystemDrive%\rman`, and a smoke test that created and re-permissioned
4018    /// *that* would be editing the machine it runs on from outside its own
4019    /// fixture. So a fixture aims this at a temporary directory it created and
4020    /// will delete — and "and will delete" is why the override is always
4021    /// reconciled for the calling account rather than for the account the start
4022    /// mode obliges. A boot-mode root admits `SY` and `BA` only, which an
4023    /// ordinary filtered token is neither, so a test that supplied one could
4024    /// not then inspect or remove its own temporary directory.
4025    #[must_use]
4026    pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
4027        self.runner_root = Some(root);
4028        self
4029    }
4030
4031    /// The directories this operates against.
4032    #[must_use]
4033    pub const fn paths(&self) -> &AppPaths {
4034        &self.paths
4035    }
4036
4037    /// The registration this operates on.
4038    #[must_use]
4039    pub const fn identity(&self) -> &ServiceIdentity {
4040        &self.identity
4041    }
4042
4043    /// Registers `daemon run` with the operating system.
4044    ///
4045    /// The order is the contract, and each step is a requirement:
4046    ///
4047    /// 1. the four directories are created, because item 2 is about writing them;
4048    /// 2. **the single-instance lock is taken** — item 1. It is held for the
4049    ///    whole of the install rather than probed and released, so a daemon that
4050    ///    starts halfway through cannot end up racing a registration;
4051    /// 3. neither start-mode domain already holds a registration — item 6's
4052    ///    record would otherwise describe one of two;
4053    /// 4. the binary path is resolved and confirmed to be a file — item 6;
4054    /// 5. the platform registers it;
4055    /// 6. the record is written.
4056    ///
4057    /// # Errors
4058    ///
4059    /// [`ServiceError::LockHeld`] when an agent is already running,
4060    /// [`ServiceError::AlreadyInstalled`], [`ServiceError::BinaryMissing`], and
4061    /// whatever the platform reports.
4062    pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
4063        self.paths
4064            .create_all()
4065            .map_err(|source| ServiceError::Paths {
4066                source: Box::new(source),
4067            })?;
4068
4069        // Item 1. The guard lives until the end of this function; dropping it is
4070        // the release, so an early return releases it too.
4071        let _guard = self.refuse_while_an_agent_runs()?;
4072
4073        // --------------------------------------------------------------------
4074        // AN INSTALL OVER THE SAME START MODE REPLACES; IT DOES NOT REFUSE.
4075        // --------------------------------------------------------------------
4076        // This used to refuse any existing registration, and that was wrong in
4077        // a way that took a real host down.
4078        //
4079        // `service install` is *also* how an operator moves to a new version by
4080        // hand — `install_owned_copy` says so, and the `.old` rename it does
4081        // exists precisely so that reinstalling over a **running** service
4082        // works. So the caller replaces the copy the service runs and *then*
4083        // asks to register it. Refusing at that point does not undo the swap:
4084        // the daemon is left running a binary nobody registered, the command
4085        // reports failure, and on macOS the new binary is a program the stored
4086        // credential's keychain grant does not name. Watched on 0.1.17: the
4087        // copy under `state/bin` was replaced at 00:16, `service install` said
4088        // "already registered", and the daemon crash-looped on `-25293` from
4089        // then on.
4090        //
4091        // Registering the same service twice is not what an operator is asking
4092        // for here and is not what they get: the platform is asked to drop the
4093        // registration and take it again, which is a deregister followed by a
4094        // register because that is the only thing launchd, systemd and the SCM
4095        // all support — `launchctl bootstrap` refuses a label already loaded.
4096        //
4097        // The *other* mode is still refused. Moving between boot and login
4098        // moves the registration between two service managers and changes the
4099        // account and the secret store with it; `set_start_mode` does that,
4100        // carefully, and an install is not the place for it.
4101        let (replacing, previous_was_running) = match self.find_registration()? {
4102            Some((existing, registration)) if existing == request.start_mode() => {
4103                (true, registration.running)
4104            }
4105            Some((existing, _)) => {
4106                return Err(ServiceError::AlreadyInstalled {
4107                    name: self.identity.name().to_string(),
4108                    existing,
4109                    requested: request.start_mode(),
4110                });
4111            }
4112            None => (false, false),
4113        };
4114
4115        let plan = InstallPlan::resolve(
4116            self.identity.clone(),
4117            request,
4118            ServiceDirectories::of(&self.paths),
4119        )?;
4120
4121        // Resolved before the runner root, and used after it: every step from
4122        // here on has to be able to undo the directory, and a `?` between the
4123        // preparation and the first fallible use would leave one behind with
4124        // nothing to say so.
4125        let control = self.controls.control(plan.start_mode())?;
4126
4127        // Item 8, and the one step that can refuse an otherwise valid install.
4128        // Before the platform is asked to register anything, because a
4129        // registration whose workspaces would land in a directory ordinary local
4130        // users can write is one that should never have existed — and because
4131        // undoing a directory is cheaper than undoing a service.
4132        let root = self.prepare_runner_root(plan.start_mode())?;
4133
4134        // Read before the registration is dropped, so that a replace which then
4135        // fails to take can put back what was there. A record this account
4136        // cannot read leaves nothing to reinstate, which is reported rather
4137        // than pretended: see `reinstate`.
4138        let previous = if replacing {
4139            InstallRecord::read(&self.paths).ok().flatten()
4140        } else {
4141            None
4142        };
4143        if replacing && let Err(cause) = control.uninstall(&self.identity) {
4144            return Err(undo_runner_root(&root, "install", &self.identity, cause));
4145        }
4146
4147        let definition = match control.install(&plan) {
4148            Ok(definition) => definition,
4149            Err(cause) => {
4150                // Nothing was deregistered on a first install, so there is
4151                // nothing to put back and `reinstate` says so with `Ok(())`.
4152                let restored =
4153                    self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running);
4154                return Err(rolled_back(
4155                    retained_runner_root(&root),
4156                    restored,
4157                    "install",
4158                    &self.identity,
4159                    cause,
4160                ));
4161            }
4162        };
4163        let review = review_least_privilege(&definition, &plan);
4164        let record = InstallRecord::of(&plan, &definition, Utc::now());
4165        if let Err(cause) = record.write(&self.paths) {
4166            let rollback = control
4167                .uninstall(&self.identity)
4168                .map(|_| ())
4169                .and_then(|()| {
4170                    self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4171                });
4172            return Err(rolled_back(
4173                retained_runner_root(&root),
4174                rollback,
4175                "install",
4176                &self.identity,
4177                cause,
4178            ));
4179        }
4180
4181        // Registration is not the postcondition of an install: a service the
4182        // current session still has to start by hand is only half-installed.
4183        // This matters most for a logon-triggered Windows task because its
4184        // trigger has already passed by the time an operator reinstalls it.
4185        // Start through the same control that registered the definition and
4186        // roll the whole replacement back if the manager refuses it.
4187        if let Err(cause) = control.start(&self.identity) {
4188            let remove_new = control.uninstall(&self.identity).map(|_| ());
4189            let restore_registration = remove_new.and_then(|()| {
4190                self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4191            });
4192            let restore_record = restore_registration.and_then(|()| match &previous {
4193                Some(previous) => previous.write(&self.paths),
4194                None => InstallRecord::remove(&self.paths).map(|_| ()),
4195            });
4196            return Err(rolled_back(
4197                retained_runner_root(&root),
4198                restore_record,
4199                "install and start",
4200                &self.identity,
4201                cause,
4202            ));
4203        }
4204        Ok(Installed {
4205            plan,
4206            definition,
4207            record,
4208            review,
4209            runner_root: root.summary().clone(),
4210            replaced_existing: replacing,
4211        })
4212    }
4213
4214    /// Puts back a registration this call deregistered in order to replace it.
4215    ///
4216    /// `Ok(())` when there was nothing to put back, which is the ordinary case:
4217    /// a first install deregisters nothing. When there *was* a record, the plan
4218    /// is rebuilt from it rather than from the request, because the request is
4219    /// the one that just failed.
4220    ///
4221    /// Best effort by construction — the caller passes the result to
4222    /// [`rolled_back`], which reports a failed rollback alongside the original
4223    /// cause rather than replacing it.
4224    fn reinstate(
4225        &self,
4226        control: &dyn ServiceControl,
4227        previous: Option<&InstallRecord>,
4228        was_running: bool,
4229    ) -> Result<(), ServiceError> {
4230        let Some(record) = previous else {
4231            return Ok(());
4232        };
4233        control.install(&record.plan(self.identity.clone()))?;
4234        if was_running {
4235            control.start(&self.identity)?;
4236        }
4237        Ok(())
4238    }
4239
4240    /// Deregisters, and deletes nothing else.
4241    ///
4242    /// # Errors
4243    ///
4244    /// Whatever the platform reports. A missing registration is **not** an
4245    /// error: `service uninstall` on a host that has none should say so and
4246    /// exit cleanly, because an operator running it twice has not made a
4247    /// mistake.
4248    pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
4249        let record = InstallRecord::read(&self.paths).ok().flatten();
4250        let removed_definition = record
4251            .as_ref()
4252            .and_then(|record| record.definition_path.clone());
4253
4254        let mut removed_registration = false;
4255        // Both domains, not only the recorded one. A record can be lost while a
4256        // registration survives, and leaving a registration behind because a
4257        // TOML file was deleted is exactly the state `uninstall` exists to end.
4258        for mode in [StartMode::Boot, StartMode::Login] {
4259            let control = self.controls.control(mode)?;
4260            if control.uninstall(&self.identity)? {
4261                removed_registration = true;
4262            }
4263        }
4264        let removed_record = InstallRecord::remove(&self.paths)?;
4265        Ok(Uninstalled {
4266            removed_registration,
4267            removed_record,
4268            removed_definition: removed_definition.filter(|_| removed_registration),
4269            preserved: self
4270                .paths
4271                .all()
4272                .iter()
4273                .map(|(_, path)| (*path).to_path_buf())
4274                .collect(),
4275        })
4276    }
4277
4278    /// Switches between `boot` and `login` **without re-resolving anything**.
4279    ///
4280    /// `05-infrastructure.md` item 7. Everything the new registration carries —
4281    /// the absolute binary path, the arguments, the restart policy, the four
4282    /// directories — comes from the existing record, so the operator is not
4283    /// asked to reinstall the product and a binary that has since moved is not
4284    /// silently swapped for whichever one happens to be running this command.
4285    ///
4286    /// On Windows the registration necessarily moves between two different
4287    /// Windows facilities, because Windows has no service that starts at logon;
4288    /// see this module's documentation. That is still not a reinstall: no file
4289    /// is downloaded, replaced, or re-resolved.
4290    ///
4291    /// The caller must persist the new mode onto this host's `Host` record so
4292    /// that `host show` reports it — [`StartModeChange::to`] is the value, and
4293    /// `f1` already emits `host.service_start_mode` from that field.
4294    ///
4295    /// # Errors
4296    ///
4297    /// [`ServiceError::NotInstalled`] when there is no record to switch, and
4298    /// whatever the platform reports.
4299    pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
4300        let Some(record) = InstallRecord::read(&self.paths)? else {
4301            return Err(ServiceError::NotInstalled {
4302                name: self.identity.name().to_string(),
4303                operation: "switch the start mode of",
4304            });
4305        };
4306        let from = record.start_mode;
4307        if from == to {
4308            // Nothing moves, so nothing about the root's access control has to.
4309            // Reconciling it here would turn a no-op command into one that can
4310            // fail on a permission it does not need.
4311            return Ok(StartModeChange {
4312                from,
4313                to,
4314                changed: false,
4315                store_scope: crate::secrets::SecretScope::for_start_mode(to),
4316                runner_root: RootAccessSummary::NotApplicable,
4317            });
4318        }
4319
4320        #[cfg(windows)]
4321        let arguments = {
4322            let mut arguments = record.arguments.clone();
4323            arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
4324            if to == StartMode::Boot {
4325                arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
4326            }
4327            arguments
4328        };
4329        #[cfg(not(windows))]
4330        let arguments = record.arguments.clone();
4331        let plan = InstallPlan::unchecked(
4332            self.identity.clone(),
4333            to,
4334            record.binary.clone(),
4335            record.directories.clone(),
4336        )
4337        .with_arguments(arguments)
4338        .with_restart(record.restart());
4339        let plan = if record.starts_on_demand {
4340            plan.started_on_demand()
4341        } else {
4342            plan
4343        };
4344        let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
4345            Ok(store) => plan.with_secret_guard(store.guard()),
4346            Err(_) => plan,
4347        };
4348
4349        // Install the target domain before touching the live one. This makes a
4350        // failed target install a no-op from the operator's point of view and,
4351        // unlike uninstall-first ordering, never trades a working service for
4352        // an error message. Resolved before the runner root and used after it,
4353        // so that no `?` sits between preparing the directory and the first
4354        // step that knows how to undo it.
4355        let target = self.controls.control(to)?;
4356        // The domain being left, resolved here rather than where it is used for
4357        // the same reason: the only step that removes it runs after the runner
4358        // root has been prepared, and a `?` there would abandon the target
4359        // registration, the record and the directory without a word.
4360        let previous = self.controls.control(from)?;
4361
4362        // The account changes with the mode, and so must the account the runner
4363        // root admits: `04-security-recovery.md` requires the selected identity
4364        // to be *reconciled* when service mode changes, which means adding the
4365        // operator's on the way to login and dropping it again on the way back.
4366        // Before the target install, for the same reason `install` does it
4367        // first — a root that cannot be made safe must not produce a working
4368        // registration.
4369        let root = self.prepare_runner_root(to)?;
4370
4371        let definition = match target.install(&plan) {
4372            Ok(definition) => definition,
4373            Err(cause) => {
4374                return Err(undo_runner_root(
4375                    &root,
4376                    "switch start mode",
4377                    &self.identity,
4378                    cause,
4379                ));
4380            }
4381        };
4382        let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
4383        if let Err(cause) = next_record.write(&self.paths) {
4384            return Err(rolled_back(
4385                retained_runner_root(&root),
4386                target.uninstall(&self.identity),
4387                "switch start mode",
4388                &self.identity,
4389                cause,
4390            ));
4391        }
4392
4393        // Only after the target registration and its durable record exist is
4394        // it safe to remove the old domain. If that last step fails, remove the
4395        // target and put the old record back so status and reality agree.
4396        if let Err(cause) = previous.uninstall(&self.identity) {
4397            let target_rollback = target.uninstall(&self.identity);
4398            let record_rollback = record.write(&self.paths);
4399            return Err(rolled_back(
4400                retained_runner_root(&root),
4401                target_rollback.and(record_rollback),
4402                "switch start mode",
4403                &self.identity,
4404                cause,
4405            ));
4406        }
4407        Ok(StartModeChange {
4408            from,
4409            to,
4410            changed: true,
4411            store_scope: crate::secrets::SecretScope::for_start_mode(to),
4412            runner_root: root.summary().clone(),
4413        })
4414    }
4415
4416    /// Starts the registration now.
4417    ///
4418    /// # Errors
4419    ///
4420    /// [`ServiceError::NotInstalled`], or whatever the platform reports.
4421    pub fn start(&self) -> Result<(), ServiceError> {
4422        let Some((mode, _)) = self.find_registration()? else {
4423            return Err(ServiceError::NotInstalled {
4424                name: self.identity.name().to_string(),
4425                operation: "start",
4426            });
4427        };
4428        self.controls.control(mode)?.start(&self.identity)
4429    }
4430
4431    /// Stops the registration. Returns whether it was running.
4432    ///
4433    /// # Errors
4434    ///
4435    /// As [`ServiceOperations::start`].
4436    pub fn stop(&self) -> Result<bool, ServiceError> {
4437        let Some((mode, _)) = self.find_registration()? else {
4438            return Err(ServiceError::NotInstalled {
4439                name: self.identity.name().to_string(),
4440                operation: "stop",
4441            });
4442        };
4443        self.controls.control(mode)?.stop(&self.identity)
4444    }
4445
4446    /// Everything Journey 5 step 4 asks `service status` to report.
4447    ///
4448    /// # Errors
4449    ///
4450    /// [`ServiceError::Record`] when local state cannot be read, and whatever
4451    /// the platform reports. A *stale binary path* is deliberately **not** an
4452    /// error here: it is a reported state, because a status command that
4453    /// refused to print anything else would hide the very facts an operator
4454    /// needs in order to fix it.
4455    ///
4456    /// A record **this account may not read** is reported for the same reason
4457    /// and is the second exception. On a boot-mode host the record was written
4458    /// by `sudo service install`, and a version before this one wrote it `0600`
4459    /// — so the operator's own `service status` ended with `Permission denied`
4460    /// and printed nothing at all, on a host whose registration the service
4461    /// manager would have described perfectly well. What launchd, systemd or
4462    /// the SCM says is a separate fact from the record, and it is still worth
4463    /// having.
4464    pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
4465        let (record, record_refused) = match InstallRecord::read(&self.paths) {
4466            Ok(record) => (record, None),
4467            Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
4468            Err(error) => return Err(error),
4469        };
4470        let found = self.find_registration()?;
4471        let last_github_contact = last_github_contact(&self.paths)?;
4472        let drift = record
4473            .as_ref()
4474            .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, record));
4475        let credential_rejected_since =
4476            github_credential_rejected_since(&self.paths).ok().flatten();
4477        let credential_unreadable_since = credential_unreadable_since(&self.paths).ok().flatten();
4478        Ok(ServiceStatus::compose(
4479            self.identity.clone(),
4480            record,
4481            record_refused.as_ref(),
4482            found.map(|(_, registration)| registration),
4483            last_github_contact,
4484            &self.paths,
4485        )
4486        .with_definition_drift(drift)
4487        .with_credential_rejection(credential_rejected_since)
4488        .with_credential_unreadable(credential_unreadable_since))
4489    }
4490
4491    /// The installed definition, when it is not what this build renders for
4492    /// the recorded installation. See [`definition_drift`].
4493    ///
4494    /// # Errors
4495    ///
4496    /// Whatever reading the install record reports.
4497    pub fn definition_drift(&self) -> Result<Option<DefinitionDrift>, ServiceError> {
4498        Ok(InstallRecord::read(&self.paths)?
4499            .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, &record)))
4500    }
4501
4502    /// Writes the plist this build renders over an outdated one, then has a
4503    /// detached helper boot the job out and bootstrap it again from the new
4504    /// file.
4505    ///
4506    /// launchd reads a plist only when the job is bootstrapped. Rewriting the
4507    /// file changes nothing for a job that is already loaded, and a `KeepAlive`
4508    /// restart reuses the loaded copy. So the job has to be booted out, and
4509    /// that terminates the daemon that asked for it. The helper runs in a
4510    /// session of its own, which launchd does not kill with the job, and
4511    /// retries the bootstrap: launchd refuses it for a few seconds while the
4512    /// old job is still exiting (`5: Input/output error`, measured).
4513    ///
4514    /// Call this only from the daemon the job runs, and only while it holds no
4515    /// runner. It is an operation on the registration the daemon itself runs
4516    /// under, and it never changes the start mode.
4517    ///
4518    /// # Errors
4519    ///
4520    /// [`ServiceError::Control`] or [`ServiceError::NeedsElevation`] when the
4521    /// plist cannot be written or the helper cannot be started. The loaded job
4522    /// is untouched in both cases.
4523    #[cfg(target_os = "macos")]
4524    pub fn reload_launchd_definition(&self, drift: &DefinitionDrift) -> Result<(), ServiceError> {
4525        // The record names the start mode; the account this process runs as has
4526        // to agree, or the record describes a registration other than the one
4527        // running, and root would end up rewriting an operator's LaunchAgent.
4528        // SAFETY: `getuid` reads the calling process's real user id and cannot
4529        // fail.
4530        let root = unsafe { libc::getuid() } == 0;
4531        if root != (drift.start_mode == StartMode::Boot) {
4532            return Err(ServiceError::Control {
4533                operation: "rewrite",
4534                name: self.identity.name().to_string(),
4535                manager: "launchd",
4536                detail: format!(
4537                    "the install record says --start-at {}, but this daemon runs as {}; the plist \
4538                     is left as it is",
4539                    drift.start_mode,
4540                    if root { "root" } else { "the operator" }
4541                ),
4542            });
4543        }
4544        replace_definition(self.identity.name(), drift)?;
4545        // The definition is now this build's, as after the WSL unit rewrite.
4546        // Best effort: the record says who wrote the plist and nothing reads it
4547        // to decide whether to repair.
4548        if let Ok(Some(mut record)) = InstallRecord::read(&self.paths) {
4549            record.installed_by_version = env!("CARGO_PKG_VERSION").to_string();
4550            let _ = record.write(&self.paths);
4551        }
4552        sys::reload_detached(&self.identity, drift.start_mode, &drift.path)
4553    }
4554
4555    /// Creates or reconciles the runner root this start mode's account needs.
4556    ///
4557    /// The account is not an argument: it is [`ServiceAccount::for_start_mode`],
4558    /// the same function the registration's own principal comes from, so the
4559    /// directory admits exactly the identity the definition registers and a mode
4560    /// change reconciles both together or neither.
4561    ///
4562    /// On macOS and Linux this is a no-op that returns
4563    /// [`RootAccessSummary::NotApplicable`]; see [`crate::runner_root_access`].
4564    fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
4565        #[cfg(not(windows))]
4566        {
4567            // `runner_root` is consumed here as well as in the Windows arm: a
4568            // field only one platform reads is a dead field on the other.
4569            let _ = (mode, &self.runner_root);
4570            Ok(RootAccessChange::not_applicable())
4571        }
4572        #[cfg(windows)]
4573        {
4574            let wrap = |source| ServiceError::RunnerRoot {
4575                source: Box::new(source),
4576            };
4577            // The test seam, and the two things allowed through it. `cfg!(test)`
4578            // is false in every shipped binary, so what a released build honours
4579            // is the fixture name alone — and a fixture name cannot be the
4580            // product's, which is what keeps a released `service install`
4581            // pointed at the platform default whatever a caller passes.
4582            //
4583            // An overridden root is always reconciled **for this account**,
4584            // which is the foreground admission — a real mode of the product
4585            // rather than a concession invented here. It has to be: a boot-mode
4586            // root admits `SY` and `BA` only, and a test process holding an
4587            // ordinary filtered token is neither, so it could not inspect the
4588            // temporary directory it just supplied nor delete it afterwards.
4589            // What that costs is that the *boot* descriptor is not proved
4590            // through this path; it is proved purely, by this module's
4591            // `the_runner_root_a_boot_registration_needs_admits_only_the_service`
4592            // and by `runner_root_access`'s own tests, and for real by the
4593            // privileged installer test, which runs elevated.
4594            if let Some(root) = self
4595                .runner_root
4596                .as_ref()
4597                .filter(|_| self.identity.is_fixture() || cfg!(test))
4598            {
4599                let admission = RootAdmission::of_this_account().map_err(wrap)?;
4600                return crate::runner_root_access::reconcile(&self.paths, root, &admission)
4601                    .map_err(wrap);
4602            }
4603
4604            let admission = match ServiceAccount::for_start_mode(mode) {
4605                // A boot registration runs as LocalSystem, which the constant
4606                // `SY` ace already names.
4607                ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
4608                // A login registration runs as this account, under a filtered
4609                // token in which Administrators is deny-only — so without an
4610                // ace of its own it would be admitted by nothing.
4611                ServiceAccount::InvokingUser | ServiceAccount::Root => {
4612                    RootAdmission::of_this_account().map_err(wrap)?
4613                }
4614            };
4615            crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
4616        }
4617    }
4618
4619    /// Takes the single-instance lock, or refuses with `d1`'s own message.
4620    fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
4621        HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
4622            |source| match source {
4623                held @ LockError::Held { .. } => ServiceError::LockHeld {
4624                    source: Box::new(held),
4625                },
4626                other => ServiceError::LockUnreadable {
4627                    source: Box::new(other),
4628                },
4629            },
4630        )
4631    }
4632
4633    /// The registration, in whichever domain holds it.
4634    fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
4635        for mode in [StartMode::Boot, StartMode::Login] {
4636            let control = self.controls.control(mode)?;
4637            if let Some(registration) = control.query(&self.identity)? {
4638                return Ok(Some((registration.start_mode, registration)));
4639            }
4640        }
4641        Ok(None)
4642    }
4643}
4644
4645// ---------------------------------------------------------------------------
4646// Status
4647// ---------------------------------------------------------------------------
4648
4649/// One thing `service status` has to report as wrong.
4650#[derive(Debug, Clone, PartialEq, Eq)]
4651pub struct StatusProblem {
4652    /// What it is about.
4653    pub subject: &'static str,
4654    /// What is wrong, and what to do.
4655    pub detail: String,
4656}
4657
4658impl fmt::Display for StatusProblem {
4659    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4660        write!(f, "{}: {}", self.subject, self.detail)
4661    }
4662}
4663
4664/// Everything `service status` reports.
4665///
4666/// Journey 5 step 4 names four of these — the start mode, the resolved binary
4667/// path, the diagnostic log path, and the last successful GitHub contact — and
4668/// the release gate adds the fifth: *"`service status` reports a stale binary
4669/// path as an error rather than appearing healthy"*. [`Self::is_healthy`] is
4670/// what an exit code should be derived from, and it is false whenever
4671/// [`Self::problems`] is non-empty.
4672#[derive(Debug, Clone)]
4673pub struct ServiceStatus {
4674    identity: ServiceIdentity,
4675    record: Option<InstallRecord>,
4676    registration: Option<Registration>,
4677    binary: Option<BinaryPath>,
4678    log_file: PathBuf,
4679    store: Option<crate::secrets::ActiveStore>,
4680    last_github_contact: Option<DateTime<Utc>>,
4681    runner_root: Option<(PathBuf, RootAccessReport)>,
4682    credential_rejected_since: Option<DateTime<Utc>>,
4683    problems: Vec<StatusProblem>,
4684    notes: Vec<String>,
4685}
4686
4687impl ServiceStatus {
4688    /// `record_refused` carries a [`ServiceError::RecordNotPermitted`] when
4689    /// there **is** a record and this account may not read it, which is not the
4690    /// same state as having none and must not be reported as one. See
4691    /// [`Operations::status`].
4692    fn compose(
4693        identity: ServiceIdentity,
4694        record: Option<InstallRecord>,
4695        record_refused: Option<&ServiceError>,
4696        registration: Option<Registration>,
4697        last_github_contact: Option<DateTime<Utc>>,
4698        paths: &AppPaths,
4699    ) -> Self {
4700        let mut problems = Vec::new();
4701        let mut notes = Vec::new();
4702
4703        if let Some(refusal) = record_refused {
4704            // A problem rather than a note: `is_healthy` stays false, because
4705            // an operator who cannot read their own install record does have
4706            // something to fix. It is just not a reason to withhold everything
4707            // the service manager knows.
4708            problems.push(StatusProblem {
4709                subject: "install record",
4710                detail: refusal.to_string(),
4711            });
4712        }
4713
4714        let log_file = record.as_ref().map_or_else(
4715            || ServiceDirectories::of(paths).log_file(),
4716            |record| record.log_file.clone(),
4717        );
4718
4719        let binary = record.as_ref().map(|record| {
4720            inspect_binary(
4721                &record.binary,
4722                registration
4723                    .as_ref()
4724                    .and_then(Registration::binary)
4725                    .as_deref(),
4726            )
4727        });
4728        if let Some(state) = &binary
4729            && state.is_error()
4730        {
4731            problems.push(StatusProblem {
4732                subject: "binary",
4733                detail: state.to_string(),
4734            });
4735        }
4736
4737        match (&record, &registration) {
4738            (Some(_), None) => problems.push(StatusProblem {
4739                subject: "registration",
4740                detail: "this host has a service record but no service manager knows the \
4741                         registration. Run `service install` again; it deletes nothing."
4742                    .to_string(),
4743            }),
4744            // Not reported when the record was refused rather than absent: the
4745            // problem above already names the real state, and "there is no
4746            // install record" would be a second, wrong diagnosis of the same
4747            // file, with a `service uninstall` in its remedy.
4748            (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
4749                subject: "record",
4750                detail: format!(
4751                    "{} holds a registration for this service but there is no install record, so \
4752                     the path it was installed from and the directories it was installed against \
4753                     are unknown. Run `service uninstall` and `service install`.",
4754                    found.manager
4755                ),
4756            }),
4757            (None, Some(_)) => {}
4758            (Some(record), Some(found)) => {
4759                if record.start_mode != found.start_mode {
4760                    problems.push(StatusProblem {
4761                        subject: "start mode",
4762                        detail: format!(
4763                            "the record says {} and {} holds a {} registration. Switch the start \
4764                             mode again to make them agree.",
4765                            record.start_mode, found.manager, found.start_mode
4766                        ),
4767                    });
4768                }
4769                if record.start_mode == StartMode::Boot && !found.starts_automatically {
4770                    problems.push(StatusProblem {
4771                        subject: "start mode",
4772                        detail: format!(
4773                            "{} holds the registration but will not start it by itself, so this \
4774                             host does not resume work after a reboot.",
4775                            found.manager
4776                        ),
4777                    });
4778                }
4779                if let Some(actual) = found.restart_delay {
4780                    let expected = record.restart().effective_delay(found.manager);
4781                    if actual != expected {
4782                        problems.push(StatusProblem {
4783                            subject: "restart policy",
4784                            detail: format!(
4785                                "the record says the service restarts after {}s and {} reports \
4786                                 {}s. Something has edited the registration since it was \
4787                                 installed.",
4788                                expected.as_secs(),
4789                                found.manager,
4790                                actual.as_secs()
4791                            ),
4792                        });
4793                    } else if expected != record.restart().delay() {
4794                        // Not a fault. Task Scheduler expresses this in whole
4795                        // minutes, so the delay in force is longer than the one
4796                        // asked for -- never shorter, which is the direction
4797                        // the requirement cares about.
4798                        notes.push(format!(
4799                            "{} expresses the restart delay in whole minutes, so the {}s asked \
4800                             for is enforced as {}s. The service therefore never restarts faster \
4801                             than the configured bound.",
4802                            found.manager,
4803                            record.restart().delay().as_secs(),
4804                            expected.as_secs()
4805                        ));
4806                    }
4807                }
4808                if found.starts_automatically && !found.running {
4809                    problems.push(StatusProblem {
4810                        subject: "runtime",
4811                        detail: format!(
4812                            "{} holds an automatic registration, but the daemon is stopped. Run \
4813                             `runner-manager service install --start-at {}`; if the service \
4814                             manager denies replacement, retry from an elevated terminal. If it \
4815                             stops again, inspect {}.",
4816                            found.manager,
4817                            found.start_mode,
4818                            log_file.display()
4819                        ),
4820                    });
4821                }
4822            }
4823            (None, None) => {}
4824        }
4825
4826        // The two facts here really are independent, which is what `f1`'s
4827        // `status.rs` says this check needs and could not have there: the scope
4828        // is derived from the mode the **record** carries, and it is compared
4829        // against the mode the **service manager** actually holds.
4830        let store = record.as_ref().and_then(|record| {
4831            let registered_mode = registration
4832                .as_ref()
4833                .map_or(record.start_mode, |found| found.start_mode);
4834            crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
4835                .ok()
4836                .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
4837        });
4838        if let Some(store) = &store
4839            && !store.agrees_with_start_mode()
4840        {
4841            problems.push(StatusProblem {
4842                subject: "secret store",
4843                detail: format!(
4844                    "{store}. Run `auth login` again so the token is stored where the registered \
4845                     start mode can read it."
4846                ),
4847            });
4848        }
4849
4850        if record.as_ref().map(|record| record.start_mode) == Some(StartMode::Login) {
4851            notes.push(
4852                "This registration starts at login, so the agent does not run until the operator \
4853                 signs in; this host does not resume work after an unattended reboot."
4854                    .to_string(),
4855            );
4856        }
4857        if last_github_contact.is_none() {
4858            notes.push(
4859                "GitHub has not been reached successfully since this host's state directory was \
4860                 created."
4861                    .to_string(),
4862            );
4863        }
4864
4865        // The privileged inspection output. Read-only, tolerant of an account
4866        // that may not read the descriptor at all, and never a `problem`: a
4867        // root this account cannot inspect is a true statement about this
4868        // account's rights, not a fault in the registration. What refuses an
4869        // install is the preflight in `runner_root_access`, which runs as the
4870        // installing account and has the authority to know.
4871        let runner_root = crate::runner_root::default_runner_root(paths)
4872            .ok()
4873            .map(|root| {
4874                let path = root.as_path().to_path_buf();
4875                let report = crate::runner_root_access::report(&path);
4876                (path, report)
4877            });
4878        // Reported, and deliberately not a `problem`. The Definition of Done
4879        // asks for a broad root to be "reported and fail the security
4880        // preflight", and the security preflight is `install`'s -- which runs
4881        // as the installing account and refuses outright. `service status` runs
4882        // as whoever typed it, is expected to be readable on a host with
4883        // nothing installed at all, and drives an exit code; turning a
4884        // directory that predates this feature into a non-zero exit for a
4885        // machine that has never installed the service would report a fault
4886        // that is not this registration's.
4887        if let Some((
4888            path,
4889            RootAccessReport::Present {
4890                broad_write: true, ..
4891            },
4892        )) = &runner_root
4893        {
4894            notes.push(format!(
4895                "the platform default runner root {} can be written by ordinary local users, so \
4896                 it is not a safe place to run jobs. `service install` refuses it rather than \
4897                 tightening it, because the contents of a directory anybody could write cannot \
4898                 be trusted: remove or empty it, or choose another root with `runner-manager \
4899                 host set-runtime-root --path <PATH>`.",
4900                path.display()
4901            ));
4902        }
4903
4904        // The surface the agent has nowhere else to reach. A root that refuses
4905        // a launch does so before any attempt row exists, and the daemon's log
4906        // redacts the paths out of the sentence, so without this the operator
4907        // sees `runner_start_failed reason=other` once per poll and nothing
4908        // that names the directory or the fix.
4909        //
4910        // A **note** and not a problem, by the same rule the broad-write root
4911        // above obeys: `service status` runs as whoever typed it, is expected
4912        // to be readable on a host with nothing installed at all, and drives an
4913        // exit code. A problem here would fail that exit code on three hosts
4914        // that are not broken -- one whose root was fixed but which has had no
4915        // queued job since, one whose service was uninstalled without clearing
4916        // `state/`, and one that only ever ran the agent in the foreground --
4917        // and the printed remedy (`service uninstall && service install`) does
4918        // not touch `state/`, so following it would not clear the error either.
4919        // A note is read by the same operator at the same moment and traps
4920        // nobody in a loop.
4921        //
4922        // A read failure is reported and not swallowed, for the reason
4923        // `runner_root_refusals` documents: absence means "every policy is
4924        // placing runners", and answering a parse failure with that would be
4925        // the wrong answer to the question this row exists for.
4926        match runner_root_refusals(paths) {
4927            Ok(refusals) => {
4928                for refusal in refusals {
4929                    notes.push(format!(
4930                        "policy {} started no runner: its runner root {} refused the launch \
4931                         ({}), last at {}. {} This clears when that policy next places a \
4932                         runner.",
4933                        refusal.policy,
4934                        refusal.root,
4935                        refusal.kind,
4936                        refusal.at.to_rfc3339(),
4937                        refusal.detail,
4938                    ));
4939                }
4940            }
4941            Err(error) => notes.push(format!(
4942                "whether the agent could use its runner roots could not be read: {error}"
4943            )),
4944        }
4945
4946        Self {
4947            identity,
4948            record,
4949            registration,
4950            binary,
4951            log_file,
4952            store,
4953            last_github_contact,
4954            runner_root,
4955            credential_rejected_since: None,
4956            problems,
4957            notes,
4958        }
4959    }
4960
4961    /// Reports a definition this build would render differently.
4962    #[must_use]
4963    pub fn with_definition_drift(mut self, drift: Option<DefinitionDrift>) -> Self {
4964        if let Some(drift) = drift {
4965            if drift.manager == DefinitionKind::LaunchdPlist {
4966                // A restart, not `service install`: the running daemon holds the
4967                // single-instance lock, which refuses an install, and a daemon
4968                // that starts with no runner repairs the plist itself.
4969                let restart = match drift.start_mode {
4970                    StartMode::Boot => format!(
4971                        "sudo launchctl kickstart -k system/{}",
4972                        self.identity.launchd_label()
4973                    ),
4974                    StartMode::Login => format!(
4975                        "launchctl kickstart -k gui/$(id -u)/{}",
4976                        self.identity.launchd_label()
4977                    ),
4978                };
4979                self.problems.push(StatusProblem {
4980                    subject: "definition",
4981                    detail: format!(
4982                        "{} was written by runner-manager {} with a `ProcessType` other than \
4983                         `{LAUNCHD_PROCESS_TYPE}`, which launchd applies to every runner the \
4984                         daemon starts, so their jobs run below normal priority. The daemon \
4985                         rewrites it and reloads itself when it starts with no runner, which it \
4986                         does after every upgrade; to do it now, restart the service while no \
4987                         job is running: `{restart}`. Configuration and credentials are kept.",
4988                        drift.path.display(),
4989                        drift.installed_by_version,
4990                    ),
4991                });
4992                return self;
4993            }
4994            let repair = match drift.start_mode {
4995                StartMode::Boot => {
4996                    "`sudo systemctl stop runner-manager && sudo runner-manager service install \
4997                     --start-at boot` (for a managed WSL host, run `runner-manager wsl install \
4998                     --distribution <name>` from Windows)"
4999                }
5000                StartMode::Login => {
5001                    "`systemctl --user stop runner-manager && runner-manager service install \
5002                     --start-at login`"
5003                }
5004            };
5005            self.problems.push(StatusProblem {
5006                subject: "definition",
5007                detail: format!(
5008                    "{} was written by runner-manager {} and differs from what {} renders, so \
5009                     the daemon runs under outdated settings (for example a credential frozen \
5010                     at service start). Re-register it with {repair}; configuration and \
5011                     credentials are kept.",
5012                    drift.path.display(),
5013                    drift.installed_by_version,
5014                    env!("CARGO_PKG_VERSION"),
5015                ),
5016            });
5017        }
5018        self
5019    }
5020
5021    /// Reports that GitHub is rejecting the daemon's credential.
5022    #[must_use]
5023    pub fn with_credential_rejection(mut self, since: Option<DateTime<Utc>>) -> Self {
5024        if let Some(since) = since {
5025            self.credential_rejected_since = Some(since);
5026            self.problems.push(StatusProblem {
5027                subject: "github credential",
5028                detail: format!(
5029                    "GitHub has rejected the service's credential since {}, so the service \
5030                     cannot see queued jobs or start runners. Run `runner-manager auth login` \
5031                     (for a managed WSL host, `runner-manager --host wsl:<name> auth login` \
5032                     from Windows).",
5033                    since.to_rfc3339()
5034                ),
5035            });
5036        }
5037        self
5038    }
5039
5040    /// Since when GitHub has rejected the service's credential, if it has.
5041    #[must_use]
5042    pub const fn credential_rejected_since(&self) -> Option<DateTime<Utc>> {
5043        self.credential_rejected_since
5044    }
5045
5046    /// Reports that the daemon cannot read its own stored credential, and the
5047    /// exact command that ends it. See [`record_credential_unreadable`].
5048    #[must_use]
5049    pub fn with_credential_unreadable(mut self, since: Option<DateTime<Utc>>) -> Self {
5050        if let Some(since) = since {
5051            let mode = self.start_mode().unwrap_or_default();
5052            self.problems.push(StatusProblem {
5053                subject: CREDENTIAL_UNREADABLE_SUBJECT,
5054                detail: format!(
5055                    "the service has been unable to read its stored GitHub credential since {}, \
5056                     so it starts no runner; the diagnostic log has the store's own answer.{} \
5057                     Sign in again: {}.",
5058                    since.to_rfc3339(),
5059                    if cfg!(target_os = "macos") {
5060                        " That is what a keychain item written by a different build looks like."
5061                    } else {
5062                        ""
5063                    },
5064                    sign_in_instruction(mode)
5065                ),
5066            });
5067        }
5068        self
5069    }
5070
5071    /// What this host's default runner root grants, and to whom.
5072    ///
5073    /// `None` when the platform default could not even be resolved. The
5074    /// descriptor inside has been through
5075    /// [`crate::runner_root_access::redact`], so it names the well-known
5076    /// trustees and says "an account" for everything else — no more identity
5077    /// than the `account` line above it already prints.
5078    #[must_use]
5079    pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
5080        self.runner_root
5081            .as_ref()
5082            .map(|(path, report)| (path.as_path(), report))
5083    }
5084
5085    /// Whether a registration exists at all.
5086    #[must_use]
5087    pub const fn is_installed(&self) -> bool {
5088        self.registration.is_some() || self.record.is_some()
5089    }
5090
5091    /// Whether the daemon is running now.
5092    #[must_use]
5093    pub fn is_running(&self) -> bool {
5094        self.registration
5095            .as_ref()
5096            .is_some_and(|registration| registration.running)
5097    }
5098
5099    /// **False whenever anything is wrong**, including a stale binary path.
5100    #[must_use]
5101    pub fn is_healthy(&self) -> bool {
5102        self.problems.is_empty()
5103    }
5104
5105    /// Everything that is wrong.
5106    #[must_use]
5107    pub fn problems(&self) -> &[StatusProblem] {
5108        &self.problems
5109    }
5110
5111    /// True statements that are not faults — a login-mode registration not
5112    /// running unattended, or an agent that has not yet reached GitHub.
5113    #[must_use]
5114    pub fn notes(&self) -> &[String] {
5115        &self.notes
5116    }
5117
5118    /// The recorded start mode. Journey 5 step 4.
5119    #[must_use]
5120    pub fn start_mode(&self) -> Option<StartMode> {
5121        self.record.as_ref().map(|record| record.start_mode)
5122    }
5123
5124    /// The resolved absolute binary path and what became of it. Journey 5
5125    /// step 4 and `05-infrastructure.md` item 6.
5126    #[must_use]
5127    pub const fn binary(&self) -> Option<&BinaryPath> {
5128        self.binary.as_ref()
5129    }
5130
5131    /// The diagnostic log path. `05-infrastructure.md` item 4.
5132    #[must_use]
5133    pub fn log_file(&self) -> &Path {
5134        &self.log_file
5135    }
5136
5137    /// The last successful GitHub contact. Journey 5 step 4.
5138    #[must_use]
5139    pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
5140        self.last_github_contact
5141    }
5142
5143    /// Which store the daemon reads, and whether that agrees with the
5144    /// registration.
5145    #[must_use]
5146    pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
5147        self.store.as_ref()
5148    }
5149
5150    /// The record `install` wrote.
5151    #[must_use]
5152    pub const fn record(&self) -> Option<&InstallRecord> {
5153        self.record.as_ref()
5154    }
5155
5156    /// What the service manager says.
5157    #[must_use]
5158    pub const fn registration(&self) -> Option<&Registration> {
5159        self.registration.as_ref()
5160    }
5161}
5162
5163impl fmt::Display for ServiceStatus {
5164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5165        writeln!(f, "Service: {}", self.identity)?;
5166        match (&self.record, &self.registration) {
5167            (None, None) => {
5168                writeln!(
5169                    f,
5170                    "  installed                 no. `service install` registers `{} {}`.",
5171                    SERVICE_NAME,
5172                    DAEMON_ARGUMENTS.join(" ")
5173                )?;
5174            }
5175            _ => {
5176                let manager = self
5177                    .registration
5178                    .as_ref()
5179                    .map(|registration| registration.manager.manager());
5180                writeln!(
5181                    f,
5182                    "  installed                 {}",
5183                    manager.unwrap_or("yes, but no service manager knows it")
5184                )?;
5185                writeln!(
5186                    f,
5187                    "  state                     {}",
5188                    if self.is_running() {
5189                        "running"
5190                    } else {
5191                        "not running"
5192                    }
5193                )?;
5194            }
5195        }
5196        if let Some(record) = &self.record {
5197            writeln!(f, "  start mode                {}", record.start_mode)?;
5198            writeln!(f, "  account                   {}", record.account)?;
5199            writeln!(f, "  restart on failure        {}", record.restart())?;
5200            writeln!(
5201                f,
5202                "  arguments                 {}",
5203                record.arguments.join(" ")
5204            )?;
5205        }
5206        if let Some(binary) = &self.binary {
5207            writeln!(f, "  binary                    {binary}")?;
5208        }
5209        writeln!(f, "  diagnostic log            {}", self.log_file.display())?;
5210        if let Some((path, report)) = &self.runner_root {
5211            // Named as the *default* rather than as "the runner root", because
5212            // it is only the effective one until an operator runs
5213            // `host set-runtime-root`. This crate cannot see that setting — it
5214            // lives in the application's store — so an unqualified label here
5215            // would contradict the `runner root … (host-configured)` row that
5216            // `status` and `host show` print from the value that is in force.
5217            writeln!(f, "  default runner root       {}", path.display())?;
5218            if *report != RootAccessReport::NotApplicable {
5219                writeln!(f, "  default root access       {report}")?;
5220            }
5221        }
5222        if let Some(store) = &self.store {
5223            writeln!(f, "  secret store              {store}")?;
5224        }
5225        writeln!(
5226            f,
5227            "  last GitHub contact       {}",
5228            match self.last_github_contact {
5229                Some(at) => at.to_rfc3339(),
5230                None => "never".to_string(),
5231            }
5232        )?;
5233        for note in &self.notes {
5234            writeln!(f, "  note                      {note}")?;
5235        }
5236        for problem in &self.problems {
5237            writeln!(f, "  ERROR                     {problem}")?;
5238        }
5239        write!(
5240            f,
5241            "  verdict                   {}",
5242            if self.is_healthy() {
5243                "healthy"
5244            } else {
5245                "NOT healthy"
5246            }
5247        )
5248    }
5249}
5250
5251// ---------------------------------------------------------------------------
5252// The in-memory double
5253// ---------------------------------------------------------------------------
5254
5255/// A [`ControlFactory`] that registers nothing and remembers everything.
5256///
5257/// Public rather than `#[cfg(test)]` on purpose. `f3` builds three commands on
5258/// [`ServiceOperations`] and has to be able to test them without a service
5259/// manager, and a double that lived behind `#[cfg(test)]` here would be
5260/// invisible from another crate — which would leave `f3` either untested or
5261/// writing a second double that drifts from this one.
5262///
5263/// It is not a simulator. It stores what it was asked to register and reports it
5264/// back, which is exactly enough to exercise the logic that sits *above* a
5265/// service manager: the lock refusal, the record, the stale-path detection, the
5266/// start-mode switch, and the promise that uninstall deletes nothing else.
5267#[derive(Debug, Clone, Default)]
5268pub struct RecordingControls {
5269    state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
5270}
5271
5272#[derive(Debug, Default)]
5273struct RecordingState {
5274    registrations: BTreeMap<(StartMode, String), Registration>,
5275    definitions: BTreeMap<String, ServiceDefinition>,
5276    calls: Vec<String>,
5277    #[cfg(test)]
5278    install_failures: BTreeMap<StartMode, String>,
5279    #[cfg(test)]
5280    after_install: BTreeMap<StartMode, TestInstallSideEffect>,
5281}
5282
5283#[cfg(test)]
5284#[derive(Debug, Clone)]
5285enum TestInstallSideEffect {
5286    HideDirectory { directory: PathBuf, hidden: PathBuf },
5287}
5288
5289impl RecordingControls {
5290    /// A factory holding no registrations.
5291    #[must_use]
5292    pub fn new() -> Self {
5293        Self::default()
5294    }
5295
5296    /// Every call made through this factory, in order, as
5297    /// `"<operation> <name> (<mode>)"`.
5298    #[must_use]
5299    pub fn calls(&self) -> Vec<String> {
5300        self.state.lock().expect("not poisoned").calls.clone()
5301    }
5302
5303    /// Every registration currently held, with the domain it is in.
5304    #[must_use]
5305    pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
5306        self.state
5307            .lock()
5308            .expect("not poisoned")
5309            .registrations
5310            .iter()
5311            .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
5312            .collect()
5313    }
5314
5315    /// The definition applied for a registration, if it is held.
5316    #[must_use]
5317    pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
5318        self.state
5319            .lock()
5320            .expect("not poisoned")
5321            .definitions
5322            .get(name)
5323            .cloned()
5324    }
5325
5326    /// Edits a held registration, as something outside this product would.
5327    ///
5328    /// This is how the divergence and start-type problems are made reachable
5329    /// from a test: `sc config`, `launchctl` and `systemctl` can all change a
5330    /// registration after installation, and a status command that could not be
5331    /// shown detecting that would be a status command nobody had tested against
5332    /// the case it exists for.
5333    ///
5334    /// Does nothing when no registration of that name is held.
5335    pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
5336        let mut state = self.state.lock().expect("not poisoned");
5337        if let Some((_, registration)) = state
5338            .registrations
5339            .iter_mut()
5340            .find(|((_, held), _)| held == name)
5341        {
5342            edit(registration);
5343        }
5344    }
5345
5346    #[cfg(test)]
5347    fn fail_next_install(&self, mode: StartMode, detail: &str) {
5348        self.state
5349            .lock()
5350            .expect("not poisoned")
5351            .install_failures
5352            .insert(mode, detail.to_string());
5353    }
5354
5355    #[cfg(test)]
5356    fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
5357        self.state
5358            .lock()
5359            .expect("not poisoned")
5360            .after_install
5361            .insert(
5362                mode,
5363                TestInstallSideEffect::HideDirectory { directory, hidden },
5364            );
5365    }
5366}
5367
5368impl ControlFactory for RecordingControls {
5369    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5370        Ok(Box::new(RecordingControl {
5371            mode,
5372            state: std::sync::Arc::clone(&self.state),
5373        }))
5374    }
5375}
5376
5377#[derive(Debug)]
5378struct RecordingControl {
5379    mode: StartMode,
5380    state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
5381}
5382
5383impl RecordingControl {
5384    fn note(&self, operation: &str, name: &str) {
5385        self.state
5386            .lock()
5387            .expect("not poisoned")
5388            .calls
5389            .push(format!("{operation} {name} ({})", self.mode));
5390    }
5391}
5392
5393impl ServiceControl for RecordingControl {
5394    fn manager(&self) -> DefinitionKind {
5395        // The double reports the kind this host's real backend would, so a test
5396        // that asserts on the manager name asserts something true of the
5397        // platform it is running on.
5398        host_definition_kind(self.mode)
5399    }
5400
5401    fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5402        self.note("install", plan.identity().name());
5403        #[cfg(test)]
5404        if let Some(detail) = self
5405            .state
5406            .lock()
5407            .expect("not poisoned")
5408            .install_failures
5409            .remove(&self.mode)
5410        {
5411            return Err(ServiceError::Control {
5412                operation: "install",
5413                name: plan.identity().name().to_string(),
5414                manager: "recording control",
5415                detail,
5416            });
5417        }
5418        // The real definition, not a stub. Rendering is pure, so the double can
5419        // afford it -- and a stub would make `Installed::review` report that the
5420        // definition confirms nothing, which is exactly the shape of false
5421        // assurance a test double must never hand back.
5422        let definition = ServiceDefinition::for_host(plan)?;
5423        let mut state = self.state.lock().expect("not poisoned");
5424        state.registrations.insert(
5425            (self.mode, plan.identity().name().to_string()),
5426            Registration {
5427                manager: host_definition_kind(self.mode),
5428                start_mode: self.mode,
5429                command_line: plan.command_line(),
5430                account: Some(plan.account().as_str().to_string()),
5431                running: false,
5432                starts_automatically: true,
5433                restart_delay: Some(plan.restart().delay()),
5434            },
5435        );
5436        state
5437            .definitions
5438            .insert(plan.identity().name().to_string(), definition.clone());
5439        #[cfg(test)]
5440        let side_effect = state.after_install.remove(&self.mode);
5441        drop(state);
5442        #[cfg(test)]
5443        if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
5444            std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
5445            std::fs::write(&directory, b"blocks recreation")
5446                .expect("test fault can block record directory recreation");
5447        }
5448        Ok(definition)
5449    }
5450
5451    fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5452        self.note("uninstall", identity.name());
5453        let mut state = self.state.lock().expect("not poisoned");
5454        state.definitions.remove(identity.name());
5455        Ok(state
5456            .registrations
5457            .remove(&(self.mode, identity.name().to_string()))
5458            .is_some())
5459    }
5460
5461    fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
5462        self.note("query", identity.name());
5463        Ok(self
5464            .state
5465            .lock()
5466            .expect("not poisoned")
5467            .registrations
5468            .get(&(self.mode, identity.name().to_string()))
5469            .cloned())
5470    }
5471
5472    fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
5473        self.note("start", identity.name());
5474        let mut state = self.state.lock().expect("not poisoned");
5475        match state
5476            .registrations
5477            .get_mut(&(self.mode, identity.name().to_string()))
5478        {
5479            Some(registration) => {
5480                registration.running = true;
5481                Ok(())
5482            }
5483            None => Err(ServiceError::NotInstalled {
5484                name: identity.name().to_string(),
5485                operation: "start",
5486            }),
5487        }
5488    }
5489
5490    fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5491        self.note("stop", identity.name());
5492        let mut state = self.state.lock().expect("not poisoned");
5493        match state
5494            .registrations
5495            .get_mut(&(self.mode, identity.name().to_string()))
5496        {
5497            Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
5498            None => Err(ServiceError::NotInstalled {
5499                name: identity.name().to_string(),
5500                operation: "stop",
5501            }),
5502        }
5503    }
5504}
5505
5506impl ControlFactory for HostControls {
5507    fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5508        sys::control(mode)
5509    }
5510
5511    fn installed_definition(&self, path: &Path) -> Option<String> {
5512        std::fs::read_to_string(path).ok()
5513    }
5514}
5515
5516/// The operator's home directory, when the platform reports one.
5517///
5518/// Only login-mode registrations need it — a LaunchAgent and a systemd user
5519/// unit both live under it — and it is resolved lazily so that a boot-mode
5520/// operation on an account with no profile is not refused for wanting something
5521/// it never uses.
5522fn host_home() -> Option<PathBuf> {
5523    directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
5524}
5525
5526/// The Unix backends' name for the same thing.
5527#[cfg(unix)]
5528fn home_directory() -> Option<PathBuf> {
5529    host_home()
5530}
5531
5532/// Runs a command and returns its exit status with both streams as text.
5533///
5534/// The three Unix backends and the Windows Task Scheduler backend all drive a
5535/// stock command-line tool, and all four want the same three things back. The
5536/// tools are chosen for machine-readable, locale-independent output wherever one
5537/// exists — `systemctl is-active`, `launchctl print`, `schtasks /XML` — because
5538/// parsing a localised human-facing table is how a status command starts lying
5539/// on somebody else's machine.
5540fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
5541    let output = std::process::Command::new(program)
5542        .args(arguments)
5543        .output()?;
5544    Ok((
5545        output.status.success(),
5546        String::from_utf8_lossy(&output.stdout).into_owned(),
5547        String::from_utf8_lossy(&output.stderr).into_owned(),
5548    ))
5549}
5550
5551/// Which manager holds a given start-mode domain on the platform this binary
5552/// was built for.
5553#[must_use]
5554pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
5555    if cfg!(windows) {
5556        match mode {
5557            StartMode::Boot => DefinitionKind::WindowsService,
5558            StartMode::Login => DefinitionKind::WindowsScheduledTask,
5559        }
5560    } else if cfg!(target_os = "macos") {
5561        DefinitionKind::LaunchdPlist
5562    } else {
5563        DefinitionKind::SystemdUnit
5564    }
5565}
5566
5567/// Enables a launchd label after it has been bootstrapped.
5568///
5569/// `launchctl bootstrap` deliberately preserves a label's disabled bit. An
5570/// `enable` failure therefore means the new registration cannot satisfy its
5571/// start policy. Compensate by booting it out and removing the plist so a
5572/// failed install cannot look installed to `service status`.
5573#[cfg(any(target_os = "macos", test))]
5574fn enable_launchd_registration(
5575    mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
5576    domain: &str,
5577    service_target: &str,
5578    plist: &Path,
5579    name: &str,
5580    elevation_remedy: &'static str,
5581) -> Result<(), ServiceError> {
5582    let (enabled, cause) = launchctl(&[
5583        std::ffi::OsStr::new("enable"),
5584        std::ffi::OsStr::new(service_target),
5585    ]);
5586    if enabled {
5587        return Ok(());
5588    }
5589
5590    let (booted_out, bootout_detail) = launchctl(&[
5591        std::ffi::OsStr::new("bootout"),
5592        std::ffi::OsStr::new(service_target),
5593    ]);
5594    let removed = std::fs::remove_file(plist);
5595    if !booted_out || removed.is_err() {
5596        return Err(ServiceError::Rollback {
5597            operation: "enable launchd registration",
5598            name: name.to_string(),
5599            cause,
5600            rollback: format!(
5601                "launchctl bootout {domain}: {}; remove {}: {}",
5602                if booted_out {
5603                    "succeeded".to_string()
5604                } else {
5605                    bootout_detail
5606                },
5607                plist.display(),
5608                removed
5609                    .err()
5610                    .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
5611            ),
5612        });
5613    }
5614
5615    if cause.to_ascii_lowercase().contains("permission denied") {
5616        Err(ServiceError::NeedsElevation {
5617            operation: "enable",
5618            name: name.to_string(),
5619            detail: cause,
5620            remedy: elevation_remedy,
5621        })
5622    } else {
5623        Err(ServiceError::Control {
5624            operation: "enable",
5625            name: name.to_string(),
5626            manager: "launchd",
5627            detail: cause,
5628        })
5629    }
5630}
5631
5632// ---------------------------------------------------------------------------
5633// Windows
5634// ---------------------------------------------------------------------------
5635
5636/// A stop request delivered by the Windows Service Control Manager.
5637///
5638/// The application owns the drain policy; this platform boundary only turns
5639/// `SERVICE_CONTROL_STOP`/`SHUTDOWN` into an awaitable notification.
5640#[derive(Debug, Clone)]
5641pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
5642
5643impl ServiceShutdown {
5644    /// Waits until SCM asks the service to stop or shut down.
5645    pub async fn wait(mut self) {
5646        if !*self.0.borrow() {
5647            let _ = self.0.changed().await;
5648        }
5649    }
5650}
5651
5652/// Runs the production process as a real Windows service host.
5653///
5654/// `StartServiceCtrlDispatcher` must run in the service process's main thread,
5655/// so the callback is handed through a process-global slot to the entrypoint
5656/// invoked by SCM. The slot is single-use by design: one process hosts exactly
5657/// one own-process service.
5658#[cfg(windows)]
5659pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
5660where
5661    F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
5662{
5663    windows_host::run(Box::new(run))
5664}
5665
5666#[cfg(windows)]
5667mod windows_host {
5668    use std::ffi::OsString;
5669    use std::sync::{Arc, Mutex, OnceLock, mpsc};
5670    use std::time::Duration;
5671
5672    use windows_service::service::{
5673        ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
5674        ServiceType,
5675    };
5676    use windows_service::service_control_handler::{
5677        self, ServiceControlHandlerResult, ServiceStatusHandle,
5678    };
5679
5680    use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
5681
5682    type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
5683
5684    struct Invocation {
5685        run: Runner,
5686        result: mpsc::SyncSender<Result<u8, String>>,
5687    }
5688
5689    static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
5690
5691    windows_service::define_windows_service!(ffi_service_main, service_main);
5692
5693    pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
5694        let (result_tx, result_rx) = mpsc::sync_channel(1);
5695        let slot = INVOCATION.get_or_init(|| Mutex::new(None));
5696        let mut invocation = slot
5697            .lock()
5698            .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
5699        if invocation.is_some() {
5700            return Err(host_error(
5701                "prepare",
5702                "the service-host slot was already used",
5703            ));
5704        }
5705        *invocation = Some(Invocation {
5706            run,
5707            result: result_tx,
5708        });
5709        drop(invocation);
5710
5711        windows_service::service_dispatcher::start("", ffi_service_main)
5712            .map_err(|error| host_error("connect", &error.to_string()))?;
5713        result_rx
5714            .recv()
5715            .map_err(|error| host_error("finish", &error.to_string()))?
5716            .map_err(|detail| host_error("run", &detail))
5717    }
5718
5719    fn service_main(_arguments: Vec<OsString>) {
5720        let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
5721            return;
5722        };
5723        let result = run_service(invocation.run);
5724        let _ = invocation.result.send(result);
5725    }
5726
5727    fn run_service(run: Runner) -> Result<u8, String> {
5728        let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
5729        let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
5730        let handler_status = Arc::clone(&status);
5731        let handler = move |control| match control {
5732            ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
5733            ServiceControl::Stop | ServiceControl::Shutdown => {
5734                if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
5735                    let _ = handle.set_service_status(service_status(
5736                        ServiceState::StopPending,
5737                        ServiceControlAccept::empty(),
5738                        1,
5739                        Duration::from_secs(300),
5740                        0,
5741                    ));
5742                }
5743                let _ = stop_tx.send(true);
5744                ServiceControlHandlerResult::NoError
5745            }
5746            _ => ServiceControlHandlerResult::NotImplemented,
5747        };
5748        let handle = service_control_handler::register("", handler)
5749            .map_err(|error| format!("cannot register the service control handler: {error}"))?;
5750        *status
5751            .lock()
5752            .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
5753        handle
5754            .set_service_status(service_status(
5755                ServiceState::Running,
5756                ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
5757                0,
5758                Duration::default(),
5759                0,
5760            ))
5761            .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
5762
5763        let exit = run(ServiceShutdown(stop_rx));
5764        handle
5765            .set_service_status(service_status(
5766                ServiceState::Stopped,
5767                ServiceControlAccept::empty(),
5768                0,
5769                Duration::default(),
5770                u32::from(exit),
5771            ))
5772            .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
5773        Ok(exit)
5774    }
5775
5776    fn service_status(
5777        state: ServiceState,
5778        accepted: ServiceControlAccept,
5779        checkpoint: u32,
5780        wait_hint: Duration,
5781        exit: u32,
5782    ) -> ServiceStatus {
5783        ServiceStatus {
5784            service_type: ServiceType::OWN_PROCESS,
5785            current_state: state,
5786            controls_accepted: accepted,
5787            exit_code: if exit == 0 {
5788                ServiceExitCode::Win32(0)
5789            } else {
5790                ServiceExitCode::ServiceSpecific(exit)
5791            },
5792            checkpoint,
5793            wait_hint,
5794            process_id: None,
5795        }
5796    }
5797
5798    fn host_error(operation: &'static str, detail: &str) -> ServiceError {
5799        ServiceError::Control {
5800            operation,
5801            name: SERVICE_NAME.to_string(),
5802            manager: "the Windows Service Control Manager",
5803            detail: detail.to_string(),
5804        }
5805    }
5806}
5807
5808#[cfg(windows)]
5809mod sys {
5810    //! Two managers, because Windows has two answers.
5811    //!
5812    //! `--start-at boot` is a service in the Service Control Manager, which is
5813    //! the only Windows facility that starts something before anybody logs in.
5814    //! `--start-at login` is a Task Scheduler task with a logon trigger, which
5815    //! is the only Windows facility that starts something when somebody does.
5816    //! There is no single mechanism that does both: service trigger-start has no
5817    //! logon trigger, and a scheduled task cannot run before a session exists.
5818
5819    use std::ffi::{OsStr, OsString};
5820    use std::time::{Duration, Instant};
5821
5822    use runner_manager_domain::model::StartMode;
5823    use windows_service::service::{
5824        ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
5825        ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
5826        ServiceState, ServiceType,
5827    };
5828    use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
5829
5830    use super::{
5831        DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
5832        ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
5833    };
5834
5835    /// `ERROR_SERVICE_DOES_NOT_EXIST`. Not an error here: it is the answer to
5836    /// "is this registered".
5837    const SERVICE_DOES_NOT_EXIST: i32 = 1060;
5838    /// `ERROR_SERVICE_MARKED_FOR_DELETE`. `DeleteService` is asynchronous: the
5839    /// registration remains in this state until every open service handle is
5840    /// closed, and callers must not mistake that short window for a leak.
5841    const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
5842    /// `ERROR_ACCESS_DENIED`.
5843    const ACCESS_DENIED: i32 = 5;
5844    const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
5845    const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
5846
5847    const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
5848         choose \"Run as administrator\".";
5849
5850    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5851        Ok(match mode {
5852            StartMode::Boot => Box::new(ScmControl),
5853            StartMode::Login => Box::new(TaskControl),
5854        })
5855    }
5856
5857    // -- the Service Control Manager -----------------------------------------
5858
5859    #[derive(Debug)]
5860    struct ScmControl;
5861
5862    /// Turns a `windows-service` failure into this module's error, keeping the
5863    /// "needs elevation" case separate: an operator told "access is denied" and
5864    /// an operator told "run this elevated" are not equally well served.
5865    fn scm_error(
5866        operation: &'static str,
5867        name: &str,
5868        error: &windows_service::Error,
5869    ) -> ServiceError {
5870        let raw = match error {
5871            windows_service::Error::Winapi(io) => io.raw_os_error(),
5872            _ => None,
5873        };
5874        let detail = match error {
5875            windows_service::Error::Winapi(io) => io.to_string(),
5876            other => other.to_string(),
5877        };
5878        if raw == Some(ACCESS_DENIED) {
5879            return ServiceError::NeedsElevation {
5880                operation,
5881                name: name.to_string(),
5882                detail,
5883                remedy: ELEVATION_REMEDY,
5884            };
5885        }
5886        ServiceError::Control {
5887            operation,
5888            name: name.to_string(),
5889            manager: "the Windows Service Control Manager",
5890            detail,
5891        }
5892    }
5893
5894    fn open_manager(
5895        access: ServiceManagerAccess,
5896        operation: &'static str,
5897        name: &str,
5898    ) -> Result<ServiceManager, ServiceError> {
5899        ServiceManager::local_computer(None::<&OsStr>, access)
5900            .map_err(|error| scm_error(operation, name, &error))
5901    }
5902
5903    impl ServiceControl for ScmControl {
5904        fn manager(&self) -> DefinitionKind {
5905            DefinitionKind::WindowsService
5906        }
5907
5908        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5909            let spec = windows_service_spec(plan);
5910            let manager = open_manager(
5911                ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
5912                "install",
5913                &spec.name,
5914            )?;
5915            let info = ServiceInfo {
5916                name: OsString::from(&spec.name),
5917                display_name: OsString::from(&spec.display_name),
5918                // Never INTERACTIVE_PROCESS: see `windows_service_descriptor`.
5919                service_type: ServiceType::OWN_PROCESS,
5920                start_type: if spec.automatic_start {
5921                    ServiceStartType::AutoStart
5922                } else {
5923                    ServiceStartType::OnDemand
5924                },
5925                error_control: ServiceErrorControl::Normal,
5926                executable_path: plan.binary().to_path_buf(),
5927                launch_arguments: plan.arguments().to_vec(),
5928                dependencies: Vec::new(),
5929                // `None` is LocalSystem, which is the account `d2`'s DACL admits.
5930                account_name: spec.account.as_ref().map(OsString::from),
5931                // No password is ever taken, asked for, or stored. An account
5932                // that needed one would be an account this installer refuses.
5933                account_password: None,
5934            };
5935            let service = manager
5936                .create_service(
5937                    &info,
5938                    ServiceAccess::CHANGE_CONFIG
5939                        | ServiceAccess::QUERY_CONFIG
5940                        | ServiceAccess::QUERY_STATUS
5941                        | ServiceAccess::START
5942                        | ServiceAccess::STOP
5943                        | ServiceAccess::DELETE,
5944                )
5945                .map_err(|error| scm_error("install", &spec.name, &error))?;
5946            service
5947                .set_description(&spec.description)
5948                .map_err(|error| scm_error("describe", &spec.name, &error))?;
5949            service
5950                .update_failure_actions(ServiceFailureActions {
5951                    reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
5952                    reboot_msg: None,
5953                    command: None,
5954                    // Three identical restart actions rather than one: the
5955                    // Service Control Manager applies the first action to the
5956                    // first failure, the second to the second, and the last to
5957                    // every failure after that. One action would leave the
5958                    // second and later failures unhandled, which is a service
5959                    // that comes back once and then stays down.
5960                    actions: Some(vec![
5961                        ServiceAction {
5962                            action_type: ServiceActionType::Restart,
5963                            delay: spec.restart.delay(),
5964                        };
5965                        3
5966                    ]),
5967                })
5968                .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
5969            // Without this the failure actions apply only to a crash, and a
5970            // daemon that exits non-zero is not a crash.
5971            service
5972                .set_failure_actions_on_non_crash_failures(true)
5973                .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
5974            Ok(ServiceDefinition::windows_service(plan))
5975        }
5976
5977        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5978            let manager =
5979                open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
5980            let service = match manager.open_service(
5981                identity.name(),
5982                ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
5983            ) {
5984                Ok(service) => Some(service),
5985                Err(error) if is_missing(&error) => return Ok(false),
5986                // A previous caller has already asked SCM to delete this
5987                // registration. That is the desired state, not an uninstall
5988                // failure. Wait for SCM to release the name so a following
5989                // `service install` can use it immediately.
5990                Err(error) if is_marked_for_delete(&error) => None,
5991                Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
5992            };
5993            if let Some(service) = service {
5994                // A running service can be deleted, but it lingers until it stops.
5995                // Stopping first is what makes `uninstall` followed by `install`
5996                // work in one sitting, which is what the start-mode switch needs.
5997                if let Ok(status) = service.query_status()
5998                    && status.current_state != ServiceState::Stopped
5999                {
6000                    let _ = service.stop();
6001                }
6002                match service.delete() {
6003                    Ok(()) => {}
6004                    // DeleteService is idempotent from the operator's point of
6005                    // view. Another process can mark the service between our
6006                    // OpenService and DeleteService calls.
6007                    Err(error) if is_marked_for_delete(&error) => {}
6008                    Err(error) => {
6009                        return Err(scm_error("uninstall", identity.name(), &error));
6010                    }
6011                }
6012                drop(service);
6013            }
6014
6015            // `DeleteService` marks a registration for deletion and returns;
6016            // SCM removes it only after the last service handle closes. Drop
6017            // ours before polling, then wait for the observable postcondition
6018            // promised by `uninstall`: an immediate status check must not see
6019            // a registration that is merely on its way out. This also keeps a
6020            // stop/uninstall/install sequence deterministic on busy hosts.
6021            let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
6022                match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
6023                    Ok(service) => {
6024                        drop(service);
6025                        Ok(false)
6026                    }
6027                    Err(error) if is_missing(&error) => Ok(true),
6028                    Err(error) if is_marked_for_delete(&error) => Ok(false),
6029                    Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
6030                }
6031            })?;
6032            if !absent {
6033                return Err(ServiceError::Control {
6034                    operation: "verify uninstall of",
6035                    name: identity.name().to_string(),
6036                    manager: "the Windows Service Control Manager",
6037                    detail: format!(
6038                        "the registration was still visible {} seconds after DeleteService; \
6039                         retry `service uninstall` from an elevated prompt",
6040                        DELETE_TIMEOUT.as_secs()
6041                    ),
6042                });
6043            }
6044            Ok(true)
6045        }
6046
6047        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6048            let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
6049            let service = match manager.open_service(
6050                identity.name(),
6051                ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
6052            ) {
6053                Ok(service) => service,
6054                Err(error) if is_missing(&error) => return Ok(None),
6055                Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
6056            };
6057            let config = service
6058                .query_config()
6059                .map_err(|error| scm_error("inspect", identity.name(), &error))?;
6060            let status = service
6061                .query_status()
6062                .map_err(|error| scm_error("inspect", identity.name(), &error))?;
6063            let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
6064                actions
6065                    .actions
6066                    .and_then(|actions| actions.into_iter().next())
6067                    .filter(|action| action.action_type == ServiceActionType::Restart)
6068                    .map(|action| action.delay)
6069            });
6070            Ok(Some(Registration {
6071                manager: DefinitionKind::WindowsService,
6072                start_mode: StartMode::Boot,
6073                // `lpBinaryPathName` holds the executable *and* its arguments
6074                // as one string, which is why this module carries its own
6075                // parser rather than trusting `executable_path`'s name.
6076                command_line: config.executable_path.to_string_lossy().into_owned(),
6077                account: config
6078                    .account_name
6079                    .map(|account| account.to_string_lossy().into_owned()),
6080                running: status.current_state == ServiceState::Running,
6081                starts_automatically: config.start_type == ServiceStartType::AutoStart,
6082                restart_delay,
6083            }))
6084        }
6085
6086        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6087            let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
6088            let service = manager
6089                .open_service(identity.name(), ServiceAccess::START)
6090                .map_err(|error| scm_error("start", identity.name(), &error))?;
6091            service
6092                .start::<&OsStr>(&[])
6093                .map_err(|error| scm_error("start", identity.name(), &error))
6094        }
6095
6096        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6097            let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
6098            let service = manager
6099                .open_service(
6100                    identity.name(),
6101                    ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
6102                )
6103                .map_err(|error| scm_error("stop", identity.name(), &error))?;
6104            let status = service
6105                .query_status()
6106                .map_err(|error| scm_error("stop", identity.name(), &error))?;
6107            if status.current_state == ServiceState::Stopped {
6108                return Ok(false);
6109            }
6110            service
6111                .stop()
6112                .map_err(|error| scm_error("stop", identity.name(), &error))?;
6113            Ok(true)
6114        }
6115    }
6116
6117    fn is_missing(error: &windows_service::Error) -> bool {
6118        matches!(error, windows_service::Error::Winapi(io)
6119            if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
6120    }
6121
6122    pub(super) fn is_marked_for_delete(error: &windows_service::Error) -> bool {
6123        matches!(error, windows_service::Error::Winapi(io)
6124            if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
6125    }
6126
6127    pub(super) fn wait_until_scm_absent(
6128        timeout: Duration,
6129        poll_interval: Duration,
6130        mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
6131    ) -> Result<bool, ServiceError> {
6132        let deadline = Instant::now() + timeout;
6133        loop {
6134            if probe_absent()? {
6135                return Ok(true);
6136            }
6137            if Instant::now() >= deadline {
6138                return Ok(false);
6139            }
6140            std::thread::sleep(poll_interval);
6141        }
6142    }
6143
6144    // -- Task Scheduler ------------------------------------------------------
6145
6146    #[derive(Debug)]
6147    struct TaskControl;
6148
6149    fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
6150        if detail.to_ascii_lowercase().contains("access is denied") {
6151            return ServiceError::NeedsElevation {
6152                operation,
6153                name: name.to_string(),
6154                detail,
6155                remedy: ELEVATION_REMEDY,
6156            };
6157        }
6158        ServiceError::Control {
6159            operation,
6160            name: name.to_string(),
6161            manager: "Windows Task Scheduler",
6162            detail,
6163        }
6164    }
6165
6166    fn schtasks(
6167        operation: &'static str,
6168        name: &str,
6169        arguments: &[&OsStr],
6170    ) -> Result<(bool, String), ServiceError> {
6171        match run("schtasks.exe", arguments) {
6172            Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
6173            Err(error) => Err(task_error(operation, name, error.to_string())),
6174        }
6175    }
6176
6177    /// Task Scheduler reads `/XML` files as UTF-16, so the document is written
6178    /// as UTF-16 little-endian with a byte-order mark rather than as UTF-8.
6179    fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
6180        let mut bytes = vec![0xFF, 0xFE];
6181        for unit in text.encode_utf16() {
6182            bytes.extend_from_slice(&unit.to_le_bytes());
6183        }
6184        std::fs::write(path, bytes)
6185    }
6186
6187    impl ServiceControl for TaskControl {
6188        fn manager(&self) -> DefinitionKind {
6189            DefinitionKind::WindowsScheduledTask
6190        }
6191
6192        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6193            let name = plan.identity().name().to_string();
6194            let principal = TaskPrincipal::current()?;
6195            let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
6196            let directory = tempfile::tempdir()
6197                .map_err(|error| task_error("install", &name, error.to_string()))?;
6198            let document = directory.path().join("task.xml");
6199            write_utf16(&document, definition.text())
6200                .map_err(|error| task_error("install", &name, error.to_string()))?;
6201            let (ok, message) = schtasks(
6202                "install",
6203                &name,
6204                &[
6205                    OsStr::new("/Create"),
6206                    OsStr::new("/TN"),
6207                    OsStr::new(&name),
6208                    OsStr::new("/XML"),
6209                    document.as_os_str(),
6210                ],
6211            )?;
6212            if !ok {
6213                return Err(task_error("install", &name, message));
6214            }
6215            Ok(definition)
6216        }
6217
6218        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6219            if self.query(identity)?.is_none() {
6220                return Ok(false);
6221            }
6222            let name = identity.name().to_string();
6223            let (ok, message) = schtasks(
6224                "uninstall",
6225                &name,
6226                &[
6227                    OsStr::new("/Delete"),
6228                    OsStr::new("/TN"),
6229                    OsStr::new(&name),
6230                    OsStr::new("/F"),
6231                ],
6232            )?;
6233            if !ok {
6234                return Err(task_error("uninstall", &name, message));
6235            }
6236            Ok(true)
6237        }
6238
6239        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6240            let name = identity.name().to_string();
6241            let (ok, document) = schtasks(
6242                "inspect",
6243                &name,
6244                &[
6245                    OsStr::new("/Query"),
6246                    OsStr::new("/TN"),
6247                    OsStr::new(&name),
6248                    OsStr::new("/XML"),
6249                    OsStr::new("ONE"),
6250                ],
6251            )?;
6252            if !ok {
6253                // `schtasks` reports a missing task and a broken Task Scheduler
6254                // the same way, with a non-zero exit; there is no distinct code.
6255                // Treating it as absence is the safe reading: the caller either
6256                // installs, which will then fail loudly, or reports "not
6257                // installed", which is what an operator with no task sees.
6258                return Ok(None);
6259            }
6260            let command = xml_value(&document, "Command").unwrap_or_default();
6261            let arguments = xml_value(&document, "Arguments").unwrap_or_default();
6262            let command_line = if arguments.is_empty() {
6263                super::quote_argument(&command)
6264            } else {
6265                format!("{} {arguments}", super::quote_argument(&command))
6266            };
6267            Ok(Some(Registration {
6268                manager: DefinitionKind::WindowsScheduledTask,
6269                start_mode: StartMode::Login,
6270                command_line,
6271                account: xml_value(&document, "UserId"),
6272                running: task_is_running(&name),
6273                starts_automatically: super::windows_login_task_starts_automatically(&document),
6274                restart_delay: xml_value(&document, "Interval")
6275                    .as_deref()
6276                    .and_then(parse_iso8601),
6277            }))
6278        }
6279
6280        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6281            let name = identity.name().to_string();
6282            let (ok, message) = schtasks(
6283                "start",
6284                &name,
6285                &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
6286            )?;
6287            if ok {
6288                Ok(())
6289            } else {
6290                Err(task_error("start", &name, message))
6291            }
6292        }
6293
6294        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6295            let running = self
6296                .query(identity)?
6297                .is_some_and(|registration| registration.running);
6298            if !running {
6299                return Ok(false);
6300            }
6301            let name = identity.name().to_string();
6302            let (ok, message) = schtasks(
6303                "stop",
6304                &name,
6305                &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
6306            )?;
6307            if ok {
6308                Ok(true)
6309            } else {
6310                Err(task_error("stop", &name, message))
6311            }
6312        }
6313    }
6314
6315    /// Whether Task Scheduler reports the task as running.
6316    ///
6317    /// **This is the one value in this module read from localised output.**
6318    /// `schtasks /Query /FO CSV` prints a `Status` column in the machine's
6319    /// display language, and Task Scheduler exposes no locale-independent
6320    /// equivalent short of COM. On a non-English Windows this therefore reports
6321    /// `false` for a task that is in fact running.
6322    ///
6323    /// That is stated rather than hidden because of what does *not* depend on
6324    /// it: Journey 5's liveness question is answered by the last successful
6325    /// GitHub contact, which is a timestamp this product writes itself and no
6326    /// locale can move. Nothing in the Definition of Done rests on this
6327    /// boolean.
6328    fn task_is_running(name: &str) -> bool {
6329        let Ok((true, stdout, _)) = run(
6330            "schtasks.exe",
6331            &[
6332                OsStr::new("/Query"),
6333                OsStr::new("/TN"),
6334                OsStr::new(name),
6335                OsStr::new("/FO"),
6336                OsStr::new("CSV"),
6337                OsStr::new("/NH"),
6338            ],
6339        ) else {
6340            return false;
6341        };
6342        stdout
6343            .lines()
6344            .filter_map(|line| line.rsplit(',').next())
6345            .any(|status| {
6346                status
6347                    .trim()
6348                    .trim_matches('"')
6349                    .eq_ignore_ascii_case("running")
6350            })
6351    }
6352
6353    /// Parses the `PT<n>M` shape this module writes, and the `PT<n>S` shape
6354    /// Task Scheduler would accept if it took seconds. Anything richer -- days,
6355    /// hours, a combination -- is `None` rather than a guess.
6356    fn parse_iso8601(value: &str) -> Option<Duration> {
6357        let rest = value.strip_prefix("PT")?;
6358        if let Some(minutes) = rest.strip_suffix('M') {
6359            return minutes
6360                .parse::<u64>()
6361                .ok()
6362                .map(|minutes| Duration::from_secs(minutes * 60));
6363        }
6364        rest.strip_suffix('S')?
6365            .parse::<u64>()
6366            .ok()
6367            .map(Duration::from_secs)
6368    }
6369}
6370
6371// ---------------------------------------------------------------------------
6372// Unix: shared plumbing
6373// ---------------------------------------------------------------------------
6374
6375/// Writes a definition file, creating its directory, and says when the refusal
6376/// was a permissions one.
6377///
6378/// Both Unix backends write a file into a directory only `root` can write —
6379/// `/Library/LaunchDaemons` and `/etc/systemd/system` — and an operator who ran
6380/// `service install` without `sudo` deserves to be told that rather than handed
6381/// an `EACCES`.
6382#[cfg(unix)]
6383fn write_definition(
6384    operation: &'static str,
6385    name: &str,
6386    path: &Path,
6387    text: &str,
6388    remedy: &'static str,
6389) -> Result<(), ServiceError> {
6390    if let Some(parent) = path.parent()
6391        && let Err(error) = std::fs::create_dir_all(parent)
6392        && error.kind() != std::io::ErrorKind::AlreadyExists
6393    {
6394        return Err(definition_error(operation, name, error, remedy, parent));
6395    }
6396    std::fs::write(path, text)
6397        .map_err(|error| definition_error(operation, name, error, remedy, path))
6398}
6399
6400/// Replaces an installed definition with [`DefinitionDrift::rendered`].
6401///
6402/// Through a temporary file in the same directory and a rename, so a failure
6403/// at any point leaves the old definition in place rather than a truncated
6404/// one. The mode is set to `0644` before the rename, as `service install`
6405/// leaves it: launchd refuses a plist that others can write.
6406#[cfg(all(unix, any(target_os = "macos", test)))]
6407fn replace_definition(name: &str, drift: &DefinitionDrift) -> Result<(), ServiceError> {
6408    use std::io::Write as _;
6409
6410    let path = drift.path.as_path();
6411    let directory = path.parent().unwrap_or_else(|| Path::new("/"));
6412    let failed = |error: std::io::Error, at: &Path| {
6413        definition_error("rewrite", name, error, SUDO_REMEDY, at)
6414    };
6415    let mut staged =
6416        tempfile::NamedTempFile::new_in(directory).map_err(|error| failed(error, directory))?;
6417    staged
6418        .write_all(drift.rendered.as_bytes())
6419        .and_then(|()| staged.as_file().sync_all())
6420        .map_err(|error| failed(error, staged.path()))?;
6421    std::fs::set_permissions(
6422        staged.path(),
6423        <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o644),
6424    )
6425    .map_err(|error| failed(error, path))?;
6426    staged
6427        .persist(path)
6428        .map(drop)
6429        .map_err(|error| failed(error.error, path))
6430}
6431
6432#[cfg(unix)]
6433fn definition_error(
6434    operation: &'static str,
6435    name: &str,
6436    error: std::io::Error,
6437    remedy: &'static str,
6438    path: &Path,
6439) -> ServiceError {
6440    let detail = format!("{}: {error}", path.display());
6441    if error.kind() == std::io::ErrorKind::PermissionDenied {
6442        ServiceError::NeedsElevation {
6443            operation,
6444            name: name.to_string(),
6445            detail,
6446            remedy,
6447        }
6448    } else {
6449        ServiceError::Control {
6450            operation,
6451            name: name.to_string(),
6452            manager: "the local service manager",
6453            detail,
6454        }
6455    }
6456}
6457
6458/// `sudo` is the remedy on both Unixes, and the message says which command.
6459#[cfg(unix)]
6460const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
6461     `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
6462     agent not running until you sign in.";
6463
6464// ---------------------------------------------------------------------------
6465// macOS
6466// ---------------------------------------------------------------------------
6467
6468#[cfg(target_os = "macos")]
6469mod sys {
6470    //! One manager, two domains. `--start-at boot` is a LaunchDaemon in
6471    //! `/Library/LaunchDaemons`, loaded into launchd's `system` domain;
6472    //! `--start-at login` is a LaunchAgent in the operator's own
6473    //! `~/Library/LaunchAgents`, loaded into `gui/<uid>`.
6474    //!
6475    //! `launchctl print` is the only inspection command here whose output is
6476    //! parsed, and it is English-only on every macOS release — launchd has no
6477    //! localisation for it. The registration's *existence* is decided by the
6478    //! plist file rather than by that output, so a launchd that changed its
6479    //! wording would cost this module the running/not-running line and nothing
6480    //! else.
6481
6482    use std::ffi::OsStr;
6483    use std::path::PathBuf;
6484    use std::time::Duration;
6485
6486    use runner_manager_domain::model::StartMode;
6487
6488    use super::{
6489        DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
6490        SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
6491        enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
6492        write_definition, xml_value,
6493    };
6494
6495    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6496        Ok(Box::new(LaunchdControl { mode }))
6497    }
6498
6499    #[derive(Debug)]
6500    struct LaunchdControl {
6501        mode: StartMode,
6502    }
6503
6504    impl LaunchdControl {
6505        /// launchd's domain target for this start mode.
6506        fn domain(&self) -> String {
6507            match self.mode {
6508                StartMode::Boot => "system".to_string(),
6509                // SAFETY: `getuid` reads the calling process's real user id and
6510                // cannot fail; it takes no arguments and touches no memory.
6511                StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
6512            }
6513        }
6514
6515        fn service_target(&self, identity: &ServiceIdentity) -> String {
6516            format!("{}/{}", self.domain(), identity.launchd_label())
6517        }
6518
6519        /// Where this domain's plist lives, when the domain has a home to put
6520        /// it in.
6521        fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
6522            let file = format!("{}.plist", identity.launchd_label());
6523            match self.mode {
6524                StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
6525                StartMode::Login => {
6526                    home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
6527                }
6528            }
6529        }
6530
6531        fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
6532            ServiceError::Control {
6533                operation,
6534                name: name.to_string(),
6535                manager: "launchd",
6536                detail,
6537            }
6538        }
6539
6540        fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
6541            match run("launchctl", arguments) {
6542                Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
6543                Err(error) => (false, error.to_string()),
6544            }
6545        }
6546    }
6547
6548    impl ServiceControl for LaunchdControl {
6549        fn manager(&self) -> DefinitionKind {
6550            DefinitionKind::LaunchdPlist
6551        }
6552
6553        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6554            let name = plan.identity().name().to_string();
6555            let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
6556            let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
6557                return Err(self.failed(
6558                    "install",
6559                    &name,
6560                    "this account has no home directory, so there is nowhere to put a \
6561                     LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
6562                     /Library/LaunchDaemons."
6563                        .to_string(),
6564                ));
6565            };
6566            write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
6567            let target = self.domain();
6568            let (ok, message) = self.launchctl(&[
6569                OsStr::new("bootstrap"),
6570                OsStr::new(&target),
6571                path.as_os_str(),
6572            ]);
6573            if !ok {
6574                // Leave nothing behind: a plist launchd refused is a file that
6575                // would make the next `service status` claim a registration
6576                // that does not exist.
6577                let _ = std::fs::remove_file(&path);
6578                if message.to_ascii_lowercase().contains("permission denied") {
6579                    return Err(ServiceError::NeedsElevation {
6580                        operation: "install",
6581                        name,
6582                        detail: message,
6583                        remedy: SUDO_REMEDY,
6584                    });
6585                }
6586                return Err(self.failed("install", &name, message));
6587            }
6588            // A previously disabled label stays disabled through a bootstrap,
6589            // which is a service that is installed and will never start.
6590            let service_target = self.service_target(plan.identity());
6591            enable_launchd_registration(
6592                |arguments| self.launchctl(arguments),
6593                &target,
6594                &service_target,
6595                &path,
6596                &name,
6597                SUDO_REMEDY,
6598            )?;
6599            Ok(definition)
6600        }
6601
6602        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6603            let Some(path) = self.plist_path(identity) else {
6604                return Ok(false);
6605            };
6606            if !path.exists() {
6607                return Ok(false);
6608            }
6609            let target = self.service_target(identity);
6610            let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
6611            if !ok
6612                && !message.to_ascii_lowercase().contains("no such process")
6613                && !message.contains("113")
6614            {
6615                if message.to_ascii_lowercase().contains("permission denied") {
6616                    return Err(ServiceError::NeedsElevation {
6617                        operation: "uninstall",
6618                        name: identity.name().to_string(),
6619                        detail: message,
6620                        remedy: SUDO_REMEDY,
6621                    });
6622                }
6623                return Err(self.failed("uninstall", identity.name(), message));
6624            }
6625            // Only the definition. `05-infrastructure.md` item 5: nothing else
6626            // on this host is touched here or anywhere below it.
6627            std::fs::remove_file(&path).map_err(|error| {
6628                super::definition_error(
6629                    "uninstall",
6630                    identity.name(),
6631                    error,
6632                    SUDO_REMEDY,
6633                    path.as_path(),
6634                )
6635            })?;
6636            Ok(true)
6637        }
6638
6639        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6640            let Some(path) = self.plist_path(identity) else {
6641                return Ok(None);
6642            };
6643            let Ok(document) = std::fs::read_to_string(&path) else {
6644                return Ok(None);
6645            };
6646            let target = self.service_target(identity);
6647            let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
6648            Ok(Some(Registration {
6649                manager: DefinitionKind::LaunchdPlist,
6650                start_mode: self.mode,
6651                command_line: program_arguments(&document),
6652                account: plist_string_value(&document, "UserName")
6653                    .or_else(|| Some("the invoking user".to_string())),
6654                running: loaded && printed.contains("state = running"),
6655                starts_automatically: document.contains("<key>RunAtLoad</key>")
6656                    && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
6657                restart_delay: xml_value(
6658                    super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
6659                    "integer",
6660                )
6661                .and_then(|value| value.parse::<u64>().ok())
6662                .map(Duration::from_secs),
6663            }))
6664        }
6665
6666        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6667            let target = self.service_target(identity);
6668            let (ok, message) = self.launchctl(&[
6669                OsStr::new("kickstart"),
6670                OsStr::new("-k"),
6671                OsStr::new(&target),
6672            ]);
6673            if ok {
6674                Ok(())
6675            } else {
6676                Err(self.failed("start", identity.name(), message))
6677            }
6678        }
6679
6680        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6681            let running = self
6682                .query(identity)?
6683                .is_some_and(|registration| registration.running);
6684            if !running {
6685                return Ok(false);
6686            }
6687            let target = self.service_target(identity);
6688            let (ok, message) = self.launchctl(&[
6689                OsStr::new("kill"),
6690                OsStr::new("SIGTERM"),
6691                OsStr::new(&target),
6692            ]);
6693            if ok {
6694                Ok(true)
6695            } else {
6696                Err(self.failed("stop", identity.name(), message))
6697            }
6698        }
6699    }
6700
6701    /// Boots the job out and bootstraps it again from `plist`, from a shell
6702    /// that outlives this process.
6703    ///
6704    /// `setsid` puts the shell in a session and process group of its own. On a
6705    /// bootout launchd sends SIGTERM to the job's whole process group, the
6706    /// process that asked for the bootout included, and spares a child in
6707    /// another session (measured on macOS 27). Without `setsid` the helper
6708    /// survived that only by luck: one run in four left the job unloaded. The
6709    /// work runs in a background subshell, so the outer shell exits at once and
6710    /// is reaped here rather than left behind. `bootout` blocks until the daemon
6711    /// has exited; the bootstrap is retried for a minute because launchd
6712    /// refuses it while the old job is still being torn down. When the job is
6713    /// still loaded after a failed bootout, every bootstrap fails and the
6714    /// running daemon carries on as it was.
6715    pub(super) fn reload_detached(
6716        identity: &ServiceIdentity,
6717        mode: StartMode,
6718        plist: &std::path::Path,
6719    ) -> Result<(), ServiceError> {
6720        use std::os::unix::process::CommandExt;
6721
6722        const SCRIPT: &str = r#"(
6723  sleep 1
6724  /bin/launchctl bootout "$1"
6725  tries=0
6726  until /bin/launchctl bootstrap "$2" "$3"; do
6727    tries=$((tries + 1))
6728    if [ "$tries" -ge 60 ]; then
6729      echo "runner-manager: launchd did not load $3 again; run: $4" >&2
6730      exit 1
6731    fi
6732    sleep 1
6733  done
6734) </dev/null &"#;
6735
6736        let control = LaunchdControl { mode };
6737        // Once the job is booted out nothing holds the single-instance lock,
6738        // so a reinstall in the same start mode is the remedy.
6739        let reinstall = match mode {
6740            StartMode::Boot => "sudo runner-manager service install --start-at boot",
6741            StartMode::Login => "runner-manager service install --start-at login",
6742        };
6743        let mut command = std::process::Command::new("/bin/sh");
6744        command
6745            .args(["-c", SCRIPT, "runner-manager-reload"])
6746            .arg(control.service_target(identity))
6747            .arg(control.domain())
6748            .arg(plist)
6749            .arg(reinstall)
6750            .stdin(std::process::Stdio::null());
6751        // SAFETY: one async-signal-safe system call between `fork` and `exec`,
6752        // touching no memory, allocator or lock.
6753        unsafe {
6754            command.pre_exec(|| {
6755                if libc::setsid() == -1 {
6756                    return Err(std::io::Error::last_os_error());
6757                }
6758                Ok(())
6759            });
6760        }
6761        let status = command.status().map_err(|error| {
6762            control.failed("reload", identity.name(), format!("/bin/sh: {error}"))
6763        })?;
6764        if status.success() {
6765            Ok(())
6766        } else {
6767            Err(control.failed(
6768                "reload",
6769                identity.name(),
6770                format!("the reload helper exited with {status}"),
6771            ))
6772        }
6773    }
6774
6775    /// Rebuilds the command line from a plist's `ProgramArguments` array.
6776    fn program_arguments(document: &str) -> String {
6777        let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
6778            return String::new();
6779        };
6780        let Some(end) = rest.find("</array>") else {
6781            return String::new();
6782        };
6783        let mut out = Vec::new();
6784        let mut cursor = &rest[..end];
6785        while let Some(open) = cursor.find("<string>") {
6786            let after = &cursor[open + "<string>".len()..];
6787            let Some(close) = after.find("</string>") else {
6788                break;
6789            };
6790            out.push(quote_argument(&super::xml_unescape(&after[..close])));
6791            cursor = &after[close..];
6792        }
6793        out.join(" ")
6794    }
6795}
6796
6797// ---------------------------------------------------------------------------
6798// Linux and the other Unixes
6799// ---------------------------------------------------------------------------
6800
6801#[cfg(all(unix, not(target_os = "macos")))]
6802mod sys {
6803    //! One manager, two domains. `--start-at boot` is a system unit in
6804    //! `/etc/systemd/system`, wanted by `multi-user.target`; `--start-at login`
6805    //! is a user unit in `~/.config/systemd/user`, wanted by `default.target`.
6806    //!
6807    //! Every inspection here goes through `systemctl is-active` and
6808    //! `systemctl is-enabled`, whose output is a fixed machine-readable word
6809    //! rather than a sentence, so nothing in this module depends on the
6810    //! machine's display language.
6811
6812    use std::ffi::OsStr;
6813    use std::path::PathBuf;
6814    use std::time::Duration;
6815
6816    use runner_manager_domain::model::StartMode;
6817
6818    use super::{
6819        DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
6820        SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
6821        home_directory, ini_directives, run, write_definition,
6822    };
6823
6824    pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6825        Ok(Box::new(SystemdControl { mode }))
6826    }
6827
6828    #[derive(Debug)]
6829    struct SystemdControl {
6830        mode: StartMode,
6831    }
6832
6833    impl SystemdControl {
6834        fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
6835            let file = identity.systemd_unit();
6836            match self.mode {
6837                StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
6838                StartMode::Login => {
6839                    home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
6840                }
6841            }
6842        }
6843
6844        /// `systemctl`, with `--user` for the login domain.
6845        fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
6846            let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
6847            if self.mode == StartMode::Login {
6848                all.push(OsStr::new("--user"));
6849            }
6850            all.extend(arguments.iter().map(OsStr::new));
6851            match run("systemctl", &all) {
6852                Ok((ok, stdout, stderr)) => (
6853                    ok,
6854                    if stdout.trim().is_empty() {
6855                        stderr
6856                    } else {
6857                        stdout
6858                    },
6859                ),
6860                Err(error) => (false, error.to_string()),
6861            }
6862        }
6863
6864        fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
6865            ServiceError::Control {
6866                operation,
6867                name: name.to_string(),
6868                manager: "systemd",
6869                detail,
6870            }
6871        }
6872    }
6873
6874    impl ServiceControl for SystemdControl {
6875        fn manager(&self) -> DefinitionKind {
6876            DefinitionKind::SystemdUnit
6877        }
6878
6879        fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6880            let name = plan.identity().name().to_string();
6881            let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
6882            let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
6883                return Err(self.failed(
6884                    "install",
6885                    &name,
6886                    "this account has no home directory, so there is nowhere to put a systemd \
6887                     user unit. Use --start-at boot, which installs a system unit under \
6888                     /etc/systemd/system."
6889                        .to_string(),
6890                ));
6891            };
6892            write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
6893            let unit = plan.identity().systemd_unit();
6894            let (reloaded, message) = self.systemctl(&["daemon-reload"]);
6895            if !reloaded {
6896                let _ = std::fs::remove_file(&path);
6897                return Err(self.failed("install", &name, message));
6898            }
6899            let (enabled, message) = self.systemctl(&["enable", &unit]);
6900            if !enabled {
6901                let _ = std::fs::remove_file(&path);
6902                let _ = self.systemctl(&["daemon-reload"]);
6903                return Err(self.failed("install", &name, message));
6904            }
6905            Ok(definition)
6906        }
6907
6908        fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6909            let Some(path) = self.unit_path(identity) else {
6910                return Ok(false);
6911            };
6912            if !path.exists() {
6913                return Ok(false);
6914            }
6915            let unit = identity.systemd_unit();
6916            // `--now` stops it as well as disabling it. A failure here is not
6917            // fatal: the unit file is about to go, and refusing to remove it
6918            // because `systemctl` disliked something would leave the host with
6919            // a unit nothing manages.
6920            let _ = self.systemctl(&["disable", "--now", &unit]);
6921            std::fs::remove_file(&path).map_err(|error| {
6922                super::definition_error(
6923                    "uninstall",
6924                    identity.name(),
6925                    error,
6926                    SUDO_REMEDY,
6927                    path.as_path(),
6928                )
6929            })?;
6930            let _ = self.systemctl(&["daemon-reload"]);
6931            Ok(true)
6932        }
6933
6934        fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6935            let Some(path) = self.unit_path(identity) else {
6936                return Ok(None);
6937            };
6938            let Ok(document) = std::fs::read_to_string(&path) else {
6939                return Ok(None);
6940            };
6941            let unit = identity.systemd_unit();
6942            let directives = ini_directives(&document, "Service");
6943            let (_, active) = self.systemctl(&["is-active", &unit]);
6944            let (_, enabled) = self.systemctl(&["is-enabled", &unit]);
6945            Ok(Some(Registration {
6946                manager: DefinitionKind::SystemdUnit,
6947                start_mode: self.mode,
6948                command_line: directives.get("ExecStart").cloned().unwrap_or_default(),
6949                account: directives.get("User").cloned().or_else(|| {
6950                    Some(match self.mode {
6951                        StartMode::Boot => "root".to_string(),
6952                        StartMode::Login => "the invoking user".to_string(),
6953                    })
6954                }),
6955                running: active.trim() == "active",
6956                starts_automatically: enabled.trim() == "enabled",
6957                restart_delay: directives
6958                    .get("RestartSec")
6959                    .and_then(|value| value.trim().trim_end_matches('s').parse::<u64>().ok())
6960                    .map(Duration::from_secs),
6961            }))
6962        }
6963
6964        fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6965            let unit = identity.systemd_unit();
6966            let (ok, message) = self.systemctl(&["start", &unit]);
6967            if ok {
6968                Ok(())
6969            } else {
6970                Err(self.failed("start", identity.name(), message))
6971            }
6972        }
6973
6974        fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6975            let running = self
6976                .query(identity)?
6977                .is_some_and(|registration| registration.running);
6978            if !running {
6979                return Ok(false);
6980            }
6981            let unit = identity.systemd_unit();
6982            let (ok, message) = self.systemctl(&["stop", &unit]);
6983            if ok {
6984                Ok(true)
6985            } else {
6986                Err(self.failed("stop", identity.name(), message))
6987            }
6988        }
6989    }
6990}
6991
6992/// What the macOS tests that load real launch agents share.
6993#[cfg(all(test, target_os = "macos"))]
6994pub(crate) mod launchd_fixture {
6995    use std::path::{Path, PathBuf};
6996
6997    /// Opts a macOS host into the tests that load launch agents into the
6998    /// caller's GUI session. CI's macOS leg sets it, and
6999    /// `privileged_tests_are_wired_into_ci.rs` asserts that it still does.
7000    const ACCEPTANCE: &str = "RUNNER_MANAGER_LAUNCHD_ACCEPTANCE";
7001
7002    /// Whether this run may load launch agents. Says so when it may not, since
7003    /// the test then passes having measured nothing.
7004    pub(crate) fn enabled() -> bool {
7005        let enabled = std::env::var_os(ACCEPTANCE).is_some_and(|value| value == "1");
7006        if !enabled {
7007            eprintln!("skipped: set {ACCEPTANCE}=1 to load test launch agents");
7008        }
7009        enabled
7010    }
7011
7012    /// A copy of the running test binary in `directory`, for a launchd job to
7013    /// run as its program.
7014    ///
7015    /// A copy, because a launchd job whose program lives on an external volume
7016    /// waits on a privacy approval nobody gives before `main` runs (measured
7017    /// with a target directory under `/Volumes`), and the temporary directory
7018    /// is on the boot volume.
7019    pub(crate) fn job_binary(directory: &Path) -> PathBuf {
7020        let binary = directory.join("launch-agent-job");
7021        std::fs::copy(std::env::current_exe().expect("test binary"), &binary)
7022            .expect("test binary copied");
7023        binary
7024    }
7025
7026    /// A launch agent loaded into the caller's GUI domain, booted out on drop.
7027    pub(crate) struct LoadedAgent(String);
7028
7029    impl LoadedAgent {
7030        /// Bootstraps `plist`, whose `Label` is `label`, and fails the test when
7031        /// launchd refuses it.
7032        pub(crate) fn bootstrap(label: &str, plist: &Path) -> Self {
7033            // SAFETY: `getuid` reads the calling process's real user id and
7034            // cannot fail.
7035            let domain = format!("gui/{}", unsafe { libc::getuid() });
7036            // Armed before the bootstrap, so a half-loaded job is removed too.
7037            let loaded = Self(format!("{domain}/{label}"));
7038            let bootstrap = std::process::Command::new("launchctl")
7039                .arg("bootstrap")
7040                .arg(&domain)
7041                .arg(plist)
7042                .output()
7043                .expect("launchctl runs");
7044            assert!(
7045                bootstrap.status.success(),
7046                "launchctl bootstrap {domain} refused {label}, so nothing was measured: {}",
7047                String::from_utf8_lossy(&bootstrap.stderr)
7048            );
7049            loaded
7050        }
7051
7052        /// `gui/<uid>/<label>`.
7053        pub(crate) fn target(&self) -> &str {
7054            &self.0
7055        }
7056    }
7057
7058    impl Drop for LoadedAgent {
7059        fn drop(&mut self) {
7060            let _ = std::process::Command::new("launchctl")
7061                .args(["bootout", &self.0])
7062                .output();
7063        }
7064    }
7065}
7066
7067#[cfg(test)]
7068mod tests {
7069    use super::*;
7070
7071    use std::collections::BTreeMap;
7072
7073    #[test]
7074    fn task_scheduler_omitting_default_enabled_still_means_automatic() {
7075        assert!(windows_login_task_starts_automatically(
7076            "<Task><Triggers><LogonTrigger></LogonTrigger></Triggers></Task>"
7077        ));
7078        assert!(windows_login_task_starts_automatically(
7079            "<Task><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Task>"
7080        ));
7081        assert!(!windows_login_task_starts_automatically(
7082            "<Task><LogonTrigger><Enabled>false</Enabled></LogonTrigger></Task>"
7083        ));
7084        assert!(!windows_login_task_starts_automatically(
7085            "<Task><BootTrigger></BootTrigger></Task>"
7086        ));
7087    }
7088
7089    // -----------------------------------------------------------------------
7090    // Fixtures
7091    // -----------------------------------------------------------------------
7092
7093    /// A plan against paths that exist on no platform, so that every renderer
7094    /// can be asserted on every leg of the CI matrix.
7095    fn linux_plan(mode: StartMode) -> InstallPlan {
7096        InstallPlan::unchecked(
7097            ServiceIdentity::product(),
7098            mode,
7099            "/opt/runner-manager/bin/runner-manager",
7100            ServiceDirectories {
7101                config: PathBuf::from("/var/lib/runner-manager/config"),
7102                state: PathBuf::from("/var/lib/runner-manager/state"),
7103                runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
7104                logs: PathBuf::from("/var/log/runner-manager"),
7105            },
7106        )
7107        .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
7108    }
7109
7110    fn windows_plan(mode: StartMode) -> InstallPlan {
7111        InstallPlan::unchecked(
7112            ServiceIdentity::product(),
7113            mode,
7114            "C:\\Program Files\\runner-manager\\runner-manager.exe",
7115            ServiceDirectories {
7116                config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
7117                state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
7118                runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
7119                logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
7120            },
7121        )
7122    }
7123
7124    /// Replaces one fragment of a rendered definition, and **fails the test if
7125    /// the fragment was not there**.
7126    ///
7127    /// Without the assertion a renderer change would quietly turn every
7128    /// "widened definition is rejected" test below into a test that reviews the
7129    /// unmodified definition and passes for the wrong reason. This is the
7130    /// single guard that keeps that class of vacuity out of this file.
7131    fn edited(text: &str, from: &str, to: &str) -> String {
7132        assert!(
7133            text.contains(from),
7134            "the rendered definition does not contain `{from}`, so this test would assert \
7135             nothing about a widened one"
7136        );
7137        text.replace(from, to)
7138    }
7139
7140    /// Every file under `root`, keyed by its path relative to `root`.
7141    fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
7142        fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
7143            let Ok(entries) = std::fs::read_dir(directory) else {
7144                return;
7145            };
7146            for entry in entries.flatten() {
7147                let path = entry.path();
7148                if path.is_dir() {
7149                    walk(&path, out);
7150                } else if let Ok(bytes) = std::fs::read(&path) {
7151                    out.insert(path, bytes);
7152                }
7153            }
7154        }
7155        let mut out = BTreeMap::new();
7156        for root in roots {
7157            walk(root, &mut out);
7158        }
7159        out
7160    }
7161
7162    struct Host {
7163        _root: tempfile::TempDir,
7164        paths: AppPaths,
7165        binary: PathBuf,
7166        /// A runner root inside this host's own temporary tree.
7167        ///
7168        /// Without it every `install` in this module would create and
7169        /// re-permission the **real** `%SystemDrive%\rman` on the machine
7170        /// running the tests, and would fail outright on any machine where that
7171        /// directory already exists with the access control `C:\` gives it.
7172        /// A sibling of the four application-data directories rather than a
7173        /// child of one, so `b1`'s overlap check has nothing to object to.
7174        runner_root: LocalAbsolutePath,
7175        controls: RecordingControls,
7176    }
7177
7178    impl Host {
7179        fn new() -> Self {
7180            let root = tempfile::tempdir().expect("a temporary directory");
7181            let paths = AppPaths::rooted_at(root.path());
7182            paths.create_all().expect("the four directories");
7183            let binary = root.path().join(if cfg!(windows) {
7184                "runner-manager.exe"
7185            } else {
7186                "runner-manager"
7187            });
7188            std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
7189            let runner_root = LocalAbsolutePath::new(
7190                root.path()
7191                    .join("runner-root")
7192                    .to_str()
7193                    .expect("a unicode temporary path"),
7194            )
7195            .expect("a local absolute path");
7196            Self {
7197                _root: root,
7198                paths,
7199                binary,
7200                runner_root,
7201                controls: RecordingControls::new(),
7202            }
7203        }
7204
7205        fn operations(&self) -> ServiceOperations {
7206            ServiceOperations::with_controls(
7207                self.paths.clone(),
7208                ServiceIdentity::product(),
7209                std::sync::Arc::new(self.controls.clone()),
7210            )
7211            .with_runner_root(self.runner_root.clone())
7212        }
7213
7214        fn request(&self, mode: StartMode) -> InstallRequest {
7215            InstallRequest::new(mode).for_binary(&self.binary)
7216        }
7217    }
7218
7219    #[cfg(windows)]
7220    #[test]
7221    fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
7222        let probes = std::cell::Cell::new(0);
7223        let absent =
7224            super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
7225                let next = probes.get() + 1;
7226                probes.set(next);
7227                Ok(next == 3)
7228            })
7229            .expect("the simulated SCM probe succeeds");
7230
7231        assert!(absent);
7232        assert_eq!(
7233            probes.get(),
7234            3,
7235            "uninstall must recheck after transient presence instead of treating it as a leak"
7236        );
7237    }
7238
7239    #[cfg(windows)]
7240    #[test]
7241    fn windows_recognises_an_already_pending_service_deletion() {
7242        let error = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(1072));
7243
7244        assert!(super::sys::is_marked_for_delete(&error));
7245    }
7246
7247    #[test]
7248    fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
7249        let root = tempfile::tempdir().expect("a temporary directory");
7250        let plist = root.path().join("fixture.plist");
7251        std::fs::write(&plist, b"fixture").expect("a plist fixture");
7252        let calls = std::cell::RefCell::new(Vec::new());
7253
7254        let error = enable_launchd_registration(
7255            |arguments| {
7256                let call = arguments
7257                    .iter()
7258                    .map(|argument| argument.to_string_lossy().into_owned())
7259                    .collect::<Vec<_>>();
7260                let operation = call[0].clone();
7261                calls.borrow_mut().push(call);
7262                if operation == "enable" {
7263                    (false, "label remains disabled".to_string())
7264                } else {
7265                    (true, String::new())
7266                }
7267            },
7268            "system",
7269            "system/com.openai.runner-manager-selftest",
7270            &plist,
7271            "runner-manager-selftest",
7272            "rerun with administrative rights",
7273        )
7274        .expect_err("enable failure must fail the install");
7275
7276        assert!(
7277            matches!(
7278                error,
7279                ServiceError::Control {
7280                    operation: "enable",
7281                    ..
7282                }
7283            ),
7284            "{error}"
7285        );
7286        assert_eq!(calls.borrow().len(), 2);
7287        assert_eq!(calls.borrow()[0][0], "enable");
7288        assert_eq!(calls.borrow()[1][0], "bootout");
7289        assert!(!plist.exists(), "rollback must remove the plist");
7290    }
7291
7292    // -----------------------------------------------------------------------
7293    // Quoting, and reading a command line back
7294    // -----------------------------------------------------------------------
7295
7296    #[test]
7297    fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
7298        let plan = windows_plan(StartMode::Boot);
7299        let command_line = plan.command_line();
7300        assert!(
7301            command_line.starts_with('"'),
7302            "a path with a space must be quoted, got {command_line}"
7303        );
7304        assert_eq!(
7305            executable_from_command_line(&command_line).as_deref(),
7306            Some(plan.binary())
7307        );
7308    }
7309
7310    #[test]
7311    fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
7312        let plan = linux_plan(StartMode::Boot);
7313        let command_line = plan.command_line();
7314        assert!(!command_line.starts_with('"'), "got {command_line}");
7315        assert_eq!(
7316            executable_from_command_line(&command_line).as_deref(),
7317            Some(plan.binary())
7318        );
7319    }
7320
7321    #[test]
7322    fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
7323        // Not a path anybody has, but it is the input that separates a real
7324        // implementation of Windows' quoting rules from a `split_whitespace`.
7325        let awkward = r#"C:\odd "name"\rm.exe"#;
7326        let quoted = quote_argument(awkward);
7327        assert_eq!(
7328            executable_from_command_line(&format!("{quoted} daemon run"))
7329                .as_deref()
7330                .map(Path::to_string_lossy)
7331                .as_deref(),
7332            Some(awkward)
7333        );
7334    }
7335
7336    #[test]
7337    fn an_empty_command_line_has_no_executable() {
7338        assert_eq!(executable_from_command_line("   "), None);
7339        assert_eq!(executable_from_command_line(""), None);
7340    }
7341
7342    #[test]
7343    fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
7344        let awkward = r#"DOMAIN\R&D <team> "ops""#;
7345        assert_eq!(
7346            xml_escape(awkward),
7347            "DOMAIN\\R&amp;D &lt;team&gt; &quot;ops&quot;"
7348        );
7349        assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
7350    }
7351
7352    // -----------------------------------------------------------------------
7353    // The restart policy is bounded at both ends
7354    // -----------------------------------------------------------------------
7355
7356    #[test]
7357    fn a_restart_delay_under_the_floor_is_refused() {
7358        let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
7359            .expect_err("half a second is under the one-second floor");
7360        assert!(
7361            matches!(error, ServiceError::RestartDelay { .. }),
7362            "{error}"
7363        );
7364    }
7365
7366    #[test]
7367    fn a_restart_delay_over_the_ceiling_is_refused() {
7368        let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
7369            .expect_err("an hour is over the five-minute ceiling");
7370        assert!(
7371            matches!(error, ServiceError::RestartDelay { .. }),
7372            "{error}"
7373        );
7374    }
7375
7376    #[test]
7377    fn a_reset_window_no_longer_than_the_delay_is_refused() {
7378        let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
7379            .expect_err("a window equal to the delay can never elapse between restarts");
7380        assert!(
7381            matches!(error, ServiceError::RestartResetWindow { .. }),
7382            "{error}"
7383        );
7384    }
7385
7386    #[test]
7387    fn a_delay_inside_the_bound_is_accepted() {
7388        let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
7389            .expect("twenty seconds is inside the bound");
7390        assert_eq!(policy.delay(), Duration::from_secs(20));
7391        assert_eq!(policy.reset_after(), Duration::from_secs(300));
7392    }
7393
7394    // -----------------------------------------------------------------------
7395    // Identity
7396    // -----------------------------------------------------------------------
7397
7398    #[test]
7399    fn a_fixture_identity_can_never_be_the_product_identity() {
7400        let fixture = ServiceIdentity::fixture("abc123");
7401        assert!(fixture.is_fixture());
7402        assert!(!ServiceIdentity::product().is_fixture());
7403        assert_ne!(fixture.name(), ServiceIdentity::product().name());
7404        assert_ne!(
7405            fixture.launchd_label(),
7406            ServiceIdentity::product().launchd_label()
7407        );
7408        assert_ne!(
7409            fixture.systemd_unit(),
7410            ServiceIdentity::product().systemd_unit()
7411        );
7412    }
7413
7414    #[test]
7415    fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
7416        let fixture = ServiceIdentity::fixture("A b/c:\\d");
7417        assert!(
7418            fixture
7419                .name()
7420                .chars()
7421                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
7422            "got {}",
7423            fixture.name()
7424        );
7425    }
7426
7427    // -----------------------------------------------------------------------
7428    // The systemd unit
7429    // -----------------------------------------------------------------------
7430
7431    #[test]
7432    fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
7433        let unit = systemd_unit(&linux_plan(StartMode::Boot));
7434        assert!(unit.contains("KillMode=process\n"), "{unit}");
7435        assert!(unit.contains("Restart=on-failure\n"), "{unit}");
7436        assert!(unit.contains("RestartSec=15\n"), "{unit}");
7437        assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
7438        assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
7439        assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
7440    }
7441
7442    #[test]
7443    fn the_boot_unit_reads_the_live_store_instead_of_a_frozen_systemd_copy() {
7444        let unit = systemd_unit(&linux_plan(StartMode::Boot));
7445        assert!(
7446            !unit.contains("LoadCredential="),
7447            "a startup snapshot would shadow every rotated credential until restart:\n{unit}"
7448        );
7449    }
7450
7451    #[test]
7452    fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
7453        let unit = systemd_unit(&linux_plan(StartMode::Login));
7454        assert!(
7455            !unit.contains("LoadCredential="),
7456            "a user unit must not name a root-owned credential file, got:\n{unit}"
7457        );
7458        assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
7459    }
7460
7461    #[test]
7462    fn the_unit_can_atomically_replace_the_credential_and_write_only_required_directories() {
7463        let plan = linux_plan(StartMode::Boot);
7464        let unit = systemd_unit(&plan);
7465        let directives = ini_directives(&unit, "Service");
7466        let listed = split_quoted(
7467            directives
7468                .get("ReadWritePaths")
7469                .expect("the unit names its writable paths"),
7470        );
7471        assert_eq!(listed.len(), 5, "{listed:?}");
7472        for path in plan.directories().all() {
7473            assert!(
7474                listed.iter().any(|entry| entry == &path.to_string_lossy()),
7475                "{} is missing from {listed:?}",
7476                path.display()
7477            );
7478        }
7479        assert!(
7480            listed
7481                .iter()
7482                .any(|entry| entry == "/var/lib/runner-manager/secrets"),
7483            "atomic credential replacement needs its parent directory in {listed:?}"
7484        );
7485    }
7486
7487    #[test]
7488    fn the_unit_records_the_absolute_binary_path() {
7489        let plan = linux_plan(StartMode::Boot);
7490        let unit = systemd_unit(&plan);
7491        assert!(
7492            unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
7493            "{unit}"
7494        );
7495    }
7496
7497    // -----------------------------------------------------------------------
7498    // The launchd property list
7499    // -----------------------------------------------------------------------
7500
7501    #[test]
7502    fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
7503        let plist = launchd_plist(&linux_plan(StartMode::Boot));
7504        // A bare `KeepAlive` would also restart a job the operator stopped
7505        // deliberately, which turns `service stop` into a fight with launchd.
7506        assert!(
7507            plist.contains(
7508                "<key>KeepAlive</key>\n  <dict>\n    <key>SuccessfulExit</key>\n    <false/>\n"
7509            ),
7510            "{plist}"
7511        );
7512        assert!(
7513            plist.contains("<key>ThrottleInterval</key>\n  <integer>15</integer>"),
7514            "{plist}"
7515        );
7516    }
7517
7518    #[test]
7519    fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
7520        let daemon = launchd_plist(&linux_plan(StartMode::Boot));
7521        assert_eq!(
7522            plist_string_value(&daemon, "UserName").as_deref(),
7523            Some("root")
7524        );
7525        assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
7526
7527        let agent = launchd_plist(&linux_plan(StartMode::Login));
7528        assert_eq!(
7529            plist_string_value(&agent, "UserName"),
7530            None,
7531            "a LaunchAgent already runs as the operator:\n{agent}"
7532        );
7533    }
7534
7535    #[test]
7536    fn the_plist_runs_the_daemon_and_its_runners_as_an_interactive_job() {
7537        for mode in [StartMode::Boot, StartMode::Login] {
7538            let plan = linux_plan(mode);
7539            let plist = launchd_plist(&plan);
7540            assert_eq!(
7541                plist_string_value(&plist, "ProcessType").as_deref(),
7542                Some("Interactive"),
7543                "launchd applies ProcessType to every runner the daemon starts:\n{plist}"
7544            );
7545            let review = review_least_privilege(
7546                &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, plist),
7547                &plan,
7548            );
7549            assert!(
7550                review
7551                    .controls()
7552                    .iter()
7553                    .any(|control| control == "ProcessType=Interactive"),
7554                "{review}"
7555            );
7556            assert!(
7557                !review
7558                    .findings()
7559                    .iter()
7560                    .any(|finding| finding.subject == "ProcessType"),
7561                "{review}"
7562            );
7563        }
7564    }
7565
7566    /// The value 0.4.31 and earlier installed, and launchd's default, both
7567    /// throttle every runner. That is a shortfall the update repairs, not an
7568    /// excess: a scheduling class grants no authority.
7569    #[test]
7570    fn a_plist_that_throttles_its_runners_is_a_shortfall() {
7571        let plan = linux_plan(StartMode::Login);
7572        let rendered = launchd_plist(&plan);
7573        let interactive = "<key>ProcessType</key>\n  <string>Interactive</string>\n";
7574        for (label, throttled) in [
7575            (
7576                "Background",
7577                edited(
7578                    &rendered,
7579                    interactive,
7580                    "<key>ProcessType</key>\n  <string>Background</string>\n",
7581                ),
7582            ),
7583            ("absent", edited(&rendered, interactive, "")),
7584        ] {
7585            let review = review_least_privilege(
7586                &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, throttled),
7587                &plan,
7588            );
7589            assert!(review.is_least_privilege(), "{label}: {review}");
7590            assert!(
7591                review.findings().iter().any(|finding| {
7592                    finding.kind == FindingKind::Shortfall
7593                        && finding.subject == "ProcessType"
7594                        && finding.detail.contains(label)
7595                }),
7596                "{label}: {review}"
7597            );
7598        }
7599    }
7600
7601    #[test]
7602    fn a_plist_with_an_old_process_type_is_drift_and_an_operator_edit_is_not() {
7603        let host = Host::new();
7604        let identity = ServiceIdentity::product();
7605        let plan = InstallPlan::resolve(
7606            identity.clone(),
7607            &host.request(StartMode::Login),
7608            ServiceDirectories::of(&host.paths),
7609        )
7610        .expect("a resolvable plan");
7611        let installed = launchd_plist(&plan);
7612        let definition =
7613            ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, installed.clone());
7614        let mut record = InstallRecord::of(&plan, &definition, Utc::now());
7615        record.installed_by_version = "0.4.31".into();
7616        let path = PathBuf::from("/Users/op/Library/LaunchAgents/runner-manager.plist");
7617
7618        assert_eq!(
7619            launchd_definition_drift(&identity, &record, path.clone(), &installed),
7620            None
7621        );
7622        let edited_by_operator = edited(
7623            &installed,
7624            "</dict>\n</plist>\n",
7625            "  <key>Nice</key>\n  <integer>0</integer>\n</dict>\n</plist>\n",
7626        );
7627        assert_eq!(
7628            launchd_definition_drift(&identity, &record, path.clone(), &edited_by_operator),
7629            None,
7630            "the daemon repairs this drift itself, so it must not key on edits it did not cause"
7631        );
7632
7633        let written_by_0_4_31 = edited(
7634            &installed,
7635            "<string>Interactive</string>",
7636            "<string>Background</string>",
7637        );
7638        let drift = launchd_definition_drift(&identity, &record, path, &written_by_0_4_31)
7639            .expect("a Background plist is drift");
7640        assert_eq!(drift.manager, DefinitionKind::LaunchdPlist);
7641        assert_eq!(drift.start_mode, StartMode::Login);
7642        assert_eq!(
7643            drift.rendered, installed,
7644            "the repair must write exactly what `service install` writes"
7645        );
7646
7647        let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
7648            .with_definition_drift(Some(drift));
7649        let problem = status
7650            .problems()
7651            .iter()
7652            .find(|problem| problem.subject == "definition")
7653            .expect("the drift is reported");
7654        // The remedy is a restart: a running daemon holds the lock that refuses
7655        // `service install`.
7656        assert!(
7657            problem.detail.contains("0.4.31")
7658                && problem.detail.contains(
7659                    "launchctl kickstart -k gui/$(id -u)/io.github.IvanMurzak.runner-manager`"
7660                )
7661                && !problem.detail.contains("service install")
7662                && !problem.detail.contains("systemctl")
7663                && !problem.detail.contains("sudo"),
7664            "{}",
7665            problem.detail
7666        );
7667    }
7668
7669    #[cfg(unix)]
7670    #[test]
7671    fn a_replaced_definition_is_the_rendering_and_readable_by_launchd() {
7672        use std::os::unix::fs::PermissionsExt as _;
7673
7674        let root = tempfile::tempdir().expect("a temporary directory");
7675        let path = root
7676            .path()
7677            .join("io.github.IvanMurzak.runner-manager.plist");
7678        std::fs::write(&path, "<string>Background</string>").expect("old plist");
7679        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
7680        let drift = DefinitionDrift {
7681            path: path.clone(),
7682            installed_by_version: "0.4.31".into(),
7683            rendered: launchd_plist(&linux_plan(StartMode::Login)),
7684            start_mode: StartMode::Login,
7685            manager: DefinitionKind::LaunchdPlist,
7686        };
7687
7688        replace_definition("runner-manager", &drift).expect("replaced");
7689
7690        assert_eq!(std::fs::read_to_string(&path).unwrap(), drift.rendered);
7691        assert_eq!(
7692            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
7693            0o644
7694        );
7695        assert_eq!(
7696            std::fs::read_dir(root.path()).unwrap().count(),
7697            1,
7698            "no staging file is left beside the plist"
7699        );
7700    }
7701
7702    /// The job of [`a_job_reloads_itself_from_its_rewritten_plist`], run by
7703    /// launchd and by nothing else.
7704    ///
7705    /// It does what the daemon does when it starts: when its own plist (kept
7706    /// in its working directory) does not carry [`LAUNCHD_PROCESS_TYPE`], it
7707    /// rewrites the plist and asks for the reload from inside the job that the
7708    /// reload boots out. Either way it then stays up, to be booted out by the
7709    /// helper or, once reloaded, by the test.
7710    #[cfg(target_os = "macos")]
7711    #[test]
7712    #[ignore = "run only by launchd, as the job of the detached-reload test"]
7713    fn detached_reload_job() {
7714        let label = std::env::var("XPC_SERVICE_NAME").unwrap_or_default();
7715        let tag = label
7716            .split_once("-selftest-")
7717            .map(|(_, tag)| tag.to_string())
7718            .unwrap_or_else(|| panic!("run only by launchd for the reload test, not {label:?}"));
7719        let identity = ServiceIdentity::fixture(&tag);
7720        assert_eq!(identity.launchd_label(), label);
7721        let path = std::env::current_dir()
7722            .expect("working directory")
7723            .join(format!("{label}.plist"));
7724        let installed = std::fs::read_to_string(&path).expect("own plist");
7725        if plist_string_value(&installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE) {
7726            let drift = DefinitionDrift {
7727                path: path.clone(),
7728                installed_by_version: "0.4.31".into(),
7729                rendered: installed.replace(
7730                    "<string>Background</string>",
7731                    &format!("<string>{LAUNCHD_PROCESS_TYPE}</string>"),
7732                ),
7733                start_mode: StartMode::Login,
7734                manager: DefinitionKind::LaunchdPlist,
7735            };
7736            replace_definition(identity.name(), &drift).expect("plist replaced");
7737            super::sys::reload_detached(&identity, StartMode::Login, &path)
7738                .expect("helper started");
7739        }
7740        std::thread::sleep(Duration::from_secs(120));
7741    }
7742
7743    /// A job that rewrites its own plist and asks for the reload comes back
7744    /// under the rewritten plist.
7745    ///
7746    /// The request comes from inside the job, as the daemon's does, so the
7747    /// helper has to outlive the bootout of the very job that started it.
7748    /// launchd sends SIGTERM to the job's whole process group, so a helper that
7749    /// stayed in it would sometimes die before its bootstrap and leave the job
7750    /// unloaded. Measured with `setsid` removed, that failed one run in four
7751    /// here: this test catches it, though not on every run.
7752    #[cfg(target_os = "macos")]
7753    #[test]
7754    fn a_job_reloads_itself_from_its_rewritten_plist() {
7755        if !super::launchd_fixture::enabled() {
7756            return;
7757        }
7758        let root = tempfile::tempdir().expect("a temporary directory");
7759        let identity = ServiceIdentity::fixture(&format!("reload-{}", std::process::id()));
7760        let label = identity.launchd_label();
7761        let path = root.path().join(format!("{label}.plist"));
7762        let binary = super::launchd_fixture::job_binary(root.path());
7763        let mut plist = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
7764        plist.push_str("<plist version=\"1.0\">\n<dict>\n");
7765        plist.push_str(&plist_string("Label", &label));
7766        plist.push_str("  <key>ProgramArguments</key>\n  <array>\n");
7767        for argument in [
7768            binary.to_string_lossy().as_ref(),
7769            "--ignored",
7770            "--exact",
7771            "service::tests::detached_reload_job",
7772            "--test-threads=1",
7773        ] {
7774            plist.push_str(&format!("    <string>{}</string>\n", xml_escape(argument)));
7775        }
7776        plist.push_str("  </array>\n  <key>RunAtLoad</key>\n  <true/>\n");
7777        plist.push_str(&plist_string(
7778            "WorkingDirectory",
7779            &root.path().to_string_lossy(),
7780        ));
7781        plist.push_str(&plist_string("ProcessType", "Background"));
7782        plist.push_str("</dict>\n</plist>\n");
7783        std::fs::write(&path, plist).expect("plist written");
7784
7785        let loaded = super::launchd_fixture::LoadedAgent::bootstrap(&label, &path);
7786        let spawn_type = || {
7787            let printed = std::process::Command::new("launchctl")
7788                .args(["print", loaded.target()])
7789                .output()
7790                .expect("launchctl runs");
7791            String::from_utf8_lossy(&printed.stdout)
7792                .lines()
7793                .find_map(|line| {
7794                    line.trim()
7795                        .strip_prefix("spawn type = ")
7796                        .map(str::to_string)
7797                })
7798        };
7799        assert_eq!(spawn_type().as_deref(), Some("background (5)"));
7800
7801        let deadline = std::time::Instant::now() + Duration::from_secs(90);
7802        while spawn_type().as_deref() != Some("interactive (4)") {
7803            assert!(
7804                std::time::Instant::now() < deadline,
7805                "the job did not come back under its rewritten plist; launchd reports {:?}",
7806                spawn_type()
7807            );
7808            std::thread::sleep(Duration::from_millis(250));
7809        }
7810        let rewritten = std::fs::read_to_string(&path).expect("plist readable");
7811        assert_eq!(
7812            plist_string_value(&rewritten, "ProcessType").as_deref(),
7813            Some(LAUNCHD_PROCESS_TYPE)
7814        );
7815    }
7816
7817    #[test]
7818    fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
7819        let plist = launchd_plist(&linux_plan(StartMode::Boot));
7820        assert!(
7821            plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
7822            "{plist}"
7823        );
7824        assert!(plist.contains("<string>daemon</string>"), "{plist}");
7825        assert!(plist.contains("<string>run</string>"), "{plist}");
7826    }
7827
7828    #[test]
7829    fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
7830        assert_eq!(
7831            ServiceIdentity::product().launchd_label(),
7832            "io.github.IvanMurzak.runner-manager"
7833        );
7834    }
7835
7836    // -----------------------------------------------------------------------
7837    // The Task Scheduler document
7838    // -----------------------------------------------------------------------
7839
7840    #[test]
7841    fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
7842        let xml = windows_scheduled_task_xml(
7843            &windows_plan(StartMode::Login),
7844            &TaskPrincipal::named("HOST\\operator"),
7845        );
7846        assert!(xml.contains("<LogonTrigger>"), "{xml}");
7847        assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
7848        assert!(
7849            xml.contains("<LogonType>InteractiveToken</LogonType>"),
7850            "{xml}"
7851        );
7852        assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
7853        assert!(
7854            xml.contains("<Interval>PT1M</Interval>"),
7855            "Task Scheduler takes whole minutes only, and rejects the registration outright \
7856             for anything finer:\n{xml}"
7857        );
7858    }
7859
7860    #[test]
7861    fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
7862        // Measured against the real Task Scheduler, which answers `PT15S` with
7863        // "The task XML contains a value which is incorrectly formatted or out
7864        // of range". Rounding *up* is what keeps "does not restart-loop faster
7865        // than that bound" true on this manager.
7866        for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
7867            let policy =
7868                RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
7869                    .expect("inside the supported range");
7870            assert_eq!(
7871                policy
7872                    .effective_delay(DefinitionKind::WindowsScheduledTask)
7873                    .as_secs(),
7874                enforced,
7875                "a {asked}s delay must be enforced as {enforced}s"
7876            );
7877            assert!(
7878                policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
7879                "rounding must never shorten the bound"
7880            );
7881        }
7882    }
7883
7884    #[test]
7885    fn every_other_manager_enforces_the_delay_exactly_as_configured() {
7886        let policy = RestartPolicy::default();
7887        for kind in [
7888            DefinitionKind::WindowsService,
7889            DefinitionKind::LaunchdPlist,
7890            DefinitionKind::SystemdUnit,
7891        ] {
7892            assert_eq!(
7893                policy.effective_delay(kind),
7894                policy.delay(),
7895                "{kind:?} takes seconds and enforces exactly what it is given"
7896            );
7897        }
7898    }
7899
7900    #[test]
7901    fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
7902        let host = Host::new();
7903        let operations = host.operations();
7904        operations
7905            .install(&host.request(StartMode::Login))
7906            .expect("an install at login");
7907        // What a real Task Scheduler reports back for a 15-second policy.
7908        host.controls.edit("runner-manager", |registration| {
7909            registration.manager = DefinitionKind::WindowsScheduledTask;
7910            registration.restart_delay = Some(Duration::from_secs(60));
7911        });
7912
7913        let status = operations.status().expect("a status");
7914        assert!(
7915            status.is_healthy(),
7916            "minute granularity is the manager's, not a mis-registration: {status}"
7917        );
7918        assert!(
7919            status
7920                .notes()
7921                .iter()
7922                .any(|note| note.contains("whole minutes")),
7923            "but the operator must be told why 15 became 60: {status}"
7924        );
7925
7926        // The discriminator: a delay that is neither the configured one nor its
7927        // rounding is still a fault.
7928        host.controls.edit("runner-manager", |registration| {
7929            registration.restart_delay = Some(Duration::from_secs(1));
7930        });
7931        assert!(
7932            !operations.status().expect("a status").is_healthy(),
7933            "a one-second delay is not what any manager was asked for"
7934        );
7935    }
7936
7937    #[test]
7938    fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
7939        let plan = windows_plan(StartMode::Login);
7940        let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
7941        assert_eq!(
7942            xml_value(&xml, "Command").as_deref(),
7943            Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
7944            "{xml}"
7945        );
7946        assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
7947    }
7948
7949    #[test]
7950    fn an_account_name_holding_xml_punctuation_is_escaped() {
7951        let xml = windows_scheduled_task_xml(
7952            &windows_plan(StartMode::Login),
7953            &TaskPrincipal::named("R&D\\ops"),
7954        );
7955        assert!(xml.contains("<UserId>R&amp;D\\ops</UserId>"), "{xml}");
7956        assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
7957    }
7958
7959    // -----------------------------------------------------------------------
7960    // The Windows service descriptor
7961    // -----------------------------------------------------------------------
7962
7963    #[test]
7964    fn the_service_starts_automatically_under_the_account_the_store_admits() {
7965        let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
7966        let directives = ini_directives(&text, "windows-service");
7967        assert_eq!(
7968            directives.get("StartType").map(String::as_str),
7969            Some("AutoStart")
7970        );
7971        assert_eq!(
7972            directives.get("Account").map(String::as_str),
7973            Some("NT AUTHORITY\\SYSTEM")
7974        );
7975        assert_eq!(
7976            directives.get("ServiceType").map(String::as_str),
7977            Some("OWN_PROCESS")
7978        );
7979        assert_eq!(
7980            directives
7981                .get("FailureActionRestartDelaySecs")
7982                .map(String::as_str),
7983            Some("15")
7984        );
7985        assert_eq!(
7986            directives
7987                .get("FailureActionsOnNonCrashFailures")
7988                .map(String::as_str),
7989            Some("true"),
7990            "without this flag a non-zero exit is not a failure the manager restarts"
7991        );
7992    }
7993
7994    #[test]
7995    fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
7996        let spec = windows_service_spec(&windows_plan(StartMode::Boot));
7997        assert_eq!(spec.account, None);
7998        assert!(spec.automatic_start);
7999        assert!(
8000            spec.command_line.contains("daemon run"),
8001            "{}",
8002            spec.command_line
8003        );
8004    }
8005
8006    // -----------------------------------------------------------------------
8007    // Where each definition is installed
8008    // -----------------------------------------------------------------------
8009
8010    #[test]
8011    fn each_definition_goes_where_its_platform_expects_it() {
8012        let home = PathBuf::from("/home/op");
8013        assert_eq!(
8014            ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
8015            Some(Path::new(
8016                "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
8017            ))
8018        );
8019        assert_eq!(
8020            ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
8021            Some(Path::new(
8022                "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
8023            ))
8024        );
8025        assert_eq!(
8026            ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
8027            Some(Path::new("/etc/systemd/system/runner-manager.service"))
8028        );
8029        assert_eq!(
8030            ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
8031            Some(Path::new(
8032                "/home/op/.config/systemd/user/runner-manager.service"
8033            ))
8034        );
8035        assert_eq!(
8036            ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
8037            None,
8038            "the Service Control Manager has no file"
8039        );
8040    }
8041
8042    #[test]
8043    fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
8044        assert_eq!(
8045            ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
8046            None
8047        );
8048        assert_eq!(
8049            ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
8050            None
8051        );
8052    }
8053
8054    // -----------------------------------------------------------------------
8055    // The least-privilege review: the passing case, then every failing one
8056    // -----------------------------------------------------------------------
8057
8058    #[test]
8059    fn the_rendered_definitions_are_all_least_privilege() {
8060        let linux = linux_plan(StartMode::Boot);
8061        let windows = windows_plan(StartMode::Boot);
8062        for (definition, plan) in [
8063            (ServiceDefinition::systemd(&linux, None), &linux),
8064            (ServiceDefinition::launchd(&linux, None), &linux),
8065            (ServiceDefinition::windows_service(&windows), &windows),
8066        ] {
8067            let review = review_least_privilege(&definition, plan);
8068            assert!(
8069                review.is_least_privilege(),
8070                "{:?} should be least privilege, got:\n{review}",
8071                definition.kind()
8072            );
8073            assert!(
8074                !review.controls().is_empty(),
8075                "a review that confirms nothing proves nothing: {:?}",
8076                definition.kind()
8077            );
8078        }
8079    }
8080
8081    #[test]
8082    fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
8083        let plan = windows_plan(StartMode::Login);
8084        let definition =
8085            ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
8086        let review = review_least_privilege(&definition, &plan);
8087        assert!(review.is_least_privilege(), "{review}");
8088        assert!(
8089            review
8090                .controls()
8091                .iter()
8092                .any(|control| control.contains("LeastPrivilege")),
8093            "{review}"
8094        );
8095    }
8096
8097    #[test]
8098    fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
8099        let plan = linux_plan(StartMode::Boot);
8100        let rendered = systemd_unit(&plan);
8101        let widened = edited(
8102            &rendered,
8103            "ReadWritePaths=/var/lib/runner-manager/config",
8104            "ReadWritePaths=/etc /var/lib/runner-manager/config",
8105        );
8106        let review = review_least_privilege(
8107            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
8108            &plan,
8109        );
8110        assert!(!review.is_least_privilege(), "{review}");
8111        assert!(
8112            review
8113                .excesses()
8114                .iter()
8115                .any(|finding| finding.detail.contains("/etc")),
8116            "the review must name the directory it objects to: {review}"
8117        );
8118    }
8119
8120    #[test]
8121    fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
8122        let plan = linux_plan(StartMode::Boot);
8123        let rendered = systemd_unit(&plan);
8124        let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
8125        let review = review_least_privilege(
8126            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
8127            &plan,
8128        );
8129        assert!(!review.is_least_privilege(), "{review}");
8130        assert!(
8131            review
8132                .excesses()
8133                .iter()
8134                .any(|finding| finding.subject == "NoNewPrivileges"),
8135            "{review}"
8136        );
8137    }
8138
8139    #[test]
8140    fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
8141        let plan = linux_plan(StartMode::Boot);
8142        let rendered = systemd_unit(&plan);
8143        let widened = edited(
8144            &rendered,
8145            "CapabilityBoundingSet=\n",
8146            "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
8147        );
8148        let review = review_least_privilege(
8149            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
8150            &plan,
8151        );
8152        assert!(!review.is_least_privilege(), "{review}");
8153        assert!(
8154            review
8155                .excesses()
8156                .iter()
8157                .any(|finding| finding.subject == "CapabilityBoundingSet"),
8158            "{review}"
8159        );
8160    }
8161
8162    #[test]
8163    fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
8164        let plan = linux_plan(StartMode::Boot);
8165        let rendered = systemd_unit(&plan);
8166        let widened = edited(
8167            &rendered,
8168            "[Install]",
8169            "ListenStream=127.0.0.1:9000\n\n[Install]",
8170        );
8171        let review = review_least_privilege(
8172            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
8173            &plan,
8174        );
8175        assert!(
8176            !review.is_least_privilege(),
8177            "07-security.md rule 2 forbids any inbound surface: {review}"
8178        );
8179    }
8180
8181    #[test]
8182    fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
8183        let plan = linux_plan(StartMode::Boot);
8184        let rendered = systemd_unit(&plan);
8185        let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
8186        let review = review_least_privilege(
8187            &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
8188            &plan,
8189        );
8190        assert!(
8191            review.is_least_privilege(),
8192            "too little authority is not an excess: {review}"
8193        );
8194        assert!(
8195            review
8196                .findings()
8197                .iter()
8198                .any(|finding| finding.kind == FindingKind::Shortfall
8199                    && finding.detail.contains("runtime")),
8200            "{review}"
8201        );
8202    }
8203
8204    #[test]
8205    fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
8206        let plan = linux_plan(StartMode::Login);
8207        let rendered = launchd_plist(&plan);
8208        let widened = edited(
8209            &rendered,
8210            "<key>ProcessType</key>",
8211            "<key>UserName</key>\n  <string>root</string>\n  <key>ProcessType</key>",
8212        );
8213        let review = review_least_privilege(
8214            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
8215            &plan,
8216        );
8217        assert!(!review.is_least_privilege(), "{review}");
8218        assert!(
8219            review
8220                .excesses()
8221                .iter()
8222                .any(|finding| finding.subject == "UserName"),
8223            "{review}"
8224        );
8225    }
8226
8227    #[test]
8228    fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
8229        let plan = linux_plan(StartMode::Boot);
8230        let rendered = launchd_plist(&plan);
8231        let widened = edited(
8232            &rendered,
8233            "<key>SessionCreate</key>\n  <false/>",
8234            "<key>SessionCreate</key>\n  <true/>",
8235        );
8236        let review = review_least_privilege(
8237            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
8238            &plan,
8239        );
8240        assert!(!review.is_least_privilege(), "{review}");
8241        assert!(
8242            review
8243                .excesses()
8244                .iter()
8245                .any(|finding| finding.subject == "SessionCreate"),
8246            "{review}"
8247        );
8248    }
8249
8250    #[test]
8251    fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
8252        let plan = linux_plan(StartMode::Boot);
8253        let rendered = launchd_plist(&plan);
8254        let widened = edited(
8255            &rendered,
8256            "<key>ProcessType</key>",
8257            "<key>MachServices</key>\n  <dict/>\n  <key>ProcessType</key>",
8258        );
8259        let review = review_least_privilege(
8260            &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
8261            &plan,
8262        );
8263        assert!(!review.is_least_privilege(), "{review}");
8264    }
8265
8266    #[test]
8267    fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
8268        let plan = windows_plan(StartMode::Login);
8269        let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
8270        let widened = edited(
8271            &rendered,
8272            "<RunLevel>LeastPrivilege</RunLevel>",
8273            "<RunLevel>HighestAvailable</RunLevel>",
8274        );
8275        let review = review_least_privilege(
8276            &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
8277            &plan,
8278        );
8279        assert!(!review.is_least_privilege(), "{review}");
8280        assert!(
8281            review
8282                .excesses()
8283                .iter()
8284                .any(|finding| finding.subject == "RunLevel"),
8285            "{review}"
8286        );
8287    }
8288
8289    #[test]
8290    fn a_task_that_would_store_a_password_is_not_least_privilege() {
8291        let plan = windows_plan(StartMode::Login);
8292        let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
8293        let widened = edited(
8294            &rendered,
8295            "<LogonType>InteractiveToken</LogonType>",
8296            "<LogonType>Password</LogonType>",
8297        );
8298        let review = review_least_privilege(
8299            &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
8300            &plan,
8301        );
8302        assert!(!review.is_least_privilege(), "{review}");
8303    }
8304
8305    #[test]
8306    fn an_interactive_windows_service_is_not_least_privilege() {
8307        let plan = windows_plan(StartMode::Boot);
8308        let rendered = windows_service_descriptor(&plan);
8309        let widened = edited(
8310            &rendered,
8311            "ServiceType=OWN_PROCESS",
8312            "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
8313        );
8314        let review = review_least_privilege(
8315            &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
8316            &plan,
8317        );
8318        assert!(!review.is_least_privilege(), "{review}");
8319        assert!(
8320            review
8321                .excesses()
8322                .iter()
8323                .any(|finding| finding.subject == "ServiceType"),
8324            "{review}"
8325        );
8326    }
8327
8328    #[test]
8329    fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
8330        let plan = windows_plan(StartMode::Boot);
8331        let rendered = windows_service_descriptor(&plan);
8332        let changed = edited(
8333            &rendered,
8334            "Account=NT AUTHORITY\\SYSTEM",
8335            "Account=NT AUTHORITY\\LocalService",
8336        );
8337        let review = review_least_privilege(
8338            &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
8339            &plan,
8340        );
8341        // LocalService is *less* privileged, so this is not an excess. It is
8342        // still wrong, because `d2`'s DACL names SY, BA and OW and nothing else:
8343        // the daemon would start and find no credential. Reporting it as a
8344        // shortfall rather than as an excess is the difference between a check
8345        // that understands the requirement and one that counts adjectives.
8346        assert!(review.is_least_privilege(), "{review}");
8347        assert!(
8348            review.findings().iter().any(|finding| {
8349                finding.kind == FindingKind::Shortfall && finding.subject == "Account"
8350            }),
8351            "{review}"
8352        );
8353    }
8354
8355    // -----------------------------------------------------------------------
8356    // The recorded absolute path, and what became of it
8357    // -----------------------------------------------------------------------
8358
8359    #[test]
8360    fn a_recorded_path_that_is_still_there_is_current() {
8361        let host = Host::new();
8362        let state = inspect_binary(&host.binary, Some(&host.binary));
8363        assert!(!state.is_error(), "{state}");
8364        assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
8365    }
8366
8367    #[test]
8368    fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
8369        let host = Host::new();
8370        // An `npm i -g` binary lives under the active Node installation's
8371        // global prefix. Switching Node versions moves the prefix, and the
8372        // recorded path stops existing while the registration survives.
8373        let recorded = host.binary.clone();
8374        let healthy = inspect_binary(&recorded, Some(&recorded));
8375        assert!(
8376            !healthy.is_error(),
8377            "the discriminator: before the binary moves, this must be healthy"
8378        );
8379
8380        std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
8381
8382        let state = inspect_binary(&recorded, Some(&recorded));
8383        assert!(state.is_error(), "{state}");
8384        assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
8385        assert!(
8386            state.to_string().contains("npm"),
8387            "the message must name the cause an operator will not otherwise connect: {state}"
8388        );
8389    }
8390
8391    #[test]
8392    fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
8393        let root = tempfile::tempdir().expect("a temporary directory");
8394        let state = inspect_binary(root.path(), None);
8395        assert!(state.is_error(), "{state}");
8396        assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
8397    }
8398
8399    #[test]
8400    fn a_registration_naming_a_different_binary_is_a_divergence() {
8401        let host = Host::new();
8402        let other = host.binary.with_file_name("something-else");
8403        let state = inspect_binary(&host.binary, Some(&other));
8404        assert!(state.is_error(), "{state}");
8405        assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
8406    }
8407
8408    #[test]
8409    fn absence_is_reported_before_divergence() {
8410        let host = Host::new();
8411        let recorded = host.binary.clone();
8412        std::fs::remove_file(&recorded).expect("removable");
8413        let other = recorded.with_file_name("something-else");
8414        // Both faults are true at once. The operator needs to hear about the
8415        // missing file, not about a disagreement between two paths of which one
8416        // does not exist.
8417        assert!(matches!(
8418            inspect_binary(&recorded, Some(&other)),
8419            BinaryPath::Missing { .. }
8420        ));
8421    }
8422
8423    // -----------------------------------------------------------------------
8424    // The record
8425    // -----------------------------------------------------------------------
8426
8427    #[test]
8428    fn the_record_round_trips_through_toml() {
8429        let host = Host::new();
8430        let plan = InstallPlan::resolve(
8431            ServiceIdentity::product(),
8432            &host.request(StartMode::Boot),
8433            ServiceDirectories::of(&host.paths),
8434        )
8435        .expect("a resolvable plan");
8436        let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
8437        let record = InstallRecord::of(&plan, &definition, Utc::now());
8438        record.write(&host.paths).expect("a writable record");
8439        let read = InstallRecord::read(&host.paths)
8440            .expect("a readable record")
8441            .expect("a record is there");
8442        assert_eq!(read, record);
8443        assert_eq!(read.binary, host.binary);
8444        assert!(read.binary.is_absolute());
8445    }
8446
8447    #[test]
8448    fn a_unit_written_by_an_older_build_is_reported_and_a_current_one_is_not() {
8449        let host = Host::new();
8450        let identity = ServiceIdentity::product();
8451        let plan = InstallPlan::resolve(
8452            identity.clone(),
8453            &host.request(StartMode::Boot),
8454            ServiceDirectories::of(&host.paths),
8455        )
8456        .expect("a resolvable plan");
8457        let installed = systemd_unit(&plan);
8458        let definition =
8459            ServiceDefinition::from_text(DefinitionKind::SystemdUnit, installed.clone());
8460        let mut record = InstallRecord::of(&plan, &definition, Utc::now());
8461        let path = PathBuf::from("/etc/systemd/system/runner-manager.service");
8462
8463        assert_eq!(
8464            systemd_definition_drift(&identity, &record, path.clone(), &installed),
8465            None,
8466            "the record must rebuild exactly the unit `service install` wrote"
8467        );
8468
8469        record.installed_by_version = "0.4.7".into();
8470        let frozen = installed.replace(
8471            "[Service]\n",
8472            "[Service]\nLoadCredential=runner-manager.user-access-token:/var/lib/runner-manager/secrets/user-access-token\n",
8473        );
8474        let drift = systemd_definition_drift(&identity, &record, path, &frozen)
8475            .expect("an outdated unit is drift");
8476        assert_eq!(drift.installed_by_version, "0.4.7");
8477        assert_eq!(drift.rendered, installed);
8478
8479        let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
8480            .with_definition_drift(Some(drift));
8481        assert!(!status.is_healthy());
8482        assert!(
8483            status
8484                .problems()
8485                .iter()
8486                .any(|problem| problem.subject == "definition" && problem.detail.contains("0.4.7")),
8487            "{status}"
8488        );
8489    }
8490
8491    #[test]
8492    fn a_rejected_credential_keeps_its_first_moment_and_makes_the_service_unhealthy() {
8493        let host = Host::new();
8494        assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
8495        let first = Utc::now() - chrono::Duration::hours(9);
8496        record_github_credential_rejected(&host.paths, first).unwrap();
8497        record_github_credential_rejected(&host.paths, Utc::now()).unwrap();
8498        let since = github_credential_rejected_since(&host.paths).unwrap();
8499        assert_eq!(since, Some(first));
8500
8501        let status = ServiceStatus::compose(
8502            ServiceIdentity::product(),
8503            None,
8504            None,
8505            None,
8506            None,
8507            &host.paths,
8508        )
8509        .with_credential_rejection(since);
8510        assert!(!status.is_healthy());
8511        assert_eq!(status.credential_rejected_since(), Some(first));
8512        assert!(status.to_string().contains("auth login"), "{status}");
8513
8514        clear_github_credential_rejection(&host.paths).unwrap();
8515        clear_github_credential_rejection(&host.paths).unwrap();
8516        assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
8517    }
8518
8519    /// The daemon's own "I cannot read my credential", which used to exist
8520    /// only in its log while `service status` said `healthy`.
8521    #[test]
8522    fn an_unreadable_credential_keeps_its_first_moment_and_names_the_exact_command() {
8523        let host = Host::new();
8524        assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
8525        let first = Utc::now() - chrono::Duration::minutes(40);
8526        record_credential_unreadable(&host.paths, first).unwrap();
8527        record_credential_unreadable(&host.paths, Utc::now()).unwrap();
8528        let since = credential_unreadable_since(&host.paths).unwrap();
8529        assert_eq!(since, Some(first), "a restart loop must not move the start");
8530
8531        let status = ServiceStatus::compose(
8532            ServiceIdentity::product(),
8533            None,
8534            None,
8535            None,
8536            None,
8537            &host.paths,
8538        )
8539        .with_credential_unreadable(since);
8540        assert!(!status.is_healthy());
8541        let problem = status
8542            .problems()
8543            .iter()
8544            .find(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT)
8545            .expect("reported under its own subject");
8546        // No record, so the default start mode, whose exact command is named.
8547        assert!(
8548            problem
8549                .detail
8550                .contains(&sign_in_instruction(StartMode::default())),
8551            "{status}"
8552        );
8553
8554        clear_credential_unreadable(&host.paths).unwrap();
8555        clear_credential_unreadable(&host.paths).unwrap();
8556        assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
8557        let healthy = ServiceStatus::compose(
8558            ServiceIdentity::product(),
8559            None,
8560            None,
8561            None,
8562            None,
8563            &host.paths,
8564        )
8565        .with_credential_unreadable(None);
8566        assert!(
8567            !healthy
8568                .problems()
8569                .iter()
8570                .any(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT),
8571            "{healthy}"
8572        );
8573    }
8574
8575    #[test]
8576    fn the_sign_in_instruction_names_the_start_mode_it_writes() {
8577        assert!(sign_in_instruction(StartMode::Login).contains("auth login --start-at login`"));
8578        assert!(sign_in_instruction(StartMode::Boot).contains("auth login --start-at boot`"));
8579        assert_eq!(
8580            sign_in_instruction(StartMode::Boot).starts_with("`sudo "),
8581            cfg!(unix),
8582            "a machine-scoped store needs root on Unix"
8583        );
8584        assert_eq!(
8585            sign_in_instruction(StartMode::Login).contains("SSH"),
8586            cfg!(target_os = "macos"),
8587            "only the macOS login keychain is out of an SSH session's reach"
8588        );
8589    }
8590
8591    /// The record is readable by the account whose directory it is in.
8592    ///
8593    /// A boot-mode `service install` runs under `sudo`, and `NamedTempFile`
8594    /// creates at `0600`, so the record landed `root`-owned and unreadable by
8595    /// the operator — `service status` then failed on their own host with
8596    /// `Permission denied`. Ownership cannot be reproduced in an unprivileged
8597    /// test, but the mode that made ownership fatal can, and it is the half
8598    /// this code controls.
8599    #[cfg(unix)]
8600    #[test]
8601    fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
8602        use std::os::unix::fs::PermissionsExt as _;
8603
8604        let host = Host::new();
8605        let plan = InstallPlan::resolve(
8606            ServiceIdentity::product(),
8607            &host.request(StartMode::Boot),
8608            ServiceDirectories::of(&host.paths),
8609        )
8610        .expect("a resolvable plan");
8611        let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
8612        InstallRecord::of(&plan, &definition, Utc::now())
8613            .write(&host.paths)
8614            .expect("a writable record");
8615
8616        let mode = std::fs::metadata(InstallRecord::path(&host.paths))
8617            .expect("the record is there")
8618            .permissions()
8619            .mode()
8620            & 0o777;
8621        assert_eq!(
8622            mode, 0o644,
8623            "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
8624             service install` wrote, and `service status` fails on their own host. It holds no \
8625             credential and sits in a 0700 directory, so 0644 discloses nothing"
8626        );
8627    }
8628
8629    /// A record written before the service ran a copy of its own must still
8630    /// load. It reads as `None`, which is the truth about it: that registration
8631    /// names the package manager's file and cannot be upgraded under itself.
8632    #[test]
8633    fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
8634        let host = Host::new();
8635        let path = InstallRecord::path(&host.paths);
8636        std::fs::write(
8637            &path,
8638            format!(
8639                "schema_version = {RECORD_SCHEMA_VERSION}
8640service_name = \"runner-manager\"
8641                 manager = \"systemd\"
8642start_mode = \"boot\"
8643account = \"root\"
8644                 binary = \"/x\"
8645arguments = []
8646restart_delay_secs = 15
8647                 restart_reset_secs = 600
8648log_file = \"/x\"
8649                 installed_at = \"2026-01-01T00:00:00Z\"
8650installed_by_version = \"0.1.0\"
8651                 [directories]
8652config = \"/a\"
8653state = \"/b\"
8654runtime = \"/c\"
8655logs = \"/d\"
8656"
8657            ),
8658        )
8659        .expect("a writable record");
8660        let read = InstallRecord::read(&host.paths)
8661            .expect("a record missing an optional field is still readable")
8662            .expect("a record is there");
8663        assert_eq!(
8664            read.source_binary, None,
8665            "the legacy layout has no source, and must not invent one"
8666        );
8667    }
8668
8669    /// The field that makes an upgrade possible survives the write.
8670    #[test]
8671    fn a_registration_remembers_the_file_it_was_copied_from() {
8672        let host = Host::new();
8673        let source = host.binary.with_file_name("npm-installed-runner-manager");
8674        std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
8675        let plan = InstallPlan::resolve(
8676            ServiceIdentity::product(),
8677            &host.request(StartMode::Boot).copied_from(&source),
8678            ServiceDirectories::of(&host.paths),
8679        )
8680        .expect("a resolvable plan");
8681        let definition = ServiceDefinition::from_text(
8682            DefinitionKind::SystemdUnit,
8683            "[Service]
8684",
8685        );
8686        let record = InstallRecord::of(&plan, &definition, Utc::now());
8687        record.write(&host.paths).expect("a writable record");
8688
8689        let read = InstallRecord::read(&host.paths)
8690            .expect("a readable record")
8691            .expect("a record is there");
8692        assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
8693        assert_ne!(
8694            read.source_binary.as_deref(),
8695            Some(read.binary.as_path()),
8696            "the whole point is that the two are different files: one the service holds open,              one the package manager is free to replace"
8697        );
8698    }
8699
8700    #[test]
8701    fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
8702        let host = Host::new();
8703        let path = InstallRecord::path(&host.paths);
8704        std::fs::write(
8705            &path,
8706            format!(
8707                "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
8708                 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
8709                 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
8710                 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
8711                 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
8712                RECORD_SCHEMA_VERSION + 1
8713            ),
8714        )
8715        .expect("a writable record");
8716        let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
8717        assert!(
8718            matches!(error, ServiceError::RecordUnreadable { .. }),
8719            "{error}"
8720        );
8721        assert!(
8722            error.to_string().contains("service uninstall"),
8723            "the message must say how to recover: {error}"
8724        );
8725    }
8726
8727    #[test]
8728    fn no_record_is_not_an_error() {
8729        let host = Host::new();
8730        assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
8731        assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
8732    }
8733
8734    // -----------------------------------------------------------------------
8735    // The last successful GitHub contact
8736    // -----------------------------------------------------------------------
8737
8738    #[test]
8739    fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
8740        let host = Host::new();
8741        assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
8742    }
8743
8744    #[test]
8745    fn the_heartbeat_round_trips_to_the_second() {
8746        let host = Host::new();
8747        let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
8748            .expect("a valid timestamp")
8749            .with_timezone(&Utc);
8750        record_github_contact(&host.paths, at).expect("a writable heartbeat");
8751        assert_eq!(
8752            last_github_contact(&host.paths).expect("readable"),
8753            Some(at)
8754        );
8755    }
8756
8757    #[test]
8758    fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
8759        let host = Host::new();
8760        std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
8761        let error = last_github_contact(&host.paths)
8762            .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
8763        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
8764    }
8765
8766    /// The surface the three-hour outage did not have.
8767    ///
8768    /// A root that refuses a launch does so before any attempt row exists, and
8769    /// the daemon's log scrubs the paths out of the sentence, so this file is
8770    /// the only place the directory and the remediation can reach the operator.
8771    #[test]
8772    fn a_runner_root_refusal_round_trips_for_service_status() {
8773        let host = Host::new();
8774        let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
8775
8776        assert!(
8777            runner_root_refusals(&host.paths)
8778                .expect("readable")
8779                .is_empty(),
8780            "no record means every policy is placing runners"
8781        );
8782
8783        record_runner_root_refusal(
8784            &host.paths,
8785            "policy-a",
8786            at,
8787            "denied_by_privacy_policy",
8788            "/Volumes/NVME/runners",
8789            "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
8790        )
8791        .expect("a writable record");
8792
8793        let refusals = runner_root_refusals(&host.paths).expect("readable");
8794        assert_eq!(refusals.len(), 1);
8795        assert_eq!(refusals[0].policy, "policy-a");
8796        assert_eq!(refusals[0].at, at);
8797        assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
8798        assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
8799        assert!(
8800            refusals[0].detail.contains("/Volumes/NVME/runners")
8801                && refusals[0].detail.contains("Full Disk Access"),
8802            "the path and the remediation are the whole point of this file: {refusals:?}"
8803        );
8804    }
8805
8806    /// The regression that would put the outage straight back.
8807    ///
8808    /// A host runs several policies and they do not share a fate. When the
8809    /// record was one host-wide slot, the policy that placed a runner deleted
8810    /// the record of the policy that could not — on the same reconcile pass —
8811    /// and `service status` reported a healthy host that had started zero
8812    /// runners for an entire target.
8813    #[test]
8814    fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
8815        let host = Host::new();
8816        let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
8817        record_runner_root_refusal(
8818            &host.paths,
8819            "broken",
8820            at,
8821            "denied_by_privacy_policy",
8822            "/Volumes/NVME/runners",
8823            "detail",
8824        )
8825        .expect("a writable record");
8826        record_runner_root_refusal(
8827            &host.paths,
8828            "also-broken",
8829            at,
8830            "not_writable",
8831            "/srv/other",
8832            "detail",
8833        )
8834        .expect("a writable record");
8835
8836        // The policy with its own working root succeeds and clears only itself.
8837        clear_runner_root_refusal(&host.paths, "healthy")
8838            .expect("clearing an absent policy is fine");
8839        clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
8840
8841        let refusals = runner_root_refusals(&host.paths).expect("readable");
8842        assert_eq!(
8843            refusals
8844                .iter()
8845                .map(|r| r.policy.as_str())
8846                .collect::<Vec<_>>(),
8847            vec!["broken"],
8848            "the policy that is still refused must keep its record"
8849        );
8850    }
8851
8852    /// Clearing is what keeps a fixed host from reporting a stale fault, and the
8853    /// file goes when the last entry does.
8854    #[test]
8855    fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
8856        let host = Host::new();
8857        record_runner_root_refusal(
8858            &host.paths,
8859            "p",
8860            Utc::now(),
8861            "not_writable",
8862            "/srv/x",
8863            "detail",
8864        )
8865        .expect("a writable record");
8866
8867        clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
8868        assert!(
8869            !root_refusal_path(&host.paths).exists(),
8870            "a host with nothing refused leaves nothing behind"
8871        );
8872        clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
8873    }
8874
8875    /// Same rule as the heartbeat: absence means "every policy is placing
8876    /// runners", so a record that cannot be parsed must not be reported as one.
8877    #[test]
8878    fn a_malformed_refusal_is_an_error_and_not_silently_none() {
8879        let host = Host::new();
8880        std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
8881        let error = runner_root_refusals(&host.paths)
8882            .expect_err("an unparseable record is not the same as no record");
8883        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
8884    }
8885
8886    /// The half of this feature that turns the file into a diagnosis, and the
8887    /// half that had no test: `service status` must actually read it.
8888    ///
8889    /// A **note** and not a problem, deliberately. `service status` runs as
8890    /// whoever typed it, must stay readable on a host with nothing installed,
8891    /// and drives an exit code -- and nothing an operator can type clears this
8892    /// record, so a problem would fail that exit code on a host whose root was
8893    /// fixed but which has had no queued job since, with a printed remedy
8894    /// (`service uninstall && service install`) that does not touch `state/`.
8895    #[test]
8896    fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
8897        let host = Host::new();
8898        record_runner_root_refusal(
8899            &host.paths,
8900            "policy-a",
8901            DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
8902            "denied_by_privacy_policy",
8903            "/Volumes/NVME/runners",
8904            "Grant Full Disk Access to the program that runs the service",
8905        )
8906        .expect("a writable record");
8907
8908        let status = host.operations().status().expect("a readable status");
8909        let notes = status.notes().join("\n");
8910
8911        assert!(
8912            notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
8913            "the directory and the remediation the log had to scrub must appear here: {notes}"
8914        );
8915        assert!(
8916            notes.contains("policy-a"),
8917            "the operator has to know which target placed no runner: {notes}"
8918        );
8919        assert!(
8920            !status
8921                .problems()
8922                .iter()
8923                .any(|problem| problem.subject == "runner root"),
8924            "a record nothing an operator types can clear must not drive the exit code"
8925        );
8926    }
8927
8928    // -----------------------------------------------------------------------
8929    // -----------------------------------------------------------------------
8930    // Install
8931    // -----------------------------------------------------------------------
8932
8933    #[test]
8934    fn install_records_the_absolute_binary_path_and_the_four_directories() {
8935        let host = Host::new();
8936        let installed = host
8937            .operations()
8938            .install(&host.request(StartMode::Boot))
8939            .expect("an install against the recording controls");
8940
8941        assert_eq!(installed.record.binary, host.binary);
8942        assert!(installed.record.binary.is_absolute());
8943        assert_eq!(installed.record.start_mode, StartMode::Boot);
8944        assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
8945        assert_eq!(
8946            installed.record.directories,
8947            ServiceDirectories::of(&host.paths)
8948        );
8949        assert_eq!(
8950            installed.record.log_file,
8951            host.paths.logs_dir().join(LOG_FILE_STEM)
8952        );
8953        assert_eq!(installed.record.restart_delay_secs, 15);
8954
8955        let registrations = host.controls.registrations();
8956        assert_eq!(registrations.len(), 1);
8957        assert_eq!(registrations[0].0, StartMode::Boot);
8958        assert_eq!(registrations[0].1, "runner-manager");
8959    }
8960
8961    #[test]
8962    fn install_is_refused_while_the_single_instance_lock_is_held() {
8963        let host = Host::new();
8964        // The discriminator: with the lock free, the same call succeeds.
8965        {
8966            let operations = host.operations();
8967            operations
8968                .install(&host.request(StartMode::Boot))
8969                .expect("an install with the lock free");
8970            operations.uninstall().expect("a clean slate");
8971        }
8972
8973        let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
8974            .expect("this process takes the lock first");
8975
8976        let error = host
8977            .operations()
8978            .install(&host.request(StartMode::Boot))
8979            .expect_err("a second agent must not be registered while one is running");
8980        assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
8981        assert!(
8982            error.to_string().contains("already running"),
8983            "the message must be actionable: {error}"
8984        );
8985        assert!(
8986            host.controls.registrations().is_empty(),
8987            "a refused install must register nothing"
8988        );
8989        assert_eq!(
8990            InstallRecord::read(&host.paths).expect("readable"),
8991            None,
8992            "a refused install must write no record"
8993        );
8994    }
8995
8996    /// Installing over the same start mode replaces the registration.
8997    ///
8998    /// It used to be refused, and the refusal is what broke a real host: the
8999    /// caller has already swapped the binary the service runs by the time this
9000    /// is reached — that is the documented way to upgrade by hand — so refusing
9001    /// left a daemon running an unregistered binary and, on macOS, one the
9002    /// stored credential's keychain grant did not name.
9003    #[test]
9004    fn installing_over_the_same_start_mode_replaces_the_registration() {
9005        let host = Host::new();
9006        let operations = host.operations();
9007        operations
9008            .install(&host.request(StartMode::Boot))
9009            .expect("the first install");
9010
9011        let again = operations
9012            .install(&host.request(StartMode::Boot))
9013            .expect("an install over the same mode replaces rather than refusing");
9014
9015        assert!(
9016            again.replaced_existing,
9017            "the operator is told this replaced something rather than made it"
9018        );
9019        assert_eq!(
9020            host.controls.registrations().len(),
9021            1,
9022            "replacing must not leave two registrations behind"
9023        );
9024        assert_eq!(
9025            InstallRecord::read(&host.paths)
9026                .expect("readable")
9027                .expect("a record")
9028                .start_mode,
9029            StartMode::Boot
9030        );
9031    }
9032
9033    /// The *other* mode is still refused, because changing it moves the
9034    /// registration between two service managers and changes the account and
9035    /// the secret store with it. `set_start_mode` is what does that.
9036    #[test]
9037    fn installing_over_the_other_start_mode_is_refused() {
9038        let host = Host::new();
9039        let operations = host.operations();
9040        operations
9041            .install(&host.request(StartMode::Boot))
9042            .expect("the first install");
9043
9044        let error = operations
9045            .install(&host.request(StartMode::Login))
9046            .expect_err("a mode change is not an install");
9047        assert!(
9048            matches!(
9049                error,
9050                ServiceError::AlreadyInstalled {
9051                    existing: StartMode::Boot,
9052                    requested: StartMode::Login,
9053                    ..
9054                }
9055            ),
9056            "{error}"
9057        );
9058        assert!(
9059            !error
9060                .to_string()
9061                .contains("switch the start mode in place,"),
9062            "the old remedy named a capability no command offers; the terminal UI is where \
9063             the start mode moves: {error}"
9064        );
9065        assert_eq!(host.controls.registrations().len(), 1);
9066    }
9067
9068    #[test]
9069    fn install_rolls_back_the_registration_when_record_persistence_fails() {
9070        let host = Host::new();
9071        let record_path = InstallRecord::path(&host.paths);
9072        std::fs::create_dir(&record_path).expect("a directory blocks the record file");
9073
9074        let error = host
9075            .operations()
9076            .install(&host.request(StartMode::Boot))
9077            .expect_err("record persistence must fail");
9078
9079        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
9080        assert!(
9081            host.controls.registrations().is_empty(),
9082            "a failed install must not leave a live unrecorded registration"
9083        );
9084        assert!(
9085            host.controls
9086                .calls()
9087                .iter()
9088                .any(|call| call == "uninstall runner-manager (boot)"),
9089            "the registration must be explicitly rolled back: {:?}",
9090            host.controls.calls()
9091        );
9092        assert!(
9093            !host.runner_root.as_path().exists(),
9094            "the rollback must take the runner root this install created with it; a directory \
9095             prepared for a registration that does not exist is litter, and on Windows it is \
9096             litter with a security descriptor"
9097        );
9098    }
9099
9100    // -----------------------------------------------------------------------
9101    // The runner root (b2)
9102    // -----------------------------------------------------------------------
9103
9104    #[test]
9105    fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
9106        use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
9107
9108        // The mapping `prepare_runner_root` reads, asserted against the same
9109        // function the registration's own principal comes from — which is the
9110        // point of deriving it there rather than restating it.
9111        assert_eq!(
9112            ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
9113            ServiceAccount::LocalSystem
9114        );
9115        assert_eq!(
9116            ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
9117            ServiceAccount::InvokingUser
9118        );
9119
9120        let boot = default_root_sddl(&RootAdmission::LocalSystem);
9121        assert!(!grants_broad_write(&boot), "{boot}");
9122        assert!(
9123            !boot.contains("S-1-5-21"),
9124            "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
9125        );
9126
9127        // A login task runs under a *filtered* token — `RunLevel` is
9128        // `LeastPrivilege`, see `windows_scheduled_task_xml` — in which
9129        // Administrators is deny-only. Without an ace of its own the account
9130        // the task runs as would be admitted by nothing at all.
9131        let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
9132        assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
9133        assert!(!grants_broad_write(&login), "{login}");
9134    }
9135
9136    #[test]
9137    fn an_install_reports_the_runner_root_it_prepared() {
9138        let host = Host::new();
9139        let installed = host
9140            .operations()
9141            .install(&host.request(StartMode::Boot))
9142            .expect("an install");
9143        let rendered = installed.runner_root.to_string();
9144        assert!(
9145            !rendered.contains("S-1-5-21"),
9146            "the report must add no identity to the output: {rendered}"
9147        );
9148        if cfg!(windows) {
9149            assert_eq!(
9150                installed.runner_root.path(),
9151                Some(host.runner_root.as_path())
9152            );
9153            assert!(
9154                host.runner_root.as_path().is_dir(),
9155                "the directory jobs would run in has to exist once the service is registered"
9156            );
9157        } else {
9158            assert_eq!(
9159                installed.runner_root,
9160                crate::runner_root_access::RootAccessSummary::NotApplicable,
9161                "macOS and Linux keep the runtime directory they have always used"
9162            );
9163        }
9164    }
9165
9166    #[test]
9167    fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
9168        let host = Host::new();
9169        let operations = host.operations();
9170        operations
9171            .install(&host.request(StartMode::Boot))
9172            .expect("an install at boot");
9173
9174        let change = operations
9175            .set_start_mode(StartMode::Login)
9176            .expect("a switch to login");
9177
9178        assert!(change.changed);
9179        if cfg!(windows) {
9180            assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
9181            assert!(
9182                host.runner_root.as_path().is_dir(),
9183                "the switch must not remove the directory it reconciled"
9184            );
9185        }
9186        // The operator is told, because the account that may write there has
9187        // moved with the mode.
9188        assert!(
9189            change.to_string().contains("runner root"),
9190            "{}",
9191            change.to_string()
9192        );
9193    }
9194
9195    #[test]
9196    fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
9197        let host = Host::new();
9198        let operations = host.operations();
9199        operations
9200            .install(&host.request(StartMode::Boot))
9201            .expect("an install at boot");
9202
9203        let change = operations
9204            .set_start_mode(StartMode::Boot)
9205            .expect("a switch to the mode already in force");
9206
9207        assert!(!change.changed);
9208        assert_eq!(
9209            change.runner_root,
9210            crate::runner_root_access::RootAccessSummary::NotApplicable,
9211            "nothing moves, so nothing about the root's access control has to; reconciling here \
9212             would turn a no-op command into one that can fail on a permission it does not need"
9213        );
9214    }
9215
9216    #[cfg(windows)]
9217    #[test]
9218    fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
9219        let host = Host::new();
9220        host.controls
9221            .fail_next_install(StartMode::Boot, "injected registration failure");
9222
9223        let error = host
9224            .operations()
9225            .install(&host.request(StartMode::Boot))
9226            .expect_err("the manager refuses the registration");
9227
9228        assert!(matches!(error, ServiceError::Control { .. }), "{error}");
9229        assert!(
9230            !host.runner_root.as_path().exists(),
9231            "the directory was created for a registration that does not exist"
9232        );
9233    }
9234
9235    #[cfg(windows)]
9236    #[test]
9237    fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
9238        let host = Host::new();
9239        // What a directory created below `C:\` with inheritance left on looks
9240        // like, built deliberately because a per-account `%TEMP%` never
9241        // produces one by accident.
9242        crate::runner_root_access::create_with_descriptor_for_tests(
9243            host.runner_root.as_path(),
9244            "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
9245        )
9246        .expect("a deliberately open runner root");
9247        let before = crate::runner_root_access::report(host.runner_root.as_path());
9248
9249        let error = host
9250            .operations()
9251            .install(&host.request(StartMode::Boot))
9252            .expect_err("an open runner root is refused");
9253
9254        assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
9255        assert!(
9256            error.to_string().contains("nothing was registered"),
9257            "{error}"
9258        );
9259        assert!(
9260            host.controls.registrations().is_empty(),
9261            "the refusal has to come before the platform is asked to register anything: {:?}",
9262            host.controls.calls()
9263        );
9264        assert_eq!(
9265            crate::runner_root_access::report(host.runner_root.as_path()),
9266            before,
9267            "an open directory is refused rather than tightened: its contents cannot be trusted, \
9268             so adopting it would be worse than declining it"
9269        );
9270    }
9271
9272    #[cfg(windows)]
9273    #[test]
9274    fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
9275        let host = Host::new();
9276        let operations = host.operations();
9277        operations
9278            .install(&host.request(StartMode::Boot))
9279            .expect("an install");
9280        assert!(host.runner_root.as_path().is_dir());
9281
9282        operations.uninstall().expect("an uninstall");
9283
9284        assert!(
9285            host.runner_root.as_path().is_dir(),
9286            "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
9287             runner root may hold an operator's retained workspaces."
9288        );
9289    }
9290
9291    #[test]
9292    fn install_reviews_what_it_registered() {
9293        let host = Host::new();
9294        let installed = host
9295            .operations()
9296            .install(&host.request(StartMode::Boot))
9297            .expect("an install");
9298        assert!(
9299            installed.review.is_least_privilege(),
9300            "{}",
9301            installed.review
9302        );
9303        assert!(
9304            !installed.review.controls().is_empty(),
9305            "a review that confirms nothing proves nothing: {}",
9306            installed.review
9307        );
9308        assert_eq!(
9309            installed.review.kind(),
9310            host_definition_kind(StartMode::Boot),
9311            "the review must be of the definition this host's manager was given"
9312        );
9313        assert!(
9314            !installed.review.account().justification().is_empty(),
9315            "a privileged account with no stated reason is an unreviewed one"
9316        );
9317    }
9318
9319    // -----------------------------------------------------------------------
9320    // Uninstall deletes nothing else
9321    // -----------------------------------------------------------------------
9322
9323    #[test]
9324    fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
9325        let host = Host::new();
9326        let operations = host.operations();
9327        operations
9328            .install(&host.request(StartMode::Boot))
9329            .expect("an install");
9330
9331        // The files `05-infrastructure.md` says must survive. Written *after*
9332        // the install so that the snapshot below is of a host in the state an
9333        // operator's would be in.
9334        let config = host.paths.config_dir();
9335        std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
9336        std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
9337        std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
9338        std::fs::write(
9339            host.paths
9340                .state_dir()
9341                .join("packages/2.330.0/runner.tar.gz"),
9342            b"cached package",
9343        )
9344        .expect("writable");
9345        std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
9346        std::fs::write(
9347            host.paths.state_dir().join("secrets/user-access-token"),
9348            b"a stand-in for the stored credential",
9349        )
9350        .expect("writable");
9351        std::fs::write(
9352            host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
9353            b"diagnostics",
9354        )
9355        .expect("writable");
9356
9357        let roots: Vec<PathBuf> = host
9358            .paths
9359            .all()
9360            .iter()
9361            .map(|(_, path)| (*path).to_path_buf())
9362            .collect();
9363        let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
9364        let before = snapshot(&roots);
9365
9366        // Non-vacuity: a comparison of two empty maps would pass whatever
9367        // `uninstall` did.
9368        assert!(
9369            before.len() >= 6,
9370            "the fixture must actually contain the files this test is about, got {before:#?}"
9371        );
9372        let record_path = InstallRecord::path(&host.paths);
9373        assert!(
9374            before.contains_key(&record_path),
9375            "the install record must be present before uninstall"
9376        );
9377
9378        let uninstalled = operations.uninstall().expect("an uninstall");
9379        assert!(uninstalled.removed_registration);
9380        assert!(uninstalled.removed_record);
9381
9382        let after = snapshot(&roots);
9383
9384        // Exactly one thing changed, and it is the registration's own record.
9385        let mut expected = before.clone();
9386        expected.remove(&record_path);
9387        assert_eq!(
9388            after, expected,
9389            "uninstall must remove its own record and nothing else"
9390        );
9391        assert!(
9392            !record_path.exists(),
9393            "the record itself must go, or `uninstall` did nothing at all"
9394        );
9395        assert!(
9396            uninstalled
9397                .preserved
9398                .iter()
9399                .all(|path| roots.contains(&path.as_path())),
9400            "the preserved list must name the four directories: {uninstalled}"
9401        );
9402    }
9403
9404    #[test]
9405    fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
9406        let host = Host::new();
9407        let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
9408        assert!(!uninstalled.removed_registration);
9409        assert!(!uninstalled.removed_record);
9410    }
9411
9412    #[test]
9413    fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
9414        let host = Host::new();
9415        let operations = host.operations();
9416        operations
9417            .install(&host.request(StartMode::Boot))
9418            .expect("an install");
9419        std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
9420
9421        let uninstalled = operations.uninstall().expect("an uninstall");
9422        assert!(
9423            uninstalled.removed_registration,
9424            "a lost record must not strand a registration"
9425        );
9426        assert!(host.controls.registrations().is_empty());
9427    }
9428
9429    // -----------------------------------------------------------------------
9430    // Switching start mode
9431    // -----------------------------------------------------------------------
9432
9433    #[test]
9434    fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
9435        let host = Host::new();
9436        let operations = host.operations();
9437        operations
9438            .install(&host.request(StartMode::Boot))
9439            .expect("an install at boot");
9440
9441        // The discriminator. If the switch re-resolved the binary the way
9442        // `install` does, it would either fail here or silently record the test
9443        // binary instead. Removing the file makes the difference visible.
9444        std::fs::remove_file(&host.binary).expect("the installed binary goes away");
9445
9446        let change = operations
9447            .set_start_mode(StartMode::Login)
9448            .expect("a switch that does not reinstall the product");
9449        assert!(change.changed);
9450        assert_eq!(change.from, StartMode::Boot);
9451        assert_eq!(change.to, StartMode::Login);
9452        assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
9453
9454        let record = InstallRecord::read(&host.paths)
9455            .expect("readable")
9456            .expect("a record");
9457        assert_eq!(record.start_mode, StartMode::Login);
9458        assert_eq!(
9459            record.binary, host.binary,
9460            "the recorded path must survive the switch untouched"
9461        );
9462
9463        let registrations = host.controls.registrations();
9464        assert_eq!(registrations.len(), 1, "{registrations:?}");
9465        assert_eq!(registrations[0].0, StartMode::Login);
9466        assert!(
9467            registrations[0]
9468                .2
9469                .command_line
9470                .contains(&host.binary.to_string_lossy().into_owned()),
9471            "{:?}",
9472            registrations[0].2
9473        );
9474    }
9475
9476    #[test]
9477    fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
9478        let host = Host::new();
9479        let operations = host.operations();
9480        operations
9481            .install(&host.request(StartMode::Boot))
9482            .expect("an install at boot");
9483        let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
9484        host.controls
9485            .fail_next_install(StartMode::Login, "injected target failure");
9486
9487        let error = operations
9488            .set_start_mode(StartMode::Login)
9489            .expect_err("the target manager refuses the install");
9490
9491        assert!(matches!(error, ServiceError::Control { .. }), "{error}");
9492        assert_eq!(
9493            std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
9494            record_before
9495        );
9496        let registrations = host.controls.registrations();
9497        assert_eq!(registrations.len(), 1, "{registrations:?}");
9498        assert_eq!(registrations[0].0, StartMode::Boot);
9499    }
9500
9501    #[test]
9502    fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
9503        let host = Host::new();
9504        let operations = host.operations();
9505        operations
9506            .install(&host.request(StartMode::Boot))
9507            .expect("an install at boot");
9508        let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
9509        let config = host.paths.config_dir().to_path_buf();
9510        let hidden = config.with_file_name("config-hidden-by-fault");
9511        host.controls.hide_directory_after_install(
9512            StartMode::Login,
9513            config.clone(),
9514            hidden.clone(),
9515        );
9516
9517        let error = operations
9518            .set_start_mode(StartMode::Login)
9519            .expect_err("the injected filesystem fault prevents persistence");
9520
9521        std::fs::remove_file(&config).expect("remove the injected blocker");
9522        std::fs::rename(&hidden, &config).expect("restore the record directory");
9523        assert!(matches!(error, ServiceError::Record { .. }), "{error}");
9524        assert_eq!(
9525            std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
9526            record_before
9527        );
9528        let registrations = host.controls.registrations();
9529        assert_eq!(registrations.len(), 1, "{registrations:?}");
9530        assert_eq!(registrations[0].0, StartMode::Boot);
9531        assert!(
9532            host.controls
9533                .calls()
9534                .iter()
9535                .any(|call| call == "uninstall runner-manager (login)"),
9536            "the target must be rolled back: {:?}",
9537            host.controls.calls()
9538        );
9539    }
9540
9541    #[test]
9542    fn switching_to_the_mode_already_in_force_registers_nothing_again() {
9543        let host = Host::new();
9544        let operations = host.operations();
9545        operations
9546            .install(&host.request(StartMode::Boot))
9547            .expect("an install");
9548        let before = host.controls.calls().len();
9549
9550        let change = operations
9551            .set_start_mode(StartMode::Boot)
9552            .expect("a no-op switch");
9553        assert!(!change.changed);
9554        assert_eq!(
9555            host.controls.calls().len(),
9556            before,
9557            "a no-op switch must not touch the service manager"
9558        );
9559    }
9560
9561    #[test]
9562    fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
9563        let host = Host::new();
9564        let error = host
9565            .operations()
9566            .set_start_mode(StartMode::Login)
9567            .expect_err("there is nothing to switch");
9568        assert!(
9569            matches!(error, ServiceError::NotInstalled { .. }),
9570            "{error}"
9571        );
9572    }
9573
9574    // -----------------------------------------------------------------------
9575    // Status
9576    // -----------------------------------------------------------------------
9577
9578    #[test]
9579    fn status_reports_the_four_facts_journey_five_asks_for() {
9580        let host = Host::new();
9581        let operations = host.operations();
9582        operations
9583            .install(&host.request(StartMode::Boot))
9584            .expect("an install");
9585        let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
9586            .expect("a valid timestamp")
9587            .with_timezone(&Utc);
9588        record_github_contact(&host.paths, at).expect("a heartbeat");
9589
9590        let status = operations.status().expect("a status");
9591        assert_eq!(status.start_mode(), Some(StartMode::Boot));
9592        assert_eq!(
9593            status.binary().map(BinaryPath::recorded),
9594            Some(host.binary.as_path())
9595        );
9596        assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
9597        assert_eq!(status.last_github_contact(), Some(at));
9598        assert!(status.is_installed());
9599        assert!(status.is_healthy(), "{status}");
9600
9601        let printed = status.to_string();
9602        for fragment in [
9603            "start mode",
9604            "diagnostic log",
9605            "last GitHub contact",
9606            "binary",
9607        ] {
9608            assert!(printed.contains(fragment), "{printed}");
9609        }
9610    }
9611
9612    /// A record this account may not read is reported, and `status` keeps
9613    /// going.
9614    ///
9615    /// It used to end the command with `Permission denied`, which is exactly
9616    /// what an operator met on a boot-mode host: `sudo service install` wrote
9617    /// the record `0600` and `root`-owned, and `service status` — a command
9618    /// that asks no privilege of anybody — then printed nothing at all about a
9619    /// registration the service manager could describe perfectly well.
9620    ///
9621    /// Ownership cannot be reproduced without privileges; the mode that made it
9622    /// fatal can.
9623    #[cfg(unix)]
9624    #[test]
9625    fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
9626        use std::os::unix::fs::PermissionsExt as _;
9627
9628        // `root` is not subject to the mode bits and would read the record
9629        // regardless, leaving the assertions below testing nothing.
9630        // SAFETY: `geteuid` takes no argument and cannot fail.
9631        if unsafe { libc::geteuid() } == 0 {
9632            return;
9633        }
9634
9635        let host = Host::new();
9636        let operations = host.operations();
9637        operations
9638            .install(&host.request(StartMode::Boot))
9639            .expect("an install");
9640        assert!(
9641            operations.status().expect("a status").is_healthy(),
9642            "the discriminator: healthy before the record is made unreadable"
9643        );
9644
9645        let record = InstallRecord::path(&host.paths);
9646        std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
9647            .expect("the mode is applied");
9648
9649        let status = operations
9650            .status()
9651            .expect("a record this account may not read is reported, not thrown");
9652        assert!(status.is_installed(), "{status}");
9653        assert!(!status.is_healthy(), "{status}");
9654
9655        let printed = status.to_string();
9656        assert!(
9657            printed.contains("this account may not read it"),
9658            "the operator is told which of the two states this is: {printed}"
9659        );
9660        assert!(
9661            !printed.contains("there is no install record"),
9662            "a record that is there and unreadable is not a record that is missing, and the \
9663             missing one's remedy starts with `service uninstall`: {printed}"
9664        );
9665
9666        // Left readable so the fixture's own cleanup is not the thing that
9667        // fails on a host where TempDir removal walks the tree.
9668        std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
9669            .expect("the mode is restored");
9670    }
9671
9672    #[test]
9673    fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
9674        let host = Host::new();
9675        let operations = host.operations();
9676        operations
9677            .install(&host.request(StartMode::Boot))
9678            .expect("an install");
9679
9680        // The discriminator: healthy first, so a `is_healthy` that always
9681        // returned false could not pass this test.
9682        assert!(
9683            operations.status().expect("a status").is_healthy(),
9684            "the freshly installed host must be healthy"
9685        );
9686
9687        std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
9688
9689        let status = operations.status().expect("a status");
9690        assert!(!status.is_healthy(), "{status}");
9691        assert!(
9692            status
9693                .problems()
9694                .iter()
9695                .any(|problem| problem.subject == "binary"),
9696            "{status}"
9697        );
9698        assert!(status.to_string().contains("STALE"), "{status}");
9699    }
9700
9701    #[test]
9702    fn status_reports_a_registration_that_would_not_start_at_boot() {
9703        let host = Host::new();
9704        let operations = host.operations();
9705        operations
9706            .install(&host.request(StartMode::Boot))
9707            .expect("an install");
9708        assert!(operations.status().expect("a status").is_healthy());
9709
9710        host.controls.edit("runner-manager", |registration| {
9711            registration.starts_automatically = false;
9712        });
9713
9714        let status = operations.status().expect("a status");
9715        assert!(!status.is_healthy(), "{status}");
9716        assert!(
9717            status
9718                .problems()
9719                .iter()
9720                .any(|problem| problem.detail.contains("after a reboot")),
9721            "{status}"
9722        );
9723    }
9724
9725    #[test]
9726    fn install_starts_the_registration_and_a_later_stop_is_unhealthy() {
9727        let host = Host::new();
9728        let operations = host.operations();
9729        operations
9730            .install(&host.request(StartMode::Login))
9731            .expect("install and immediate start");
9732
9733        assert!(
9734            operations.status().expect("running status").is_running(),
9735            "install must not wait for the next login trigger"
9736        );
9737        operations.stop().expect("stop the registration");
9738        let status = operations.status().expect("stopped status");
9739        assert!(!status.is_healthy(), "{status}");
9740        assert!(
9741            status
9742                .problems()
9743                .iter()
9744                .any(|problem| problem.subject == "runtime"),
9745            "{status}"
9746        );
9747        let rendered = status.to_string();
9748        assert!(
9749            rendered.contains("runner-manager service install --start-at login"),
9750            "{rendered}"
9751        );
9752        assert!(
9753            !rendered.contains("runner-manager service start"),
9754            "{rendered}"
9755        );
9756    }
9757
9758    #[test]
9759    fn status_reports_a_restart_policy_something_else_edited() {
9760        let host = Host::new();
9761        let operations = host.operations();
9762        operations
9763            .install(&host.request(StartMode::Boot))
9764            .expect("an install");
9765        assert!(operations.status().expect("a status").is_healthy());
9766
9767        host.controls.edit("runner-manager", |registration| {
9768            registration.restart_delay = Some(Duration::from_secs(1));
9769        });
9770
9771        let status = operations.status().expect("a status");
9772        assert!(!status.is_healthy(), "{status}");
9773        assert!(
9774            status
9775                .problems()
9776                .iter()
9777                .any(|problem| problem.subject == "restart policy"),
9778            "{status}"
9779        );
9780    }
9781
9782    #[test]
9783    fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
9784        let host = Host::new();
9785        let operations = host.operations();
9786        operations
9787            .install(&host.request(StartMode::Boot))
9788            .expect("an install");
9789        let other = host.binary.with_file_name("someone-elses.exe");
9790        std::fs::write(&other, b"x").expect("writable");
9791
9792        host.controls.edit("runner-manager", |registration| {
9793            registration.command_line = quote_argument(&other.to_string_lossy());
9794        });
9795
9796        let status = operations.status().expect("a status");
9797        assert!(!status.is_healthy(), "{status}");
9798        assert!(
9799            matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
9800            "{status}"
9801        );
9802    }
9803
9804    #[test]
9805    fn status_reports_a_record_no_service_manager_knows_about() {
9806        let host = Host::new();
9807        let operations = host.operations();
9808        operations
9809            .install(&host.request(StartMode::Boot))
9810            .expect("an install");
9811        // Something removed the registration behind this product's back.
9812        for mode in [StartMode::Boot, StartMode::Login] {
9813            host.controls
9814                .control(mode)
9815                .expect("a control")
9816                .uninstall(&ServiceIdentity::product())
9817                .expect("removed");
9818        }
9819
9820        let status = operations.status().expect("a status");
9821        assert!(!status.is_healthy(), "{status}");
9822        assert!(
9823            status
9824                .problems()
9825                .iter()
9826                .any(|problem| problem.subject == "registration"),
9827            "{status}"
9828        );
9829    }
9830
9831    #[test]
9832    fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
9833        let host = Host::new();
9834        let status = host.operations().status().expect("a status");
9835        assert!(!status.is_installed());
9836        assert!(
9837            status.is_healthy(),
9838            "a host that never installed the service has no fault to report: {status}"
9839        );
9840        assert!(status.to_string().contains("installed"), "{status}");
9841    }
9842
9843    #[test]
9844    fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
9845        let host = Host::new();
9846        let operations = host.operations();
9847        operations
9848            .install(&host.request(StartMode::Login))
9849            .expect("an install at login");
9850        let status = operations.status().expect("a status");
9851        assert!(
9852            status
9853                .notes()
9854                .iter()
9855                .any(|note| note.contains("does not run until the operator signs in")),
9856            "05-infrastructure.md requires `service status` to say so: {status}"
9857        );
9858    }
9859
9860    // -----------------------------------------------------------------------
9861    // Start and stop
9862    // -----------------------------------------------------------------------
9863
9864    #[test]
9865    fn start_and_stop_reach_the_domain_that_holds_the_registration() {
9866        let host = Host::new();
9867        let operations = host.operations();
9868        operations
9869            .install(&host.request(StartMode::Login))
9870            .expect("an install at login");
9871        operations.start().expect("a start");
9872        assert!(operations.status().expect("a status").is_running());
9873        assert!(operations.stop().expect("a stop"));
9874        assert!(!operations.status().expect("a status").is_running());
9875    }
9876
9877    #[test]
9878    fn starting_a_host_with_no_registration_is_refused() {
9879        let host = Host::new();
9880        let error = host.operations().start().expect_err("nothing to start");
9881        assert!(
9882            matches!(error, ServiceError::NotInstalled { .. }),
9883            "{error}"
9884        );
9885    }
9886
9887    // -----------------------------------------------------------------------
9888    // The seam with `d2`
9889    // -----------------------------------------------------------------------
9890
9891    /// Requirement 2 is *"an account that can **read the secret store**"*, and
9892    /// on Windows what an account may read is decided by a DACL in a file this
9893    /// task does not own. Asserting the account name alone would be asserting
9894    /// this module against itself; this reads `d2`'s protection back off a real
9895    /// store and checks that it admits the account this installer registers.
9896    ///
9897    /// No privileges are needed. `d2` documents that a rooted machine-scoped
9898    /// store *"is protected and encrypted exactly as the standard one is"* —
9899    /// the Windows backend picks its DACL from the scope, not from the site —
9900    /// so a store in a temporary directory carries the access control the real
9901    /// one does.
9902    #[cfg(windows)]
9903    #[test]
9904    fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
9905        use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
9906
9907        let root = tempfile::tempdir().expect("a temporary directory");
9908        let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
9909            .expect("a rooted machine-scoped store");
9910        store
9911            .store(&secrecy::SecretString::from("a stand-in for the token"))
9912            .expect("the store accepts a value");
9913        let protection = store.protection().expect("the DACL can be read back");
9914
9915        assert!(
9916            protection.description().contains(";;;SY)"),
9917            "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
9918             read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
9919            protection.description()
9920        );
9921        assert_eq!(
9922            ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
9923            ServiceAccount::LocalSystem,
9924            "and that is the account this installer registers, which is why SY is what matters"
9925        );
9926        assert!(
9927            !protection.readable_by_other_local_users(),
9928            "the same DACL must still exclude ordinary local users: {}",
9929            protection.description()
9930        );
9931
9932        // The other half of the analysis in `docs/service-account.md`, and the
9933        // reason LocalService and NetworkService are not used: the DACL names
9934        // three trustees and neither of them is among them.
9935        for rejected in [";;;LS)", ";;;NS)"] {
9936            assert!(
9937                !protection.description().contains(rejected),
9938                "if the store ever admitted {rejected}, the least-privilege analysis in \
9939                 docs/service-account.md would need redoing: {}",
9940                protection.description()
9941            );
9942        }
9943    }
9944}