Skip to main content

runner_manager_platform/
secrets.rs

1// owner: d2-machine-secret-store
2
3//! The one persisted GitHub credential, in a place a boot-time service can
4//! read.
5//!
6//! `07-security.md` counts the persisted credential surface and gets to one:
7//! *"The product now holds exactly one persisted GitHub credential and one
8//! short-lived sensitive value."* This module is where that one credential
9//! lives. The short-lived one — the encoded JIT configuration — belongs to
10//! [`crate::process::RestrictiveHandoff`] and is not stored here or anywhere
11//! else.
12//!
13//! # The constraint that decides the whole design
14//!
15//! D13 requires the service to start at **machine boot**.
16//! `05-infrastructure.md`: *"A boot-time service runs outside any user's login
17//! session and cannot read a per-user keychain on any supported OS: macOS
18//! LaunchAgents start only at login, and Windows Credential Manager vaults are
19//! per-user."* So the default store is machine-scoped, and every per-user
20//! secret facility on all three operating systems is unavailable to it by
21//! construction rather than by preference.
22//!
23//! | OS | [`SecretScope::Machine`] | [`SecretScope::User`] |
24//! |---|---|---|
25//! | Windows | DPAPI **machine** scope, in a file under `%ProgramData%` with its own protected DACL | DPAPI **user** scope, in a file under `%LOCALAPPDATA%` with its own protected DACL |
26//! | macOS | System Keychain (`/Library/Keychains/System.keychain`) | the account's login keychain |
27//! | Linux | `0600` file under `/var/lib/runner-manager`, plus the systemd credential the service is started with | `0600` file under `$XDG_DATA_HOME/runner-manager` |
28//!
29//! `service install --start-at login` is the escape hatch
30//! `07-security.md` promises operators who reject machine-scoped storage:
31//! *"Operators who reject this can use `service install --start-at login` and
32//! keep a user-scoped store, accepting no unattended restart."* Both columns
33//! implement [`SecretStore`], the active one is chosen by
34//! [`SecretScope::for_start_mode`], and [`ActiveStore`] is what `host show`
35//! and `service status` print so the choice is inspectable rather than
36//! implied.
37//!
38//! # The accepted trade-off, implemented honestly
39//!
40//! A local administrator or `root` on this machine can read a machine-scoped
41//! secret. `07-security.md` records that as an accepted consequence, on the
42//! grounds that such an account *"can already read the runner's own
43//! credentials and job workspaces"*. Nothing here tries to defeat it, and
44//! nothing here pretends to. What is defended is the case that is actually in
45//! the threat model: **an ordinary local user who is not an administrator must
46//! not be able to read the stored value**, and [`SecretStore::protection`]
47//! reports whether that holds, per OS, through the one cross-platform name
48//! [`crate::process::permissions_summary`] already defines.
49//!
50//! Delete is delete, not erasure. [`crate::process::RestrictiveHandoff`] sets
51//! out why no userspace program can promise that the bytes are unrecoverable —
52//! a journal, a snapshot, or an SSD's wear levelling each keep copies an
53//! overwrite never reaches — and the same disclaimer applies here. The Linux
54//! backend overwrites before unlinking because the value is at rest in
55//! plaintext there and the overwrite is free; that is a best effort and is not
56//! a claim.
57//!
58//! # What never holds this value
59//!
60//! SQLite, TOML configuration, logs, diagnostics, UI state, and command-line
61//! arguments. The type system carries as much of that as it can: the value
62//! crosses this module's surface only as a [`SecretString`], which has no
63//! `Display` and a redacting `Debug`, and no error in [`SecretStoreError`]
64//! carries the value or any part of it. `client_id` is public by design
65//! (`07-security.md`: *"Public by design; may appear in logs and
66//! documentation"*) and is not a secret this store handles.
67//!
68//! # Blocking, not async
69//!
70//! DPAPI, Security.framework and `open(2)` are blocking calls that take
71//! microseconds. This runs twice in the life of a process — once at `auth
72//! login`, once at startup — so an async surface would buy nothing and would
73//! oblige every caller to be in a runtime to read a file.
74//!
75//! # Where a test may point it, and where it may not
76//!
77//! [`PlatformSecretStore::standard`] resolves the production location in the
78//! table above. [`PlatformSecretStore::rooted_at`] puts the same backend under
79//! a directory the caller names, exactly as
80//! [`crate::paths::AppPaths::rooted_at`] does for the four application-data
81//! directories, and for the same two reasons: a test needs a disposable store,
82//! and a service installed against an explicitly configured root has to
83//! reproduce one.
84//!
85//! **No test in this crate writes to a standard location**, and that is a
86//! deliberate constraint rather than an oversight. Two of the six standard
87//! locations need `root` to create (`/var/lib`, the System Keychain), and the
88//! other four are the operator's real store — a suite that wrote there would
89//! destroy a developer's `auth login` every time it ran. So the standard
90//! locations are asserted by *resolution*, and every round trip runs against a
91//! rooted store. What that does and does not cover is written out at
92//! [`PlatformSecretStore::rooted_at`].
93
94use std::fmt;
95use std::path::{Path, PathBuf};
96
97use runner_manager_domain::model::StartMode;
98use secrecy::{ExposeSecret, SecretString};
99
100/// The product identity, resolved from the one place that defines it.
101///
102/// `crate::paths` owns these three segments and builds the four
103/// application-data directories out of them. This module builds *different*
104/// locations — `%ProgramData%\<org>\<app>`, `$XDG_DATA_HOME/<app>`, the macOS
105/// keychain service — but out of the same identity, and it must stay the same
106/// identity. Spelled a second time here, a drift would move the secret store
107/// out from under an upgraded binary and the token would read as simply
108/// absent, which is the one failure mode this store must never produce
109/// silently.
110use crate::paths::{APPLICATION, ORGANIZATION, QUALIFIER};
111
112/// Reverse-domain service name for the macOS keychain item.
113///
114/// Composed rather than written out, for the reason above. It is a `LazyLock`
115/// because the three segments are `const`s and neither `concat!` nor a `const
116/// fn` can join them — `concat!` takes literals, not constant items. The
117/// composition is asserted against the documented literal by
118/// `the_standard_locations_are_the_documented_ones`, which keeps its own
119/// hard-coded strings precisely so that it is an *independent* oracle rather
120/// than a second copy of this expression.
121#[cfg(target_os = "macos")]
122static KEYCHAIN_SERVICE: std::sync::LazyLock<String> =
123    std::sync::LazyLock::new(|| format!("{QUALIFIER}.{ORGANIZATION}.{APPLICATION}"));
124
125/// The keychain account, and the stem of the file name on the two platforms
126/// that use a file. One value, one name, everywhere.
127const ITEM: &str = "user-access-token";
128
129/// The directory a file-backed store keeps its value in, under whichever root
130/// the scope resolved to. Never `config/` — `05-infrastructure.md` reserves
131/// that for *"non-secret TOML and SQLite"*.
132const DIRECTORY: &str = "secrets";
133
134// ---------------------------------------------------------------------------
135// Scope
136// ---------------------------------------------------------------------------
137
138/// Which of the two stores a value lives in.
139///
140/// Not a preference and not a tuning knob: it is a direct function of the
141/// service's start mode, because a service that starts at boot has no login
142/// session to read a user-scoped store from. [`SecretScope::for_start_mode`]
143/// is that function, and it is total in both directions —
144/// [`SecretScope::start_mode`] is its inverse, which is what lets
145/// [`ActiveStore`] state whether the store in use agrees with the start mode
146/// actually configured.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
148pub enum SecretScope {
149    /// Readable by a service running at machine boot, outside any login
150    /// session. The default, and what `--start-at boot` requires.
151    Machine,
152    /// Readable only inside the operator's login session. What
153    /// `--start-at login` gets, at the cost of no unattended restart.
154    User,
155}
156
157impl SecretScope {
158    /// The store a given start mode obliges.
159    #[must_use]
160    pub const fn for_start_mode(mode: StartMode) -> Self {
161        match mode {
162            StartMode::Boot => Self::Machine,
163            StartMode::Login => Self::User,
164        }
165    }
166
167    /// The start mode this store is the one correct choice for.
168    #[must_use]
169    pub const fn start_mode(self) -> StartMode {
170        match self {
171            Self::Machine => StartMode::Boot,
172            Self::User => StartMode::Login,
173        }
174    }
175
176    /// The name `host show`, `service status`, and the `scope` log field use.
177    #[must_use]
178    pub const fn as_str(self) -> &'static str {
179        match self {
180            Self::Machine => "machine",
181            Self::User => "user",
182        }
183    }
184}
185
186impl fmt::Display for SecretScope {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        f.write_str(self.as_str())
189    }
190}
191
192// ---------------------------------------------------------------------------
193// Errors
194// ---------------------------------------------------------------------------
195
196/// Something went wrong reaching the secret store.
197///
198/// **No variant carries the stored value, and none ever may.** Every one of
199/// them is formatted into an operator-facing message and into a `tracing`
200/// event, and `07-security.md`'s release gate is that the user access token is
201/// *"absent from logs, databases, snapshots, crash reports, and CLI output"*.
202/// A variant that carried the value would put it in all four at once.
203///
204/// Note what is *not* here: an "absent" variant. A load that finds nothing is
205/// [`Ok(None)`], because absence is the ordinary state of this store before
206/// `auth login` and after `auth logout`, and a caller that has to distinguish
207/// "not logged in" from "the keychain is unreachable" cannot be asked to do it
208/// by matching on an error kind.
209#[derive(Debug, thiserror::Error)]
210pub enum SecretStoreError {
211    /// The store's location could not be worked out at all.
212    #[error("cannot work out where the {scope}-scoped secret store lives: {reason}")]
213    Resolve {
214        /// Which store was being resolved.
215        scope: SecretScope,
216        /// What was missing, in terms an operator can act on.
217        reason: String,
218    },
219
220    /// The value could not be written.
221    #[error(
222        "cannot write the user access token to the {scope}-scoped store at {location}: {source}"
223    )]
224    Store {
225        /// Which store.
226        scope: SecretScope,
227        /// Where it lives, as [`SecretStore::location`] reports it.
228        location: String,
229        /// The underlying platform error.
230        #[source]
231        source: std::io::Error,
232    },
233
234    /// The value could not be read back.
235    #[error(
236        "cannot read the user access token from the {scope}-scoped store at {location}: {source}"
237    )]
238    Load {
239        /// Which store.
240        scope: SecretScope,
241        /// Where it lives.
242        location: String,
243        /// The underlying platform error.
244        #[source]
245        source: std::io::Error,
246    },
247
248    /// The value could not be removed.
249    ///
250    /// Worth an error rather than a shrug, for the reason
251    /// `05-infrastructure.md` gives the credential-disclosure response: step 2
252    /// is *"Run `auth logout` on every host to purge the machine-scoped secret
253    /// store"*, and an operator following that procedure has to be told when a
254    /// host did not comply.
255    #[error(
256        "cannot delete the user access token from the {scope}-scoped store at {location}: {source}"
257    )]
258    Delete {
259        /// Which store.
260        scope: SecretScope,
261        /// Where it lives.
262        location: String,
263        /// The underlying platform error.
264        #[source]
265        source: std::io::Error,
266    },
267
268    /// Something is stored, and it is not a value this store wrote.
269    ///
270    /// Deliberately not folded into [`SecretStoreError::Load`] and deliberately
271    /// not reported as absence. A caller that saw absence would silently start
272    /// a device-flow login and overwrite whatever is there; a caller that saw a
273    /// transient read failure would retry forever. This is neither: it is an
274    /// operator-actionable condition whose remedy is `auth logout` followed by
275    /// `auth login`.
276    #[error(
277        "the {scope}-scoped store at {location} does not hold a user access token this product \
278         wrote: {detail}. Run `auth logout` to purge it and `auth login` to obtain a fresh token."
279    )]
280    Corrupt {
281        /// Which store.
282        scope: SecretScope,
283        /// Where it lives.
284        location: String,
285        /// What is wrong with what is there, in terms that name no part of it.
286        /// A byte count is not a disclosure; the bytes would be, so they are
287        /// never carried.
288        detail: String,
289    },
290
291    /// The access control protecting the value could not be read back.
292    #[error("cannot inspect what protects the {scope}-scoped store at {}: {source}", guard.display())]
293    Inspect {
294        /// Which store.
295        scope: SecretScope,
296        /// The filesystem object whose access control was being read.
297        guard: PathBuf,
298        /// The underlying error.
299        #[source]
300        source: crate::process::HandoffError,
301    },
302}
303
304// ---------------------------------------------------------------------------
305// What the operations report
306// ---------------------------------------------------------------------------
307
308/// What [`SecretStore::delete`] found.
309///
310/// Both variants are success. `auth logout` on a host that was never logged in
311/// is not a failure, and the credential-disclosure procedure in
312/// `05-infrastructure.md` is run across *every* host precisely because the
313/// operator does not know which ones hold a value.
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub enum Removal {
316    /// A value was there and is not any more.
317    Removed,
318    /// There was nothing to remove.
319    AlreadyAbsent,
320}
321
322impl Removal {
323    /// Whether this call is the one that removed something.
324    #[must_use]
325    pub const fn removed_something(self) -> bool {
326        matches!(self, Self::Removed)
327    }
328}
329
330/// What actually stands between the stored value and an ordinary local user.
331///
332/// The three operating systems protect it with three different mechanisms, so
333/// the useful cross-platform question is not "what is the mode" but "**which
334/// object's access control decides who can read this, and does it exclude an
335/// unprivileged local user**". [`Protection::guard`] answers the first half and
336/// [`Protection::readable_by_other_local_users`] the second.
337///
338/// | store | guard |
339/// |---|---|
340/// | Windows, either scope | the file holding the DPAPI blob, whose DACL is protected and names no broad trustee |
341/// | Linux, either scope | the `0600` file |
342/// | macOS, login or rooted keychain | the keychain database file |
343/// | macOS, System Keychain | `/var/db/SystemKey`, the root-only master key that unlocks it |
344///
345/// That last row is the one worth reading twice.
346/// `/Library/Keychains/System.keychain` is itself world-readable, and saying so
347/// and stopping would be both true and useless: its contents are encrypted, and
348/// what decides who can decrypt them is the mode of the master key beside it.
349/// Reporting the keychain database there would answer a question nobody asked
350/// and would answer it wrongly.
351#[derive(Debug, Clone, PartialEq, Eq)]
352pub struct Protection {
353    guard: PathBuf,
354    description: String,
355    readable_by_other_local_users: bool,
356}
357
358impl Protection {
359    /// The filesystem object whose access control was inspected.
360    #[must_use]
361    pub fn guard(&self) -> &Path {
362        &self.guard
363    }
364
365    /// The platform's own description of it — a Unix mode, or a DACL in SDDL
366    /// form. For diagnostics and for test failure messages.
367    #[must_use]
368    pub fn description(&self) -> &str {
369        &self.description
370    }
371
372    /// Whether an ordinary local user other than the owner could read the
373    /// stored value.
374    ///
375    /// A local administrator or `root` is deliberately outside this question;
376    /// see this module's documentation and `07-security.md`.
377    #[must_use]
378    pub const fn readable_by_other_local_users(&self) -> bool {
379        self.readable_by_other_local_users
380    }
381}
382
383impl fmt::Display for Protection {
384    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
385        write!(
386            f,
387            "{} ({}){}",
388            self.guard.display(),
389            self.description,
390            if self.readable_by_other_local_users {
391                " -- READABLE BY OTHER LOCAL USERS"
392            } else {
393                ""
394            }
395        )
396    }
397}
398
399/// Which store is in use, and whether that is the one the configured start
400/// mode obliges.
401///
402/// `05-infrastructure.md` requires the start mode to be visible in `host show`
403/// and in TUI host settings, and requires `service status` to say when
404/// `--start-at login` means the agent does not run until the operator logs in.
405/// This is the value both of those print. It exists as a type rather than as a
406/// formatted string in two places so that the two cannot drift.
407///
408/// [`ActiveStore::agrees_with_start_mode`] is the check worth having: the store
409/// a process opened and the start mode recorded for the installed service are
410/// two independently persisted facts, and a host whose service was switched
411/// from `boot` to `login` without the token being moved is a host whose daemon
412/// will start and then fail to find a credential. Saying so in `service status`
413/// is cheaper than finding out at three in the morning.
414#[derive(Debug, Clone, PartialEq, Eq)]
415pub struct ActiveStore {
416    scope: SecretScope,
417    start_mode: StartMode,
418    location: String,
419}
420
421impl ActiveStore {
422    /// Pairs a store with the start mode currently configured for the service.
423    #[must_use]
424    pub fn of(store: &dyn SecretStore, start_mode: StartMode) -> Self {
425        Self {
426            scope: store.scope(),
427            start_mode,
428            location: store.location(),
429        }
430    }
431
432    /// The store in use.
433    #[must_use]
434    pub const fn scope(&self) -> SecretScope {
435        self.scope
436    }
437
438    /// The start mode configured for the service.
439    #[must_use]
440    pub const fn start_mode(&self) -> StartMode {
441        self.start_mode
442    }
443
444    /// Where the store keeps its value, in the platform's own terms.
445    #[must_use]
446    pub fn location(&self) -> &str {
447        &self.location
448    }
449
450    /// Whether the store in use is the one this start mode obliges.
451    #[must_use]
452    pub fn agrees_with_start_mode(&self) -> bool {
453        SecretScope::for_start_mode(self.start_mode) == self.scope
454    }
455}
456
457impl fmt::Display for ActiveStore {
458    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
459        write!(
460            f,
461            "{}-scoped secret store at {} (service starts at {})",
462            self.scope, self.location, self.start_mode
463        )?;
464        if !self.agrees_with_start_mode() {
465            write!(
466                f,
467                " -- MISMATCH: starting at {} needs the {}-scoped store",
468                self.start_mode,
469                SecretScope::for_start_mode(self.start_mode)
470            )?;
471        }
472        Ok(())
473    }
474}
475
476// ---------------------------------------------------------------------------
477// The port
478// ---------------------------------------------------------------------------
479
480/// Store, load, delete — and say where the value lives and what protects it.
481///
482/// `Send + Sync` because the agent holds one across `tokio` tasks; `Debug`
483/// because every other port in this workspace is, and because a store that
484/// could not be printed in an error context would be replaced by a path that
485/// could.
486pub trait SecretStore: fmt::Debug + Send + Sync {
487    /// Which of the two stores this is.
488    fn scope(&self) -> SecretScope;
489
490    /// Where the value lives, in the platform's own terms. What `host show`
491    /// and `service status` print, and never the value itself.
492    fn location(&self) -> String;
493
494    /// Writes the value, replacing whatever was there.
495    ///
496    /// # Errors
497    ///
498    /// [`SecretStoreError::Store`].
499    fn store(&self, secret: &SecretString) -> Result<(), SecretStoreError>;
500
501    /// Refuses when [`SecretStore::store`] would, without writing anything.
502    ///
503    /// For a caller about to do something that only a successful write makes
504    /// safe. Renewal is that caller: GitHub retires a refresh token the moment
505    /// it is exchanged, so a process that exchanges it and then cannot store
506    /// the replacement has destroyed the credential for every process sharing
507    /// this store. On a boot-mode host that is any TUI or CLI command started
508    /// without the privilege the machine-scoped store needs to be written,
509    /// even though it can read it.
510    ///
511    /// A permission check, not a promise: a write can still fail for reasons
512    /// no check can foresee. It answers the question that is stable across a
513    /// process's lifetime -- whether *this* account may write here at all.
514    ///
515    /// # Errors
516    ///
517    /// [`SecretStoreError::Store`], carrying why a write would be refused.
518    fn ensure_writable(&self) -> Result<(), SecretStoreError>;
519
520    /// Reads the value back, or reports that there is none.
521    ///
522    /// `Ok(None)` is absence and is not an error; see [`SecretStoreError`].
523    ///
524    /// # Errors
525    ///
526    /// [`SecretStoreError::Load`] when the store cannot be reached, and
527    /// [`SecretStoreError::Corrupt`] when what is there is not a value this
528    /// store wrote.
529    fn load(&self) -> Result<Option<SecretString>, SecretStoreError>;
530
531    /// Removes the value.
532    ///
533    /// # Errors
534    ///
535    /// [`SecretStoreError::Delete`].
536    fn delete(&self) -> Result<Removal, SecretStoreError>;
537
538    /// What stands between the stored value and an ordinary local user.
539    ///
540    /// # Errors
541    ///
542    /// [`SecretStoreError::Inspect`].
543    fn protection(&self) -> Result<Protection, SecretStoreError>;
544}
545
546// ---------------------------------------------------------------------------
547// The platform store
548// ---------------------------------------------------------------------------
549
550/// The real store: DPAPI on Windows, a keychain on macOS, a `0600` file plus
551/// systemd credentials on Linux.
552///
553/// One type with three bodies rather than three types, because the choice is
554/// made by `cfg` at compile time and a caller never has one without the other
555/// two being impossible. The scope, by contrast, is a runtime choice and is a
556/// field.
557#[derive(Debug, Clone, PartialEq, Eq)]
558pub struct PlatformSecretStore {
559    scope: SecretScope,
560    site: sys::Site,
561}
562
563impl PlatformSecretStore {
564    /// Resolves the platform-standard location for `scope`.
565    ///
566    /// This is what production uses and what `host show` reports. Nothing is
567    /// touched on disk here; the directory, the file, and the keychain are
568    /// created by the first [`SecretStore::store`].
569    ///
570    /// # Errors
571    ///
572    /// [`SecretStoreError::Resolve`] when the platform cannot say where the
573    /// location is — no `%ProgramData%` on Windows, no home directory for this
574    /// account on the two Unixes. A service account configured with no profile
575    /// is the way that actually happens, and the message says so.
576    pub fn standard(scope: SecretScope) -> Result<Self, SecretStoreError> {
577        let site = sys::standard_site(scope).map_err(|reason| SecretStoreError::Resolve {
578            scope,
579            reason: reason.to_string(),
580        })?;
581        Ok(Self { scope, site })
582    }
583
584    /// Resolves the store the configured start mode obliges.
585    ///
586    /// The one constructor a daemon should use: it makes the store a function
587    /// of the recorded start mode rather than of whichever constructor the
588    /// call site happened to reach for.
589    ///
590    /// # Errors
591    ///
592    /// As [`PlatformSecretStore::standard`].
593    pub fn for_start_mode(mode: StartMode) -> Result<Self, SecretStoreError> {
594        Self::standard(SecretScope::for_start_mode(mode))
595    }
596
597    /// Places the same backend under a root the caller names.
598    ///
599    /// [`crate::paths::AppPaths::rooted_at`] is the precedent and the reasoning
600    /// is the same: a test needs a disposable store, and a service installed
601    /// against an explicitly configured root has to reproduce one. A relative
602    /// root stays relative, which is the caller's decision to make.
603    ///
604    /// # What a rooted store does and does not exercise
605    ///
606    /// Everything that makes a scope a scope, on two of the three platforms.
607    /// The Windows backend picks its DPAPI flag and its DACL from the scope,
608    /// not from the site, so a rooted machine-scoped store is protected and
609    /// encrypted exactly as the standard one is. The Linux backend's `0600`
610    /// file, its atomic replace, and its systemd-credential read path are the
611    /// same code under either root.
612    ///
613    /// macOS is where a rooted store is genuinely weaker, and it is worth being
614    /// precise about how. A rooted store is a keychain this program creates in
615    /// the root directory, and both scopes get one; what the standard sites
616    /// have and it does not is the *choice of keychain* — the System Keychain
617    /// against the login keychain — and the System Keychain's root-only master
618    /// key. The keychain calls themselves, the item, the not-found handling and
619    /// the delete are identical. The password protecting a rooted keychain is
620    /// [`ROOTED_KEYCHAIN_PASSWORD`], which is a constant in a public binary and
621    /// therefore protects nothing on its own: what protects a rooted keychain
622    /// is the mode of the directory it is in, which is the same protection the
623    /// Linux backend relies on for a value it stores in plaintext.
624    ///
625    /// # Errors
626    ///
627    /// [`SecretStoreError::Resolve`]; in practice it cannot fail, because the
628    /// caller supplied the root.
629    pub fn rooted_at(scope: SecretScope, root: impl AsRef<Path>) -> Result<Self, SecretStoreError> {
630        let site =
631            sys::rooted_site(scope, root.as_ref()).map_err(|reason| SecretStoreError::Resolve {
632                scope,
633                reason: reason.to_string(),
634            })?;
635        Ok(Self { scope, site })
636    }
637
638    /// The filesystem object whose access control decides who can read this
639    /// store, whether or not anything is stored yet.
640    ///
641    /// Separate from [`SecretStore::protection`] because the guard can be named
642    /// before the store exists, which is what lets `host show` report the
643    /// machine store's protection on a host that has never logged in.
644    #[must_use]
645    pub fn guard(&self) -> PathBuf {
646        sys::guard(&self.site)
647    }
648
649    /// Turns platform bytes into the value, or says why they are not one.
650    fn decode(&self, bytes: Vec<u8>) -> Result<SecretString, SecretStoreError> {
651        use secrecy::zeroize::Zeroize as _;
652
653        let length = bytes.len();
654        // A zero-length value is not a credential and cannot have been written
655        // by `store`, which refuses one. Treating it as absence would hide a
656        // truncated write behind a device-flow login.
657        if length == 0 {
658            return Err(self.corrupt("it is empty"));
659        }
660
661        match String::from_utf8(bytes) {
662            Ok(text) => Ok(SecretString::from(text)),
663            Err(error) => {
664                // The bytes are not the value, but they may still be *a* value
665                // — something else's, or a partially overwritten one. Scrub the
666                // buffer rather than dropping it, and report only its length.
667                let mut bytes = error.into_bytes();
668                bytes.zeroize();
669                Err(self.corrupt(&format!("the {length} bytes there are not valid UTF-8")))
670            }
671        }
672    }
673
674    fn corrupt(&self, detail: &str) -> SecretStoreError {
675        SecretStoreError::Corrupt {
676            scope: self.scope,
677            location: self.location(),
678            detail: detail.to_string(),
679        }
680    }
681}
682
683impl SecretStore for PlatformSecretStore {
684    fn scope(&self) -> SecretScope {
685        self.scope
686    }
687
688    fn location(&self) -> String {
689        sys::describe(&self.site)
690    }
691
692    fn store(&self, secret: &SecretString) -> Result<(), SecretStoreError> {
693        let failed = |source| SecretStoreError::Store {
694            scope: self.scope,
695            location: self.location(),
696            source,
697        };
698
699        let exposed = secret.expose_secret();
700        if exposed.is_empty() {
701            // Refused here rather than accepted and stored, because an empty
702            // value is indistinguishable from a truncated one on the way back
703            // out and `decode` has to reject it either way.
704            return Err(failed(std::io::Error::new(
705                std::io::ErrorKind::InvalidInput,
706                "an empty value is not a user access token",
707            )));
708        }
709
710        sys::store(&self.site, self.scope, exposed.as_bytes()).map_err(failed)?;
711
712        tracing::info!(
713            event = "secret_store_written",
714            scope = self.scope.as_str(),
715            "the user access token was written to the secret store"
716        );
717        Ok(())
718    }
719
720    fn ensure_writable(&self) -> Result<(), SecretStoreError> {
721        sys::ensure_writable(&self.site).map_err(|source| SecretStoreError::Store {
722            scope: self.scope,
723            location: self.location(),
724            source,
725        })
726    }
727
728    fn load(&self) -> Result<Option<SecretString>, SecretStoreError> {
729        let bytes = sys::load(&self.site, self.scope).map_err(|source| {
730            // A backend reports "there is something here and it is not ours"
731            // as `InvalidData` -- a DPAPI blob this machine's key cannot
732            // unprotect, a keychain item of the wrong shape. That is the
733            // `Corrupt` condition, not a transient read failure, and the
734            // difference decides whether a caller retries or tells the
735            // operator to run `auth logout`.
736            if source.kind() == std::io::ErrorKind::InvalidData {
737                self.corrupt(&source.to_string())
738            } else {
739                SecretStoreError::Load {
740                    scope: self.scope,
741                    location: self.location(),
742                    source,
743                }
744            }
745        })?;
746
747        match bytes {
748            Some(bytes) => self.decode(bytes).map(Some),
749            None => Ok(None),
750        }
751    }
752
753    fn delete(&self) -> Result<Removal, SecretStoreError> {
754        let removed = sys::delete(&self.site).map_err(|source| SecretStoreError::Delete {
755            scope: self.scope,
756            location: self.location(),
757            source,
758        })?;
759
760        let removal = if removed {
761            Removal::Removed
762        } else {
763            Removal::AlreadyAbsent
764        };
765
766        tracing::info!(
767            event = "secret_store_purged",
768            scope = self.scope.as_str(),
769            outcome = if removal.removed_something() {
770                "removed"
771            } else {
772                "already_absent"
773            },
774            "the user access token was purged from the secret store"
775        );
776        Ok(removal)
777    }
778
779    fn protection(&self) -> Result<Protection, SecretStoreError> {
780        let guard = sys::guard(&self.site);
781        let summary = crate::process::permissions_summary(&guard).map_err(|source| {
782            SecretStoreError::Inspect {
783                scope: self.scope,
784                guard: guard.clone(),
785                source,
786            }
787        })?;
788
789        Ok(Protection {
790            guard,
791            description: summary.description,
792            readable_by_other_local_users: summary.readable_by_other_local_users,
793        })
794    }
795}
796
797/// The password protecting a keychain created by
798/// [`PlatformSecretStore::rooted_at`] on macOS.
799///
800/// A constant in a public binary, and therefore not a secret. It is here
801/// because `SecKeychainCreate` requires *a* password and the alternative —
802/// passing `NULL` — prompts the operator for one, which a daemon must never
803/// do. What protects a rooted keychain is the mode of the directory it is
804/// created in; see [`PlatformSecretStore::rooted_at`].
805///
806/// Public so that a test can assert this is what it is, rather than discovering
807/// it by reading the backend.
808pub const ROOTED_KEYCHAIN_PASSWORD: &str = "runner-manager-rooted-keychain";
809
810/// The name of the systemd credential the Linux machine-scoped store reads
811/// before it reads its own file.
812///
813/// `05-infrastructure.md` puts the Linux machine store at *"`0600` file plus
814/// systemd credentials"*, and the second half is a read path rather than a
815/// write path: `systemd` decrypts a credential into a private `ramfs` at
816/// `$CREDENTIALS_DIRECTORY` and mounts it read-only, so a service given
817/// `LoadCredentialEncrypted=` gets the value without the file ever being
818/// readable by anything but that unit. A store that ignored it would oblige an
819/// operator who had set one up to keep a second plaintext copy on disk.
820pub const SYSTEMD_CREDENTIAL: &str = "runner-manager.user-access-token";
821
822/// The environment variable `systemd` sets for a unit that was given
823/// credentials. Read once, at [`PlatformSecretStore::standard`] time.
824pub const CREDENTIALS_DIRECTORY: &str = "CREDENTIALS_DIRECTORY";
825
826// ---------------------------------------------------------------------------
827// Platform implementations
828// ---------------------------------------------------------------------------
829//
830// Each `sys` module offers the same nine items, and the shared code above is
831// the only caller:
832//
833//   Site                        -- where one store keeps its value
834//   standard_site(scope)        -> the production location for that scope
835//   rooted_site(scope, root)    -> the same backend under a caller-named root
836//   describe(&Site)             -> what `host show` prints
837//   guard(&Site)                -> the object whose access control decides
838//                                  who can read the value
839//   store(&Site, scope, bytes)  -> write, replacing whatever was there
840//   ensure_writable(&Site)      -> refuse when `store` would be refused by
841//                                  permissions, without writing anything
842//   load(&Site, scope)          -> Ok(None) when nothing is stored;
843//                                  ErrorKind::InvalidData when something is
844//                                  stored and it is not ours
845//   delete(&Site)               -> Ok(false) when there was nothing to remove
846//
847// `standard_site` and `rooted_site` fail with a `String` rather than an
848// `io::Error`, because a resolution failure is never an `errno`: it is "this
849// account has no home directory" or "this Windows reports no %ProgramData%",
850// and both want a sentence an operator can act on.
851
852/// The stem every temporary file this module writes shares, so that a crash
853/// leaves something [`sweep_temporaries`] can recognise rather than an
854/// anonymous file beside the store.
855#[cfg(not(target_os = "macos"))]
856const TEMP_PREFIX: &str = "user-access-token.";
857
858/// Removes temporary files left in `directory` by an interrupted write.
859///
860/// Called from `delete` and not from `store`, and that order is deliberate:
861/// two `store` calls can be in flight at once and a sweep there could remove a
862/// live temporary out from under the other one. `delete` is `auth logout`,
863/// which is where "leave no remnant" is the actual requirement.
864#[cfg(not(target_os = "macos"))]
865fn sweep_temporaries(directory: &Path) {
866    let Ok(entries) = std::fs::read_dir(directory) else {
867        return;
868    };
869    for entry in entries.flatten() {
870        let name = entry.file_name();
871        let Some(name) = name.to_str() else { continue };
872        if name.starts_with(TEMP_PREFIX) && name.ends_with(".tmp") {
873            let _ = std::fs::remove_file(entry.path());
874        }
875    }
876}
877
878/// Overwrites every byte of a file with zeroes, in place.
879///
880/// Returns `Ok(false)` when there is nothing there, which is the ordinary state
881/// of a store that was never written.
882///
883/// # What this is and is not
884///
885/// It is **not** secure erasure and nothing here claims it is. The reasoning is
886/// [`crate::process::RestrictiveHandoff`]'s, unchanged: a journal, a
887/// copy-on-write snapshot, or an SSD's wear levelling can each keep a copy that
888/// an overwrite never reaches. It is here because it costs one `write` and
889/// because on Linux the stored value is at rest in **plaintext** — the mode is
890/// the whole access control there — so leaving the bytes in the block after the
891/// inode is unlinked is worse than not.
892///
893/// # Why it is a separate function, and not `cfg`-gated to Linux
894///
895/// Both are the same reason, and it is a testing reason rather than a
896/// portability one. Folded into the delete path, the zero fill was asserted by
897/// nothing: a test could only observe the file's *absence* afterwards, which is
898/// what removing it proves too, so the fill could have been deleted outright
899/// and the suite would have stayed green. Split out, the fill is observable —
900/// [`tests::overwrite_zeroes_every_byte_and_leaves_the_file_there`] reads the
901/// bytes back before anything unlinks them. Compiled on Windows as well as
902/// Linux, that test runs on every leg of the matrix rather than on one, and the
903/// Windows delete path calls it too: the value there is ciphertext, so the fill
904/// buys less, but it costs the same and it keeps the mechanism on a path a
905/// developer can execute.
906#[cfg(not(target_os = "macos"))]
907fn overwrite(path: &Path) -> std::io::Result<bool> {
908    use std::io::Write as _;
909
910    let mut file = match std::fs::OpenOptions::new().write(true).open(path) {
911        Ok(file) => file,
912        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
913        Err(error) => return Err(error),
914    };
915
916    let length = usize::try_from(file.metadata()?.len()).unwrap_or(0);
917    file.write_all(&vec![0u8; length])?;
918    file.flush()?;
919    file.sync_all()?;
920    Ok(true)
921}
922
923/// Drops trailing ASCII whitespace from a value that came from outside this
924/// module.
925///
926/// There is exactly one such value: the systemd credential. `store` writes no
927/// newline and its own file is read back verbatim, deliberately — trimming
928/// there would quietly repair a corruption this store would rather report. A
929/// credential, though, is produced by the operator through `systemd-creds
930/// encrypt`, and `echo`, `printf '%s\n'`, and every text editor put a newline
931/// on the end. Used byte for byte, that newline becomes part of the token and
932/// surfaces much later inside an `Authorization` header, where it looks like a
933/// bad credential rather than a bad read.
934///
935/// Trailing only. A token has no interior whitespace, but if one ever did,
936/// silently rewriting its middle would be a worse bug than the one this fixes.
937#[cfg(not(target_os = "macos"))]
938// `allow` rather than `expect`: on Windows this is dead in a normal build and
939// live in a test build, because the test below is the whole reason it is
940// compiled there, and an `expect` that is fulfilled in only one of the two
941// configurations warns in the other.
942#[cfg_attr(
943    windows,
944    allow(
945        dead_code,
946        reason = "the systemd credential path is Linux-only; this is compiled on Windows so \
947                  that its unit test runs on every leg of the matrix rather than on the one \
948                  platform a developer usually cannot execute"
949    )
950)]
951fn trim_trailing_ascii_whitespace(mut bytes: Vec<u8>) -> Vec<u8> {
952    while bytes.last().is_some_and(u8::is_ascii_whitespace) {
953        bytes.pop();
954    }
955    bytes
956}
957
958/// Refuses when this process may not create entries in the directory a write
959/// to `target` replaces it from.
960///
961/// Both Unix backends write by creating a new file beside the old one and
962/// renaming it over. The Linux store does that itself; a keychain does it
963/// inside Security.framework, in this process -- `create
964/// /Library/Keychains/System.keychain.sb-...: Permission denied` is what an
965/// unprivileged TUI logged there. So the right to create entries in that
966/// directory is exactly the permission a write turns on.
967///
968/// The nearest directory that exists is the one asked, because `store` creates
969/// any that are missing. `AT_EACCESS` asks with the effective IDs, which are
970/// the ones the write itself would use, so a command run under `sudo` passes.
971#[cfg(unix)]
972fn ensure_directory_writable(target: &Path) -> std::io::Result<()> {
973    use std::os::unix::ffi::OsStrExt as _;
974
975    let Some(directory) = target.ancestors().skip(1).find(|path| path.is_dir()) else {
976        return Ok(());
977    };
978    let c_path =
979        std::ffi::CString::new(directory.as_os_str().as_bytes()).map_err(std::io::Error::other)?;
980    // SAFETY: `c_path` is a NUL-terminated string that outlives the call, and
981    // `faccessat` only reads it.
982    let status = unsafe {
983        libc::faccessat(
984            libc::AT_FDCWD,
985            c_path.as_ptr(),
986            libc::W_OK | libc::X_OK,
987            libc::AT_EACCESS,
988        )
989    };
990    if status == 0 {
991        return Ok(());
992    }
993    let error = std::io::Error::last_os_error();
994    Err(std::io::Error::new(
995        error.kind(),
996        format!(
997            "this account may not create files in {} ({error}), so the store would refuse the \
998             write",
999            directory.display()
1000        ),
1001    ))
1002}
1003
1004// ---------------------------------------------------------------------------
1005
1006#[cfg(windows)]
1007mod sys {
1008    //! DPAPI, in a file whose DACL is this store's real access control.
1009    //!
1010    //! # Two mechanisms, and which one is doing the work
1011    //!
1012    //! `CryptProtectData` with `CRYPTPROTECT_LOCAL_MACHINE` produces a blob
1013    //! that **any process on this machine** can unprotect. That is not a
1014    //! weakness in the flag, it is the definition of machine scope, and it is
1015    //! what D13 needs: a service starting at boot, under an account that has
1016    //! never logged in, has no user master key to decrypt with. The
1017    //! consequence is that the encryption is not the access control — the
1018    //! file's DACL is. `07-security.md` says the same thing from the other
1019    //! end: *"Stored only in the machine-scoped secret store, ACL'd to the
1020    //! service account."*
1021    //!
1022    //! The user-scoped store passes no scope flag, so the blob is bound to the
1023    //! calling account's master key. There the encryption *is* an access
1024    //! control, and the DACL is a second one.
1025    //!
1026    //! # The DACL, and why it is not `process.rs`'s
1027    //!
1028    //! [`crate::process::RestrictiveHandoff`] writes
1029    //! `D:P(A;;FA;;;BA)(A;;FA;;;<this account>)` and documents at length why it
1030    //! carries no `SY` ACE: that file's writer and reader are the same
1031    //! process, so if the process is `LocalSystem` its own SID *is* S-1-5-18
1032    //! and a separate ACE adds nothing.
1033    //!
1034    //! This store's writer and reader are **not** the same process. `auth
1035    //! login` runs as the interactive operator; the daemon runs as whatever
1036    //! `service install` registered, which defaults to `LocalSystem`. So `SY`
1037    //! is load-bearing here in exactly the case where it was redundant there,
1038    //! and the machine-scoped DACL carries it. The user-scoped DACL does not,
1039    //! because a user-scoped store is deliberately not for a service.
1040    //!
1041    //! The third trustee is `OW` — OWNER RIGHTS, S-1-3-4 — rather than the
1042    //! creating account's SID read back out of the process token. When a DACL
1043    //! contains an ACE for OWNER RIGHTS, that ACE is what the object's owner
1044    //! gets, and the owner of a file is the account that created it. So `OW`
1045    //! says "the account that ran `auth login` keeps access to what it stored"
1046    //! without opening a process token, without a `TOKEN_USER` buffer, and
1047    //! without a second copy of the SID lookup `process.rs` already carries.
1048    //!
1049    //! **That sentence held only while `auth login` was the sole writer.** Token
1050    //! renewal made the daemon a second writer under a different account, and
1051    //! because every write creates a new file, the owner moved with it and `OW`
1052    //! stopped meaning the operator. [`replacement_sddl`] is what keeps the
1053    //! grant `OW` describes from evaporating; it carries the previous owner's
1054    //! SID onto the replacement explicitly.
1055    //!
1056    //! # One store per host, and what a second operator actually gets
1057    //!
1058    //! An earlier version of this paragraph said that ownership moves with the
1059    //! write, so a second non-administrative operator's `auth login` "takes"
1060    //! the store. **That was wrong, and it was wrong in the optimistic
1061    //! direction.** Ownership does not move, because the write never lands:
1062    //! `store` finishes with a replacing rename, a replacing rename deletes the
1063    //! target, and delete is granted by `DELETE` on the file or
1064    //! `FILE_DELETE_CHILD` on its parent. `sddl(Machine)` names `SY`, `BA` and
1065    //! `OW`, none of which is operator B; and a stock `%ProgramData%` grants
1066    //! `BUILTIN\Users` only `(OI)(CI)(RX)` plus `(CI)(WD,AD,WEA,WA)` — `WD`
1067    //! lets B create the temporary, and the absence of `DC` and `DE` is what
1068    //! denies the rename. So B's `auth login` **fails**.
1069    //!
1070    //! That is the correct behaviour, and it is the behaviour this store now
1071    //! states rather than stumbles into. **The machine-scoped value is the
1072    //! host's one credential, not an operator's.** `07-security.md` counts the
1073    //! persisted credential surface and gets to one; `service install`
1074    //! registers one service reading one store; the domain has one `Host` and
1075    //! `d1`'s lock permits one agent. A second operator does not get a second
1076    //! store, and whether B may overwrite A's token is a policy question whose
1077    //! answer is "only if B is trusted with this host" — which on Windows is
1078    //! spelled *administrator*, and `BA` already grants it.
1079    //!
1080    //! What changed is the *failure*, not the policy. [`cannot_replace`] asks
1081    //! before anything is encrypted or written, so B gets a message naming the
1082    //! three ways forward instead of a bare "Access is denied" raised at the
1083    //! last step of the write, after a valid machine-decryptable blob of a live
1084    //! token has already been placed on disk.
1085    //!
1086    //! Widening the DACL was considered and rejected. The DACL is the **entire**
1087    //! access control here — a machine-scope DPAPI blob is unprotectable by any
1088    //! process on the host, by definition — so an ACE that let B replace the
1089    //! file would also let every interactive account on the machine read the
1090    //! one credential the product holds. The reader that actually matters is
1091    //! the service account, which `SY` covers by default and which `d3` grants
1092    //! explicitly when `service install` registers a least-privilege account
1093    //! instead (`05-infrastructure.md`, service behaviour, item 2).
1094    //!
1095    //! [`crate::process::permissions_summary`] reads the result back, and it
1096    //! treats an *unprotected* DACL as broadly readable because an inherited
1097    //! one is not this program's to vouch for. That is why every DACL below
1098    //! begins `D:P`, and why the file carries its own rather than inheriting
1099    //! `%ProgramData%`'s, which grants `BU` read.
1100    //!
1101    //! # Write, then replace
1102    //!
1103    //! The blob goes to a temporary file *carrying its final DACL from the
1104    //! moment it exists*, is `sync_all`ed, and is then renamed over the target.
1105    //! `process.rs` argues the first half — a file created and then tightened
1106    //! is readable for however long the gap lasts. The second half is this
1107    //! store's own: the value is long-lived, so a write interrupted half way
1108    //! must not leave a truncated blob where a whole one was.
1109
1110    use std::fs::File;
1111    use std::io::{self, Write as _};
1112    use std::os::windows::ffi::OsStrExt;
1113    use std::os::windows::io::FromRawHandle;
1114    use std::path::{Path, PathBuf};
1115
1116    use windows::Win32::Foundation::{ERROR_SUCCESS, HLOCAL, LocalFree};
1117    use windows::Win32::Security::Authorization::{
1118        ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW,
1119        GetNamedSecurityInfoW, SDDL_REVISION_1, SE_FILE_OBJECT,
1120    };
1121    use windows::Win32::Security::Cryptography::{
1122        CRYPT_INTEGER_BLOB, CRYPTPROTECT_LOCAL_MACHINE, CRYPTPROTECT_UI_FORBIDDEN,
1123        CryptProtectData, CryptUnprotectData,
1124    };
1125    use windows::Win32::Security::{
1126        OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, SECURITY_ATTRIBUTES,
1127    };
1128    use windows::Win32::Storage::FileSystem::{
1129        CREATE_NEW, CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_GENERIC_READ, FILE_GENERIC_WRITE,
1130        FILE_SHARE_NONE,
1131    };
1132    use windows::core::{PCWSTR, PWSTR};
1133
1134    use super::{
1135        APPLICATION, DIRECTORY, ITEM, ORGANIZATION, QUALIFIER, SecretScope, TEMP_PREFIX, overwrite,
1136        sweep_temporaries,
1137    };
1138
1139    /// Optional entropy mixed into every blob.
1140    ///
1141    /// **Not a secret**, and nothing here pretends otherwise: it is a constant
1142    /// in a published binary. It binds a blob to this product, so that a
1143    /// machine-scoped blob found on disk is not readable by a passing program
1144    /// that merely calls `CryptUnprotectData` with no arguments, and so that a
1145    /// value written by some future second store cannot be silently read back
1146    /// by this one. Against an attacker holding the binary it buys nothing.
1147    const ENTROPY: &[u8] = b"io.github.IvanMurzak.runner-manager/user-access-token/v1";
1148
1149    /// The file the blob lives in.
1150    #[derive(Debug, Clone, PartialEq, Eq)]
1151    pub(super) struct Site {
1152        file: PathBuf,
1153    }
1154
1155    pub(super) fn standard_site(scope: SecretScope) -> Result<Site, String> {
1156        let root = match scope {
1157            // `%ProgramData%` rather than `%LOCALAPPDATA%`, because the whole
1158            // requirement is that an account which has never logged in can
1159            // read it — and `%LOCALAPPDATA%` does not exist for such an
1160            // account until its profile is loaded.
1161            SecretScope::Machine => std::env::var_os("ProgramData")
1162                .map(PathBuf::from)
1163                .ok_or_else(|| {
1164                    "this Windows reports no %ProgramData%, so the machine-wide \
1165                     application-data directory cannot be resolved. Set ProgramData, or \
1166                     install the service with --start-at login to use the user-scoped store."
1167                        .to_string()
1168                })?
1169                .join(ORGANIZATION)
1170                .join(APPLICATION),
1171            SecretScope::User => {
1172                directories::ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION)
1173                    .ok_or_else(|| {
1174                        "the operating system reports no home directory for this account, so \
1175                         the user-scoped store cannot be resolved. A service account \
1176                         configured with no profile normally hits this; give the account a \
1177                         home directory, or use the machine-scoped store."
1178                            .to_string()
1179                    })?
1180                    .data_local_dir()
1181                    .to_path_buf()
1182            }
1183        };
1184        Ok(Site {
1185            file: root.join(DIRECTORY).join(format!("{ITEM}.dpapi")),
1186        })
1187    }
1188
1189    pub(super) fn rooted_site(scope: SecretScope, root: &Path) -> Result<Site, String> {
1190        // The scope is a path segment here and is not one under a standard
1191        // site, because a standard site gets its separation from the root:
1192        // `%ProgramData%` against `%LOCALAPPDATA%`. Under one caller-named root
1193        // there is no such separation, and two stores sharing a file is the
1194        // failure `the_two_variants_do_not_share_a_value` exists to catch --
1195        // `auth logout` under one scope would silently purge the other.
1196        Ok(Site {
1197            file: root
1198                .join(DIRECTORY)
1199                .join(scope.as_str())
1200                .join(format!("{ITEM}.dpapi")),
1201        })
1202    }
1203
1204    pub(super) fn describe(site: &Site) -> String {
1205        format!("DPAPI blob at {}", site.file.display())
1206    }
1207
1208    pub(super) fn guard(site: &Site) -> PathBuf {
1209        site.file.clone()
1210    }
1211
1212    /// The protected DACL a store of this scope gets, in SDDL.
1213    ///
1214    /// Split out and `pub(super)` so a test can assert the exact string rather
1215    /// than infer it from a file, and so a reviewer can read the two DACLs
1216    /// side by side.
1217    pub(super) const fn sddl(scope: SecretScope) -> &'static str {
1218        match scope {
1219            SecretScope::Machine => "D:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;OW)",
1220            SecretScope::User => "D:P(A;;FA;;;BA)(A;;FA;;;OW)",
1221        }
1222    }
1223
1224    /// The DACL a *replacement* gets: [`sddl`], plus the account that owned
1225    /// what is being replaced.
1226    ///
1227    /// # Why `OW` alone stopped being enough
1228    ///
1229    /// `OW` is OWNER RIGHTS, and the owner of a file is whoever created it.
1230    /// That made `OW` an exact statement of *"the account that ran `auth login`
1231    /// keeps access to what it stored"* for as long as `auth login` was the
1232    /// only writer.
1233    ///
1234    /// Token renewal made the daemon a second writer with a different
1235    /// identity. [`store`] finishes by renaming a temporary over the target, so
1236    /// every write creates a new file, and the new file's owner is whoever
1237    /// created it — `LocalSystem` for a daemon installed at boot. `OW` then
1238    /// resolves to `LocalSystem`, the operator matches none of the three ACEs,
1239    /// and an unelevated `auth status` cannot read the store or even its ACL.
1240    ///
1241    /// Observed on 2026-08-29: a store readable at `07:50Z` and unreadable at
1242    /// `07:58Z`, with nothing between the two but the daemon renewing its
1243    /// eight-hour token. See `docs/spikes/token-expiry-and-renewal.md`.
1244    ///
1245    /// # Why the previous owner, rather than preserving ownership
1246    ///
1247    /// Setting a new file's owner to an account that is not the caller's needs
1248    /// `SE_RESTORE_NAME`, which `LocalSystem` holds but a least-privilege
1249    /// service account — which `service install` can register — does not. An
1250    /// ACE needs nothing: the writer created the file and so may say who else
1251    /// reaches it. This works the same for both, which is why it is the
1252    /// mechanism rather than the fallback.
1253    ///
1254    /// This widens nothing. The previous owner already had full control
1255    /// through `OW`; carrying the SID forward is what keeps a grant that
1256    /// already existed from evaporating when somebody else writes.
1257    ///
1258    /// # Why the previous *grants* and not just the previous owner
1259    ///
1260    /// Carrying the owner alone survives exactly one write and then undoes
1261    /// itself. After the first renewal the owner is the daemon and the operator
1262    /// is named by an ACE; at the second renewal the owner read back is the
1263    /// *daemon's*, so a DACL rebuilt from the owner drops the operator and
1264    /// locks them out again — the same failure, deferred by eight hours. What
1265    /// is carried is therefore every SID the previous DACL granted, which
1266    /// includes the ACE the previous write added.
1267    ///
1268    /// [`carried_grants`] reads that set and drops the SIDs the constant
1269    /// already covers, so the DACL cannot grow by one ACE per write.
1270    pub(super) fn replacement_sddl(scope: SecretScope, carried: &[String]) -> String {
1271        let mut text = sddl(scope).to_owned();
1272        for sid in carried {
1273            // The SID goes in where a two-letter alias usually does; SDDL takes
1274            // either.
1275            text.push_str("(A;;FA;;;");
1276            text.push_str(sid);
1277            text.push(')');
1278        }
1279        text
1280    }
1281
1282    /// Trustees the constant DACL already grants, in both spellings.
1283    ///
1284    /// The aliases are what [`sddl`] itself writes, so re-emitting them would
1285    /// double every ACE in the base. The two SIDs are the same accounts as
1286    /// `SY` and `BA`, which is the spelling [`previous_owner`] hands back:
1287    /// `ConvertSidToStringSidW` always answers `S-1-…`, never an alias.
1288    const ALREADY_GRANTED: [&str; 5] = ["SY", "BA", "OW", "S-1-5-18", "S-1-5-32-544"];
1289
1290    /// Every account the value being replaced was readable by, so that
1291    /// replacing it does not quietly take the store away from one of them.
1292    ///
1293    /// The owner, because `OW` granted it; plus the SIDs already named
1294    /// explicitly, because those are the owners of earlier writes that this
1295    /// mechanism already rescued. Bounded by [`ALREADY_GRANTED`] and by
1296    /// deduplication: in practice the set is the one operator who signed in.
1297    ///
1298    /// Deliberately infallible, for the reason [`previous_owner`] gives.
1299    fn carried_grants(path: &Path) -> Vec<String> {
1300        // The reader `protection` already goes through, rather than a second
1301        // descriptor round trip of this module's own.
1302        let dacl = crate::process::permissions_summary(path)
1303            .map(|summary| summary.description)
1304            .unwrap_or_default();
1305        let mut grants = merge_grants(previous_owner(path).as_deref(), &dacl);
1306
1307        // ------------------------------------------------------------------
1308        // AND THIS WRITER, BY NAME, BECAUSE `OW` DOES NOT SURVIVE.
1309        // ------------------------------------------------------------------
1310        // `OW` was chosen to mean "the account that wrote this keeps access",
1311        // and it does -- until anybody changes the owner, at which point
1312        // Windows **deletes the OWNER RIGHTS ACE**. Taking ownership of the
1313        // file therefore removes the only thing granting the new owner access:
1314        // they end up owning a file they cannot read, holding `READ_CONTROL`
1315        // and `WRITE_DAC` and nothing else. Read back from a real host on
1316        // 2026-08-30, after `takeown` had been offered as the repair:
1317        //
1318        //     O:S-1-5-21-...-1001 G:SY D:P(A;;FA;;;SY)(A;;FA;;;BA)
1319        //
1320        // -- the owner is the operator, and `OW` is simply gone.
1321        //
1322        // So the writer is named by SID as well. `OW` stays because it costs
1323        // nothing and still covers the ordinary case, but nothing depends on
1324        // it any more. `ALREADY_GRANTED` drops the service accounts, so a
1325        // daemon writing under `LocalSystem` adds no ACE at all and the set
1326        // stays at the one operator who signed in.
1327        if let Ok(writer) = crate::process::current_user_sid()
1328            && !ALREADY_GRANTED.contains(&&*writer)
1329            && !grants.contains(&writer)
1330        {
1331            grants.push(writer);
1332        }
1333        grants
1334    }
1335
1336    /// [`carried_grants`] without the two reads, which is where its one rule
1337    /// lives and the only part a test can drive.
1338    ///
1339    /// A test process is one account: it cannot become `LocalSystem`, so it
1340    /// cannot make the owner move between writes, so driving [`store`] can
1341    /// never reach the case this rule exists for. Passing the owner and the
1342    /// DACL in is what makes "the writer is not the previous owner" reachable
1343    /// at all.
1344    pub(super) fn merge_grants(previous_owner: Option<&str>, previous_dacl: &str) -> Vec<String> {
1345        let mut carried: Vec<String> = Vec::new();
1346        let mut add = |sid: &str| {
1347            if !ALREADY_GRANTED.contains(&sid) && !carried.iter().any(|seen| seen == sid) {
1348                carried.push(sid.to_owned());
1349            }
1350        };
1351        if let Some(owner) = previous_owner {
1352            add(owner);
1353        }
1354        for trustee in trustees(previous_dacl) {
1355            add(&trustee);
1356        }
1357        carried
1358    }
1359
1360    /// Every trustee an SDDL DACL names, in whatever spelling it names them.
1361    ///
1362    /// An ACE is `(type;flags;rights;object;inherit;trustee)`, so the trustee is
1363    /// what follows the last `;` before the closing parenthesis.
1364    ///
1365    /// # Why not "the ones written as `S-1-…`"
1366    ///
1367    /// Because Windows does not read back what was written. A DACL built with
1368    /// `S-1-5-21-…-500` comes out of
1369    /// `ConvertSecurityDescriptorToStringSecurityDescriptorW` as `(A;;FA;;;LA)`
1370    /// — the alias for the built-in Administrator — and any other well-known
1371    /// account behaves the same way. Filtering on the `S-1-` prefix therefore
1372    /// dropped exactly the accounts whose grant had already been rescued once,
1373    /// so the carry survived one write and undid itself on the next, which is
1374    /// the bug it exists to prevent.
1375    ///
1376    /// Found by CI, whose Windows runner signs in as that account. It passes on
1377    /// an ordinary developer machine, where the operator has a plain
1378    /// `S-1-5-21-…-1001` that survives the round trip unchanged.
1379    ///
1380    /// [`ALREADY_GRANTED`] is what keeps the base DACL's own aliases out.
1381    pub(super) fn trustees(sddl: &str) -> Vec<String> {
1382        let mut found = Vec::new();
1383        for ace in sddl.split('(').skip(1) {
1384            let Some(body) = ace.split(')').next() else {
1385                continue;
1386            };
1387            let Some(trustee) = body.rsplit(';').next() else {
1388                continue;
1389            };
1390            if !trustee.is_empty() && !found.iter().any(|seen| seen == trustee) {
1391                found.push(trustee.to_owned());
1392            }
1393        }
1394        found
1395    }
1396
1397    /// The SID of the account that owns the file already there, as a string.
1398    ///
1399    /// `None` when there is no file, when its owner cannot be read, or when the
1400    /// SID will not convert — all of which mean the same thing to the caller:
1401    /// there is no previous grant to carry forward, so write [`sddl`] as it
1402    /// stands. A first write reaches this with nothing on disk and takes that
1403    /// path.
1404    ///
1405    /// Deliberately infallible. A store that refused to write because it could
1406    /// not read a security descriptor would trade a working credential for a
1407    /// tidier ACL.
1408    fn previous_owner(path: &Path) -> Option<String> {
1409        let wide = to_wide(path);
1410        let mut owner = PSID::default();
1411        let mut descriptor = PSECURITY_DESCRIPTOR(std::ptr::null_mut());
1412        // SAFETY: `wide` is NUL-terminated and outlives the call. `descriptor`
1413        // receives a LocalAlloc'd descriptor freed below on every path, and
1414        // `owner` points into it rather than owning anything itself.
1415        let status = unsafe {
1416            GetNamedSecurityInfoW(
1417                PCWSTR(wide.as_ptr()),
1418                SE_FILE_OBJECT,
1419                OWNER_SECURITY_INFORMATION,
1420                Some(&mut owner),
1421                None,
1422                None,
1423                None,
1424                &mut descriptor,
1425            )
1426        };
1427        if status != ERROR_SUCCESS {
1428            return None;
1429        }
1430
1431        let mut sid_string = PWSTR::null();
1432        // SAFETY: `owner` points into the descriptor, which is still live.
1433        let converted = unsafe { ConvertSidToStringSidW(owner, &mut sid_string) };
1434        let text = match converted {
1435            // SAFETY: the conversion succeeded, so `sid_string` is a
1436            // LocalAlloc'd NUL-terminated string, freed immediately after.
1437            Ok(()) => {
1438                let text = unsafe { sid_string.to_string() }.ok();
1439                unsafe {
1440                    let _ = LocalFree(Some(HLOCAL(sid_string.0.cast())));
1441                }
1442                text
1443            }
1444            Err(_) => None,
1445        };
1446        // SAFETY: LocalAlloc'd by `GetNamedSecurityInfoW`, freed exactly once.
1447        unsafe {
1448            let _ = LocalFree(Some(HLOCAL(descriptor.0)));
1449        }
1450        text
1451    }
1452
1453    /// The access right a replacing rename needs on the file it replaces.
1454    ///
1455    /// `MOVEFILE_REPLACE_EXISTING` deletes the target, and delete is granted by
1456    /// `DELETE` on the object or by `FILE_DELETE_CHILD` on its parent. Probing
1457    /// for it is `CreateFileW` with `dwDesiredAccess = DELETE`, which
1458    /// [`std::os::windows::fs::OpenOptionsExt::access_mode`] reaches without
1459    /// another FFI declaration.
1460    const DELETE: u32 = 0x0001_0000;
1461
1462    /// Whether this account may replace the value already in the store, and if
1463    /// not, why an operator is seeing it.
1464    ///
1465    /// Returns `None` when there is nothing there, or when the existing value
1466    /// can be replaced — the ordinary cases.
1467    ///
1468    /// # What this catches
1469    ///
1470    /// A second non-administrative operator on a shared host. Their `auth
1471    /// login` inherits `WD` (create file) from `%ProgramData%` so the temporary
1472    /// file is written happily, and then the rename over the first operator's
1473    /// file is denied, because `sddl(Machine)` names `SY`, `BA` and `OW` and
1474    /// this account is none of the three. Left to the rename, that surfaces as
1475    /// a bare "Access is denied" at the last step of the write, after a valid
1476    /// machine-decryptable blob of a real token has already been put on disk.
1477    ///
1478    /// # Why it refuses rather than widening the DACL
1479    ///
1480    /// **This product keeps one machine-scoped credential per host, and that is
1481    /// the intended model rather than an accident of the ACL.**
1482    /// `07-security.md` counts the persisted credential surface and gets to
1483    /// one; `05-infrastructure.md` has `service install` register one service
1484    /// reading one store; the domain has one `Host`, and `d1`'s single-instance
1485    /// lock allows one agent. The value is *the host's* credential, not an
1486    /// operator's — so a second operator does not get a second store, and the
1487    /// question "may B overwrite A's token" is a policy question with a policy
1488    /// answer: only if B is trusted with the host, which on Windows means
1489    /// administrator.
1490    ///
1491    /// Widening the DACL to make the rename succeed would give every
1492    /// interactive account on the machine write access to the one credential
1493    /// the product holds, and read access with it, since the DACL is the
1494    /// **entire** access control here — a machine-scope DPAPI blob is
1495    /// unprotectable by any process on the host by definition. That trade is
1496    /// not worth a smoother second login.
1497    fn cannot_replace(site: &Site) -> Option<io::Error> {
1498        use std::os::windows::fs::OpenOptionsExt as _;
1499
1500        match std::fs::OpenOptions::new()
1501            .access_mode(DELETE)
1502            .open(&site.file)
1503        {
1504            // Replaceable, or nothing there to replace.
1505            Ok(_) => None,
1506            Err(error) if error.kind() == io::ErrorKind::NotFound => None,
1507            Err(error) if error.kind() == io::ErrorKind::PermissionDenied => Some(io::Error::new(
1508                io::ErrorKind::PermissionDenied,
1509                format!(
1510                    "the machine-scoped store at {} already holds a token that belongs to \
1511                     another account on this host, and this account may not replace it. This \
1512                     product keeps one machine-scoped credential per host -- it is the host's \
1513                     credential, not an operator's -- so a second operator does not get a \
1514                     second store. Either run `auth logout` as the account that stored it, or \
1515                     run `auth login` from an elevated prompt, since the local Administrators \
1516                     group is granted access, or install the service with `--start-at login`, \
1517                     which uses the per-user store instead. Nothing was written.",
1518                    site.file.display()
1519                ),
1520            )),
1521            // Anything else is not this condition. Say nothing and let the
1522            // write report whatever it actually runs into, rather than
1523            // inventing a diagnosis from an unrelated errno.
1524            Err(_) => None,
1525        }
1526    }
1527
1528    pub(super) fn ensure_writable(site: &Site) -> io::Result<()> {
1529        if let Some(refusal) = cannot_replace(site) {
1530            return Err(refusal);
1531        }
1532        Ok(())
1533    }
1534
1535    pub(super) fn store(site: &Site, scope: SecretScope, plaintext: &[u8]) -> io::Result<()> {
1536        let directory = site
1537            .file
1538            .parent()
1539            .ok_or_else(|| io::Error::other("the store path has no parent directory"))?;
1540        std::fs::create_dir_all(directory)?;
1541
1542        // Before anything is encrypted or written. The point of asking here is
1543        // that the alternative -- finding out at the rename -- means a valid,
1544        // machine-decryptable blob of a live token has already been placed on
1545        // disk before the refusal.
1546        if let Some(refusal) = cannot_replace(site) {
1547            return Err(refusal);
1548        }
1549
1550        let blob = protect(plaintext, scope)?;
1551
1552        // Read before the temporary exists, from the file about to be replaced.
1553        // A renewal writes as the daemon's account and would otherwise take
1554        // `OW` away from the operator who signed in; see `replacement_sddl`.
1555        let descriptor = replacement_sddl(scope, &carried_grants(&site.file));
1556
1557        let temporary = directory.join(format!("{TEMP_PREFIX}{}.tmp", uuid::Uuid::new_v4()));
1558        let written = (|| -> io::Result<()> {
1559            let mut file = create_protected_file(&temporary, &descriptor)?;
1560            file.write_all(&blob)?;
1561            file.flush()?;
1562            file.sync_all()
1563        })();
1564        if let Err(error) = written {
1565            return Err(discard(&temporary, error));
1566        }
1567
1568        // `std::fs::rename` is `MoveFileExW(.., MOVEFILE_REPLACE_EXISTING)` on
1569        // Windows, so this replaces a previous token rather than failing. The
1570        // file keeps its own security descriptor across the move, so the DACL
1571        // applied at creation is the one the store ends up with.
1572        if let Err(error) = std::fs::rename(&temporary, &site.file) {
1573            // The probe above should have caught the second-operator case, but
1574            // it is a probe and this is a race: another account can take the
1575            // file between the two calls. Report the same diagnosis rather than
1576            // the bare denial, then discard the blob either way.
1577            let error = if error.kind() == io::ErrorKind::PermissionDenied {
1578                cannot_replace(site).unwrap_or(error)
1579            } else {
1580                error
1581            };
1582            return Err(discard(&temporary, error));
1583        }
1584        Ok(())
1585    }
1586
1587    /// Scrubs and removes a temporary that will not become the store, and folds
1588    /// a failure to do so into the error the caller is already returning.
1589    ///
1590    /// A temporary that survives a failed `store` is a valid machine-scope
1591    /// DPAPI blob of a real token, sitting under a name nobody looks at.
1592    /// Removing it was already the behaviour; what was missing is that the
1593    /// removal was `let _ =`, so a temporary that could *not* be removed left
1594    /// the token on disk with nothing said about it.
1595    ///
1596    /// A blanket [`sweep_temporaries`] is deliberately not used here. Its own
1597    /// documentation says why: two `store` calls can be in flight at once, and
1598    /// sweeping the directory would remove the other one's live temporary. This
1599    /// removes exactly the file this call created.
1600    fn discard(temporary: &Path, error: io::Error) -> io::Error {
1601        let _ = overwrite(temporary);
1602        match std::fs::remove_file(temporary) {
1603            Ok(()) => error,
1604            Err(removal) if removal.kind() == io::ErrorKind::NotFound => error,
1605            Err(removal) => io::Error::new(
1606                error.kind(),
1607                format!(
1608                    "{error}. A temporary file holding the encrypted token was also left at \
1609                     {} and could not be removed ({removal}); delete it by hand.",
1610                    temporary.display()
1611                ),
1612            ),
1613        }
1614    }
1615
1616    /// Why an operator cannot read a store that is theirs, and the one command
1617    /// that gives it back.
1618    ///
1619    /// # The state this diagnoses
1620    ///
1621    /// A file that exists and that this account may not read. On this store
1622    /// that has one cause: the DACL grants `SY`, `BA` and `OW`, and the owner
1623    /// is whoever wrote last. A daemon under `LocalSystem` renewing the token
1624    /// became that owner, so `OW` stopped meaning the operator.
1625    ///
1626    /// # Why it names `icacls` and not `takeown`
1627    ///
1628    /// **Because `takeown` makes it permanent.** Changing an object's owner
1629    /// makes Windows delete its OWNER RIGHTS ACE, so taking ownership removes
1630    /// the one thing that would have granted the new owner access: they end up
1631    /// owning a file they cannot read, holding `READ_CONTROL` and `WRITE_DAC`
1632    /// and nothing else.
1633    ///
1634    /// This was offered as the repair on 2026-08-30 and appeared to work,
1635    /// because the `auth status` that followed ran in the same elevated prompt
1636    /// and succeeded through `BA`. The store read back:
1637    ///
1638    /// ```text
1639    /// O:S-1-5-21-...-1001 G:SY D:P(A;;FA;;;SY)(A;;FA;;;BA)
1640    /// ```
1641    ///
1642    /// The operator owns it. `OW` is gone. An unelevated read is still denied,
1643    /// and no renewal will ever bring it back.
1644    ///
1645    /// An explicit grant has none of that: it adds an ACE, changes no owner,
1646    /// deletes nothing, and is exactly the shape [`carried_grants`] then
1647    /// preserves on every later write.
1648    ///
1649    /// # Why a message and not a repair
1650    ///
1651    /// Naming an account in the DACL of the file holding this host's
1652    /// credential is not something a status command should do under an
1653    /// operator, and the account that can is an administrator, which this
1654    /// process may not be. So it says exactly what to run.
1655    fn locked_out(site: &Site, source: &io::Error) -> io::Error {
1656        io::Error::new(
1657            io::ErrorKind::PermissionDenied,
1658            format!(
1659                "{source}. The file exists but this account may not read it, which on this \
1660                 store means its owner changed: the service renews the token under its own \
1661                 account. Grant this account access explicitly from an elevated prompt -- it \
1662                 returns immediately, and stays, because every later renewal carries the \
1663                 grant forward:\n    icacls \"{}\" /grant \"%USERNAME%:(F)\"\nDo NOT use \
1664                 `takeown`: changing the owner makes Windows delete the OWNER RIGHTS ACE, \
1665                 which leaves this account owning a file it still cannot read. An `auth \
1666                 logout` followed by `auth login`, also elevated, is the heavier alternative \
1667                 and costs a fresh sign-in.",
1668                site.file.display()
1669            ),
1670        )
1671    }
1672
1673    pub(super) fn load(site: &Site, _scope: SecretScope) -> io::Result<Option<Vec<u8>>> {
1674        let blob = match std::fs::read(&site.file) {
1675            Ok(blob) => blob,
1676            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
1677            // Asked before the bare denial is returned, for the same reason
1678            // `cannot_replace` exists on the write path: `Access is denied` on
1679            // a file the operator owns the machine of is a true statement that
1680            // helps nobody.
1681            Err(error) if error.kind() == io::ErrorKind::PermissionDenied => {
1682                return Err(locked_out(site, &error));
1683            }
1684            Err(error) => return Err(error),
1685        };
1686        if blob.is_empty() {
1687            // An empty file is the remnant of an interrupted write, not a
1688            // value. Reported as `InvalidData` so the caller says `Corrupt`
1689            // rather than starting a fresh login over the top of it.
1690            return Err(io::Error::new(
1691                io::ErrorKind::InvalidData,
1692                "the stored file is empty",
1693            ));
1694        }
1695        unprotect(&blob).map(Some)
1696    }
1697
1698    pub(super) fn delete(site: &Site) -> io::Result<bool> {
1699        // Zeroed before it is unlinked, the same as the Linux backend. The
1700        // value here is a DPAPI blob rather than plaintext, so the fill buys
1701        // less; it costs one `write`, and it keeps one mechanism instead of two
1702        // on the path the Definition of Done calls "leaves no recoverable
1703        // remnant". `overwrite`'s documentation carries the disclaimer.
1704        let _ = overwrite(&site.file);
1705
1706        let removed = match std::fs::remove_file(&site.file) {
1707            Ok(()) => true,
1708            Err(error) if error.kind() == io::ErrorKind::NotFound => false,
1709            Err(error) => return Err(error),
1710        };
1711        if let Some(directory) = site.file.parent() {
1712            sweep_temporaries(directory);
1713        }
1714        Ok(removed)
1715    }
1716
1717    // -- DPAPI ---------------------------------------------------------------
1718
1719    /// Turns a windows-rs error into one whose [`io::Error::kind`] means
1720    /// something.
1721    ///
1722    /// `Error::code()` is an **HRESULT**, and handing that straight to
1723    /// `from_raw_os_error` is what an earlier version did. It produces an
1724    /// `io::Error` whose `raw_os_error` is `0x8007_0005` rather than `5`, so
1725    /// `kind()` is `Uncategorized` and a caller cannot tell "access denied"
1726    /// from anything else — which matters here, because the second operator
1727    /// case below is precisely an access denial a caller has to recognise.
1728    ///
1729    /// The `0x8007_xxxx` range is `HRESULT_FROM_WIN32`, so its low sixteen bits
1730    /// are the Win32 code and unwrapping them restores the classification.
1731    /// Anything outside that range is not a Win32 error and is passed through
1732    /// as it is.
1733    pub(super) fn io_error(error: &windows::core::Error) -> io::Error {
1734        let code = error.code().0;
1735        if (code as u32) & 0xffff_0000 == 0x8007_0000 {
1736            io::Error::from_raw_os_error(code & 0xffff)
1737        } else {
1738            io::Error::from_raw_os_error(code)
1739        }
1740    }
1741
1742    /// A blob descriptor over a buffer the caller keeps alive.
1743    fn blob_of(bytes: &mut [u8]) -> CRYPT_INTEGER_BLOB {
1744        CRYPT_INTEGER_BLOB {
1745            cbData: u32::try_from(bytes.len()).unwrap_or(u32::MAX),
1746            pbData: bytes.as_mut_ptr(),
1747        }
1748    }
1749
1750    /// Copies `len` bytes out of `ptr` and zeroes the source.
1751    ///
1752    /// Split out from [`take_blob`] for one reason: a scrub that happens inside
1753    /// an `unsafe` block around a DPAPI buffer can be asserted by nothing —
1754    /// once `LocalFree` has run there is nothing left to look at. Given a
1755    /// pointer, the scrub is a property a test can check against a buffer Rust
1756    /// owns, which is what
1757    /// [`tests::windows::the_dpapi_buffer_is_scrubbed_before_it_is_freed`]
1758    /// does.
1759    ///
1760    /// # Safety
1761    ///
1762    /// `ptr` must be valid for reads *and writes* of `len` bytes, and must not
1763    /// be aliased for the duration of the call.
1764    pub(super) unsafe fn copy_and_scrub(ptr: *mut u8, len: usize) -> Vec<u8> {
1765        use secrecy::zeroize::Zeroize as _;
1766
1767        // SAFETY: the caller guarantees `ptr` is valid for `len` bytes and
1768        // unaliased, so one exclusive slice over it is sound.
1769        let source = unsafe { std::slice::from_raw_parts_mut(ptr, len) };
1770        let copy = source.to_vec();
1771        source.zeroize();
1772        copy
1773    }
1774
1775    /// Copies a DPAPI-allocated output blob into a `Vec`, scrubs the original,
1776    /// and frees it.
1777    ///
1778    /// The scrub is not optional and is not only for tidiness. On the
1779    /// [`unprotect`] path this buffer holds **the user access token in
1780    /// plaintext**, and `LocalFree` returns it to the process heap exactly as
1781    /// it is — where a later allocation, a crash dump, or a core file can pick
1782    /// it up. Every other copy this module makes is scrubbed (`protect`'s
1783    /// input, `decode`'s rejected bytes); this one was the exception.
1784    ///
1785    /// It runs on the [`protect`] path too, where the buffer is ciphertext and
1786    /// the scrub buys nothing. Making it conditional would buy a branch and an
1787    /// opportunity to get the condition wrong.
1788    ///
1789    /// # Safety
1790    ///
1791    /// `out` must be an output blob DPAPI filled in and that has not yet been
1792    /// freed.
1793    unsafe fn take_blob(out: &mut CRYPT_INTEGER_BLOB) -> Vec<u8> {
1794        if out.pbData.is_null() {
1795            return Vec::new();
1796        }
1797        // SAFETY: DPAPI filled this blob in, so the pointer is valid for
1798        // `cbData` bytes and nothing else holds a reference to it.
1799        let bytes = unsafe { copy_and_scrub(out.pbData, out.cbData as usize) };
1800        // SAFETY: the buffer was `LocalAlloc`ed by DPAPI and is freed once.
1801        unsafe {
1802            let _ = LocalFree(Some(HLOCAL(out.pbData.cast())));
1803        }
1804        out.pbData = std::ptr::null_mut();
1805        out.cbData = 0;
1806        bytes
1807    }
1808
1809    fn protect(plaintext: &[u8], scope: SecretScope) -> io::Result<Vec<u8>> {
1810        use secrecy::zeroize::Zeroize as _;
1811
1812        let mut input = plaintext.to_vec();
1813        let mut entropy = ENTROPY.to_vec();
1814        let input_blob = blob_of(&mut input);
1815        let entropy_blob = blob_of(&mut entropy);
1816        let mut out = CRYPT_INTEGER_BLOB::default();
1817
1818        // `CRYPTPROTECT_UI_FORBIDDEN` on both scopes, without exception. A
1819        // daemon that starts at boot has no desktop to draw a prompt on, and a
1820        // call blocked waiting for one is indistinguishable from a hang.
1821        let flags = CRYPTPROTECT_UI_FORBIDDEN
1822            | match scope {
1823                SecretScope::Machine => CRYPTPROTECT_LOCAL_MACHINE,
1824                SecretScope::User => 0,
1825            };
1826
1827        // SAFETY: both input descriptors point into `input` and `entropy`,
1828        // which outlive the call; `out` is a zeroed descriptor DPAPI fills in
1829        // and `take_blob` frees exactly once.
1830        let result = unsafe {
1831            CryptProtectData(
1832                &raw const input_blob,
1833                PCWSTR::null(),
1834                Some(&raw const entropy_blob),
1835                None,
1836                None,
1837                flags,
1838                &raw mut out,
1839            )
1840        };
1841
1842        // The plaintext copy this function made is scrubbed on every path out,
1843        // the failure path included.
1844        input.zeroize();
1845
1846        result.map_err(|error| io_error(&error))?;
1847        // SAFETY: `CryptProtectData` returned success, so `out` is a blob it
1848        // allocated and has not freed.
1849        Ok(unsafe { take_blob(&mut out) })
1850    }
1851
1852    fn unprotect(blob: &[u8]) -> io::Result<Vec<u8>> {
1853        let mut input = blob.to_vec();
1854        let mut entropy = ENTROPY.to_vec();
1855        let input_blob = blob_of(&mut input);
1856        let entropy_blob = blob_of(&mut entropy);
1857        let mut out = CRYPT_INTEGER_BLOB::default();
1858
1859        // SAFETY: as `protect`.
1860        let result = unsafe {
1861            CryptUnprotectData(
1862                &raw const input_blob,
1863                None,
1864                Some(&raw const entropy_blob),
1865                None,
1866                None,
1867                CRYPTPROTECT_UI_FORBIDDEN,
1868                &raw mut out,
1869            )
1870        };
1871
1872        match result {
1873            // SAFETY: success, so `out` is DPAPI's and unfreed.
1874            Ok(()) => Ok(unsafe { take_blob(&mut out) }),
1875            // Every way this fails means one thing to a caller: there are bytes
1876            // here and they are not a value this store can read back. Reported
1877            // as `InvalidData` so it surfaces as `Corrupt` and sends the
1878            // operator to `auth logout`, rather than as a transient read
1879            // failure that something would retry forever.
1880            Err(error) => Err(io::Error::new(
1881                io::ErrorKind::InvalidData,
1882                format!(
1883                    "the stored bytes could not be unprotected with this machine's DPAPI key \
1884                     ({error})"
1885                ),
1886            )),
1887        }
1888    }
1889
1890    // -- The protected file --------------------------------------------------
1891
1892    fn to_wide(path: &Path) -> Vec<u16> {
1893        path.as_os_str()
1894            .encode_wide()
1895            .chain(std::iter::once(0))
1896            .collect()
1897    }
1898
1899    /// Creates a new file carrying `descriptor` from the moment it exists.
1900    ///
1901    /// Takes the SDDL rather than the scope because a replacement's DACL is
1902    /// [`sddl`] plus the previous owner — see [`replacement_sddl`] — and a file
1903    /// that is created and then widened is unreadable to the account it is
1904    /// being widened for for however long the gap lasts.
1905    fn create_protected_file(path: &Path, descriptor: &str) -> io::Result<File> {
1906        let sddl_wide: Vec<u16> = descriptor
1907            .encode_utf16()
1908            .chain(std::iter::once(0))
1909            .collect();
1910
1911        let mut descriptor = PSECURITY_DESCRIPTOR(std::ptr::null_mut());
1912        // SAFETY: `sddl_wide` is a NUL-terminated UTF-16 buffer that outlives
1913        // the call, and `descriptor` receives a LocalAlloc'd descriptor freed
1914        // below on both paths.
1915        unsafe {
1916            ConvertStringSecurityDescriptorToSecurityDescriptorW(
1917                PCWSTR(sddl_wide.as_ptr()),
1918                SDDL_REVISION_1,
1919                &mut descriptor,
1920                None,
1921            )
1922        }
1923        .map_err(|error| io_error(&error))?;
1924
1925        let attributes = SECURITY_ATTRIBUTES {
1926            nLength: u32::try_from(size_of::<SECURITY_ATTRIBUTES>()).unwrap_or(u32::MAX),
1927            lpSecurityDescriptor: descriptor.0,
1928            // False: no child process this agent spawns has any business
1929            // inheriting a handle to the token.
1930            bInheritHandle: windows::core::BOOL(0),
1931        };
1932
1933        let wide = to_wide(path);
1934        // SAFETY: `wide` is NUL-terminated and outlives the call; `attributes`
1935        // points at a descriptor that is still live here.
1936        let handle = unsafe {
1937            CreateFileW(
1938                PCWSTR(wide.as_ptr()),
1939                FILE_GENERIC_READ.0 | FILE_GENERIC_WRITE.0,
1940                FILE_SHARE_NONE,
1941                Some(&raw const attributes),
1942                CREATE_NEW,
1943                FILE_ATTRIBUTE_NORMAL,
1944                None,
1945            )
1946        };
1947
1948        // SAFETY: the descriptor was LocalAlloc'd by the conversion above and
1949        // is freed exactly once, after the last use of `attributes`.
1950        unsafe {
1951            let _ = LocalFree(Some(HLOCAL(descriptor.0)));
1952        }
1953
1954        let handle = handle.map_err(|error| io_error(&error))?;
1955        // SAFETY: `CreateFileW` returned success, so the handle is a valid
1956        // owned file handle this `File` takes over.
1957        Ok(unsafe { File::from_raw_handle(handle.0) })
1958    }
1959}
1960
1961// ---------------------------------------------------------------------------
1962
1963/// The System keychain's refusal of an ordinary account, explained.
1964///
1965/// `auth login` without `--start-at` on a Mac with no service used to reach
1966/// for this store and print only `SecKeychainItemCreateFromContent returned
1967/// -61`, after the operator had already approved the device code.
1968#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
1969fn system_keychain_write_refused(source: &str) -> String {
1970    format!(
1971        "{source}. The machine-scoped store is the System keychain, and only root may write \
1972         it. It holds the credential for a service that starts at boot; sign in for that one \
1973         with `sudo runner-manager auth login --start-at boot`. For a service that runs as you \
1974         (`runner-manager service install --start-at login`), sign in to your own login \
1975         keychain instead: `runner-manager auth login --start-at login`"
1976    )
1977}
1978
1979#[cfg(target_os = "macos")]
1980mod sys {
1981    //! A generic-password item in a keychain, and which keychain is the whole
1982    //! of the scope.
1983    //!
1984    //! | scope | keychain | why |
1985    //! |---|---|---|
1986    //! | [`SecretScope::Machine`] | `/Library/Keychains/System.keychain` | the only keychain a process with no login session can open. A LaunchAgent starts at login; a LaunchDaemon starts at boot and has no user keychain to reach for. |
1987    //! | [`SecretScope::User`] | `~/Library/Keychains/login.keychain-db` | the operator's own keychain, unlocked by their login, gone when they log out. |
1988    //!
1989    //! # Why the login keychain is opened by path
1990    //!
1991    //! `SecKeychainCopyDefault` would give the account's *current* default
1992    //! keychain, which is more nearly what an operator expects. It hands back a
1993    //! handle and no path — and this store has to be able to name the file
1994    //! whose mode protects the value, both for `host show` and for
1995    //! [`super::SecretStore::protection`]. A store that could not say what
1996    //! protects it would be asserting the security property by assumption.
1997    //! So the login keychain is resolved by path, `login.keychain-db` first and
1998    //! the pre-Sierra `login.keychain` second, and an operator who has moved
1999    //! their default elsewhere gets the store in their login keychain rather
2000    //! than wherever the default now points.
2001    //!
2002    //! # User interaction is disabled around every call
2003    //!
2004    //! `SecKeychainSetUserInteractionAllowed(false)` for the duration of each
2005    //! operation, unconditionally. A daemon started at boot has no desktop, and
2006    //! a keychain call that decides to draw an unlock panel there does not fail
2007    //! — it waits. `errSecInteractionNotAllowed` returned in a second is a
2008    //! diagnosable condition; a process wedged behind an invisible dialog is
2009    //! not.
2010    //!
2011    //! # What a rooted keychain is for
2012    //!
2013    //! [`super::PlatformSecretStore::rooted_at`] creates a keychain of its own
2014    //! under the caller's root. That is the only way the suite can exercise
2015    //! this backend at all: writing to `/Library/Keychains/System.keychain`
2016    //! needs `root`, and writing to the login keychain would destroy a
2017    //! developer's real `auth login` every time the tests ran. What it covers
2018    //! and what it does not is set out on `rooted_at` itself.
2019
2020    use std::io;
2021    use std::os::unix::fs::{DirBuilderExt as _, PermissionsExt as _};
2022    use std::path::{Path, PathBuf};
2023
2024    use security_framework::os::macos::keychain::{CreateOptions, KeychainSettings, SecKeychain};
2025
2026    use super::{
2027        DIRECTORY, ITEM, KEYCHAIN_SERVICE, ROOTED_KEYCHAIN_PASSWORD, SecretScope, StartMode,
2028    };
2029
2030    /// The composed product identity, as a plain `&str`.
2031    ///
2032    /// [`KEYCHAIN_SERVICE`] is a `LazyLock<String>` so that it is built from
2033    /// `crate::paths`'s three segments rather than written out a second time.
2034    /// This is the one place that unwraps it, so the call sites below read as
2035    /// they did when it was a constant.
2036    pub(super) fn service() -> &'static str {
2037        &KEYCHAIN_SERVICE
2038    }
2039
2040    /// `errSecItemNotFound`. Hard-coded rather than imported because
2041    /// `security-framework-sys` is not a dependency of this workspace and
2042    /// adding one would be an A-group change for a single integer.
2043    const ERR_SEC_ITEM_NOT_FOUND: i32 = -25300;
2044    /// `errSecNoSuchKeychain`. What a keychain file that is not there answers
2045    /// with, which is absence rather than failure.
2046    const ERR_SEC_NO_SUCH_KEYCHAIN: i32 = -25294;
2047
2048    /// The System Keychain's master key. Root-only, and the reason an
2049    /// unprivileged local user cannot read a machine-scoped item even though
2050    /// the keychain database beside it is world-readable.
2051    const SYSTEM_KEYCHAIN_MASTER_KEY: &str = "/var/db/SystemKey";
2052    /// The machine-scoped keychain itself.
2053    const SYSTEM_KEYCHAIN: &str = "/Library/Keychains/System.keychain";
2054
2055    /// Which keychain, and where it is.
2056    #[derive(Debug, Clone, PartialEq, Eq)]
2057    pub(super) struct Site {
2058        path: PathBuf,
2059        kind: Kind,
2060    }
2061
2062    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2063    enum Kind {
2064        /// The System Keychain. Written by `root`, readable by a LaunchDaemon.
2065        System,
2066        /// The operator's login keychain.
2067        Login,
2068        /// A keychain this program created under a caller-named root.
2069        Rooted,
2070    }
2071
2072    pub(super) fn standard_site(scope: SecretScope) -> Result<Site, String> {
2073        match scope {
2074            SecretScope::Machine => Ok(Site {
2075                path: PathBuf::from(SYSTEM_KEYCHAIN),
2076                kind: Kind::System,
2077            }),
2078            SecretScope::User => {
2079                let home = directories::BaseDirs::new()
2080                    .ok_or_else(|| {
2081                        "the operating system reports no home directory for this account, so \
2082                         the login keychain cannot be resolved. A service account configured \
2083                         with no profile normally hits this; use the machine-scoped store, \
2084                         which is what --start-at boot installs."
2085                            .to_string()
2086                    })?
2087                    .home_dir()
2088                    .join("Library")
2089                    .join("Keychains");
2090
2091                // Sierra renamed the login keychain and kept the old one
2092                // working, so a machine upgraded across that boundary can have
2093                // either. Prefer the modern name, fall back to the legacy one
2094                // only when it is the one that actually exists, and resolve to
2095                // the modern name when neither does so that a first `auth
2096                // login` creates the right thing.
2097                let modern = home.join("login.keychain-db");
2098                let legacy = home.join("login.keychain");
2099                let path = if modern.exists() || !legacy.exists() {
2100                    modern
2101                } else {
2102                    legacy
2103                };
2104                Ok(Site {
2105                    path,
2106                    kind: Kind::Login,
2107                })
2108            }
2109        }
2110    }
2111
2112    pub(super) fn rooted_site(scope: SecretScope, root: &Path) -> Result<Site, String> {
2113        // A keychain per scope, for the reason the Windows and Linux backends
2114        // give a directory per scope: under one caller-named root the two
2115        // stores have nothing else keeping them apart, and two stores sharing
2116        // one item means `auth logout` under either scope purges both.
2117        Ok(Site {
2118            path: root
2119                .join(DIRECTORY)
2120                .join(scope.as_str())
2121                .join("runner-manager.keychain-db"),
2122            kind: Kind::Rooted,
2123        })
2124    }
2125
2126    pub(super) fn describe(site: &Site) -> String {
2127        let kind = match site.kind {
2128            Kind::System => "System",
2129            Kind::Login => "login",
2130            Kind::Rooted => "rooted",
2131        };
2132        format!(
2133            "{kind} keychain {}, item {}/{ITEM}",
2134            site.path.display(),
2135            service()
2136        )
2137    }
2138
2139    pub(super) fn guard(site: &Site) -> PathBuf {
2140        match site.kind {
2141            // Not the keychain database. `/Library/Keychains/System.keychain`
2142            // is world-readable and its contents are encrypted; the mode that
2143            // decides who can decrypt them belongs to the master key beside it.
2144            // Reporting the database here would answer a question nobody asked,
2145            // and answer it wrongly.
2146            Kind::System => PathBuf::from(SYSTEM_KEYCHAIN_MASTER_KEY),
2147            Kind::Login | Kind::Rooted => site.path.clone(),
2148        }
2149    }
2150
2151    /// `errSecWrPerm`: the keychain refused to be written.
2152    const ERR_SEC_WR_PERM: i32 = -61;
2153
2154    /// A Security.framework status, carried inside the `io::Error` so that a
2155    /// caller acts on the code rather than on the words.
2156    #[derive(Debug)]
2157    struct Status {
2158        message: String,
2159        code: i32,
2160    }
2161
2162    impl std::fmt::Display for Status {
2163        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2164            f.write_str(&self.message)
2165        }
2166    }
2167
2168    impl std::error::Error for Status {}
2169
2170    /// The status an error from this module carries, if it carries one.
2171    fn status_of(error: &io::Error) -> Option<i32> {
2172        error
2173            .get_ref()?
2174            .downcast_ref::<Status>()
2175            .map(|status| status.code)
2176    }
2177
2178    fn sec_error(error: &security_framework::base::Error) -> io::Error {
2179        io::Error::other(Status {
2180            message: format!("Security.framework returned {} ({error})", error.code()),
2181            code: error.code(),
2182        })
2183    }
2184
2185    fn is_absence(error: &security_framework::base::Error) -> bool {
2186        matches!(
2187            error.code(),
2188            ERR_SEC_ITEM_NOT_FOUND | ERR_SEC_NO_SUCH_KEYCHAIN
2189        )
2190    }
2191
2192    /// `errSecDuplicateItem`, which this store only ever meets in one
2193    /// situation. See [`replace_unreadable`].
2194    const ERR_SEC_DUPLICATE_ITEM: i32 = -25299;
2195
2196    /// `errSecAuthFailed`, which a keychain answers when the item is there and
2197    /// the caller may not have it — because the ACL does not name the program
2198    /// asking, or because the keychain's master key is out of this account's
2199    /// reach. [`locked_out`] tells those two apart.
2200    const ERR_SEC_AUTH_FAILED: i32 = -25293;
2201
2202    fn is_duplicate(error: &security_framework::base::Error) -> bool {
2203        error.code() == ERR_SEC_DUPLICATE_ITEM
2204    }
2205
2206    /// Whether this process is `root`.
2207    ///
2208    /// Only ever used to *explain* a refusal, never to decide whether to
2209    /// attempt one: the keychain is the authority on what this process may
2210    /// read, and a privilege check of our own that disagreed with it would be a
2211    /// second, wrong answer.
2212    fn is_root() -> bool {
2213        // SAFETY: `geteuid` takes no argument, reads only this process's own
2214        // credentials, and is documented never to fail.
2215        unsafe { libc::geteuid() == 0 }
2216    }
2217
2218    /// Removes the item without reading it, so that a value this program may
2219    /// not read can still be replaced.
2220    ///
2221    /// # Why this is needed at all
2222    ///
2223    /// A keychain grants access per *application*, and the daemon runs a copy
2224    /// of the binary that `service install` makes. Replacing that copy — which
2225    /// is what an upgrade is — produces a program the item's ACL does not name,
2226    /// and it reads `-25293` from the credential it is supposed to own.
2227    ///
2228    /// `auth login` is the remedy, and before this it could not be: the
2229    /// crate's `set_generic_password` is
2230    ///
2231    /// ```text
2232    /// match self.find_generic_password(service, account) {
2233    ///     Ok((_, mut item)) => item.set_password(password),
2234    ///     _ => self.add_generic_password(service, account, password),
2235    /// }
2236    /// ```
2237    ///
2238    /// so a `find` refused by the ACL falls through to `add`, and `add` meets
2239    /// the item that is already there: **`-25299`**. Update refused because the
2240    /// item cannot be read, add refused because it exists. A device flow that
2241    /// had already completed then had nowhere to put the token it obtained,
2242    /// and the refresh half went with it.
2243    ///
2244    /// Watched on 2026-08-30, upgrading a real host from 0.1.12 to 0.1.15.
2245    ///
2246    /// # Why deleting works where updating does not
2247    ///
2248    /// `SecItemDelete` matches on the query — class, keychain, service,
2249    /// account — and never asks for the data, so the ACL that guards *reading*
2250    /// does not apply. The value is being replaced either way, which is the
2251    /// only reason destroying it is the right move here.
2252    fn replace_unreadable(keychain: &SecKeychain) -> io::Result<()> {
2253        delete_by_query(keychain).map_err(|error| sec_error(&error))
2254    }
2255
2256    /// The delete itself, with the platform error kept intact.
2257    ///
2258    /// Separate from [`replace_unreadable`] only so that
2259    /// [`remove_any_existing`] can read the status code rather than the
2260    /// rendered message.
2261    fn delete_by_query(keychain: &SecKeychain) -> Result<(), security_framework::base::Error> {
2262        use security_framework::item::{ItemClass, ItemSearchOptions};
2263
2264        ItemSearchOptions::new()
2265            .class(ItemClass::generic_password())
2266            .keychains(std::slice::from_ref(keychain))
2267            .service(service())
2268            .account(ITEM)
2269            .delete()
2270    }
2271
2272    /// [`replace_unreadable`], for a caller that does not know whether there is
2273    /// anything there.
2274    ///
2275    /// Nothing to delete is the ordinary state of a first `auth login`, and is
2276    /// success.
2277    fn remove_any_existing(keychain: &SecKeychain) -> io::Result<()> {
2278        match delete_by_query(keychain) {
2279            Ok(()) => Ok(()),
2280            Err(error) if is_absence(&error) => Ok(()),
2281            Err(error) => Err(sec_error(&error)),
2282        }
2283    }
2284
2285    /// Suppresses keychain UI for as long as the returned guard lives.
2286    ///
2287    /// Best effort: a platform that refuses the call is not a reason to fail
2288    /// the operation, only a reason not to have the guarantee. The guard
2289    /// re-enables interaction on drop, so this never leaks process-wide state
2290    /// past the call that took it.
2291    fn without_user_interaction()
2292    -> Option<security_framework::os::macos::keychain::KeychainUserInteractionLock> {
2293        SecKeychain::disable_user_interaction().ok()
2294    }
2295
2296    /// Opens the keychain, creating a rooted one if asked and if it is missing.
2297    ///
2298    /// `Ok(None)` means "there is no such keychain", which for a read is
2299    /// absence rather than failure.
2300    fn open(site: &Site, create_if_missing: bool) -> io::Result<Option<SecKeychain>> {
2301        match site.kind {
2302            Kind::System | Kind::Login => {
2303                if !site.path.exists() {
2304                    return Ok(None);
2305                }
2306                SecKeychain::open(&site.path)
2307                    .map(Some)
2308                    .map_err(|error| sec_error(&error))
2309            }
2310            Kind::Rooted if site.path.exists() => {
2311                let mut keychain =
2312                    SecKeychain::open(&site.path).map_err(|error| sec_error(&error))?;
2313                keychain
2314                    .unlock(Some(ROOTED_KEYCHAIN_PASSWORD))
2315                    .map_err(|error| sec_error(&error))?;
2316                Ok(Some(keychain))
2317            }
2318            Kind::Rooted if create_if_missing => Ok(Some(create_rooted(site)?)),
2319            Kind::Rooted => Ok(None),
2320        }
2321    }
2322
2323    fn create_rooted(site: &Site) -> io::Result<SecKeychain> {
2324        let directory = site
2325            .path
2326            .parent()
2327            .ok_or_else(|| io::Error::other("the rooted keychain path has no parent directory"))?;
2328        // `0700` at `mkdir(2)` time, for the reason `crate::paths` gives: a
2329        // two-step create-then-chmod leaves a window, and here the window is
2330        // over the directory whose mode is the only thing protecting a rooted
2331        // keychain.
2332        std::fs::DirBuilder::new()
2333            .mode(0o700)
2334            .recursive(true)
2335            .create(directory)?;
2336        std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700))?;
2337
2338        let mut keychain = CreateOptions::new()
2339            .password(ROOTED_KEYCHAIN_PASSWORD)
2340            // Never. A daemon has no operator to ask.
2341            .prompt_user(false)
2342            .create(&site.path)
2343            .map_err(|error| sec_error(&error))?;
2344
2345        // No auto-lock and no lock on sleep: an agent that has been running for
2346        // a week must not find its own store locked. The keychain is inside a
2347        // `0700` directory, so locking would add nothing an unprivileged local
2348        // user could defeat anyway.
2349        let mut settings = KeychainSettings::new();
2350        settings.set_lock_on_sleep(false);
2351        settings.set_lock_interval(None);
2352        keychain
2353            .set_settings(&settings)
2354            .map_err(|error| sec_error(&error))?;
2355
2356        // `SecKeychainCreate` is documented to take a POSIX path and is
2357        // expected to use it verbatim; the `-db` suffix `security
2358        // create-keychain` is known for is appended to a *bare name*, and the
2359        // name above already carries it. If that expectation is ever wrong the
2360        // next line fails with a bare `NotFound` on a path nobody would think
2361        // to look at, so say what actually appeared instead.
2362        if !site.path.exists() {
2363            return Err(io::Error::other(format!(
2364                "SecKeychainCreate reported success but there is no keychain at {}. {} holds {:?}",
2365                site.path.display(),
2366                directory.display(),
2367                std::fs::read_dir(directory)
2368                    .map(|entries| entries
2369                        .flatten()
2370                        .map(|entry| entry.file_name().to_string_lossy().into_owned())
2371                        .collect::<Vec<_>>())
2372                    .unwrap_or_default()
2373            )));
2374        }
2375
2376        restrict(site)?;
2377        Ok(keychain)
2378    }
2379
2380    /// Sets a keychain this program created to `0600`.
2381    ///
2382    /// Called after creation *and* after every write. A keychain database is
2383    /// rewritten by `securityd` rather than by this process, and a rewrite that
2384    /// went through a fresh file would take the umask's mode rather than the
2385    /// one set at creation — which is the mode this store's entire access
2386    /// control rests on for a rooted keychain, and which
2387    /// `the_guard_is_0600_and_its_directory_is_0700` asserts.
2388    ///
2389    /// Never applied to the System or login keychains: their modes are the
2390    /// operating system's business, not this program's.
2391    fn restrict(site: &Site) -> io::Result<()> {
2392        if site.kind != Kind::Rooted {
2393            return Ok(());
2394        }
2395        std::fs::set_permissions(&site.path, std::fs::Permissions::from_mode(0o600))
2396    }
2397
2398    /// The keychain database is rewritten beside itself, in this process, so
2399    /// its directory is what a write needs. Root's alone for the System
2400    /// keychain -- which every local account can nevertheless *read* an item
2401    /// from, once it is granted to every application.
2402    pub(super) fn ensure_writable(site: &Site) -> io::Result<()> {
2403        super::ensure_directory_writable(&site.path)
2404    }
2405
2406    pub(super) fn store(site: &Site, _scope: SecretScope, plaintext: &[u8]) -> io::Result<()> {
2407        write(site, plaintext).map_err(|error| {
2408            if site.kind == Kind::System && !is_root() && status_of(&error) == Some(ERR_SEC_WR_PERM)
2409            {
2410                io::Error::new(
2411                    io::ErrorKind::PermissionDenied,
2412                    super::system_keychain_write_refused(&error.to_string()),
2413                )
2414            } else {
2415                error
2416            }
2417        })
2418    }
2419
2420    fn write(site: &Site, plaintext: &[u8]) -> io::Result<()> {
2421        let _no_ui = without_user_interaction();
2422        let keychain = open(site, true)?.ok_or_else(|| {
2423            io::Error::new(
2424                io::ErrorKind::NotFound,
2425                format!(
2426                    "there is no keychain at {}. The machine-scoped store needs the System \
2427                     Keychain, which only root may write; the user-scoped store needs a login \
2428                     keychain, which exists once the account has logged in.",
2429                    site.path.display()
2430                ),
2431            )
2432        })?;
2433
2434        if grants_every_application(site) {
2435            // Removed and written afresh, never updated. An item keeps the
2436            // access it was created with, so `set_generic_password` on an
2437            // existing item would leave the per-binary grant in place — which
2438            // is the whole condition this branch exists to end. Deleting by
2439            // query needs no permission to *read* the value, so it works even
2440            // against an item this program is already locked out of.
2441            remove_any_existing(&keychain)?;
2442            add_granted_to_every_application(&keychain, plaintext)?;
2443        } else {
2444            match keychain.set_generic_password(service(), ITEM, plaintext) {
2445                Ok(()) => {}
2446                // The item is there and this program may not read it, so the
2447                // crate's update-or-add could do neither. Take it away and
2448                // write a fresh one, which is what replacing a credential means
2449                // anyway.
2450                Err(error) if is_duplicate(&error) => {
2451                    replace_unreadable(&keychain)?;
2452                    keychain
2453                        .set_generic_password(service(), ITEM, plaintext)
2454                        .map_err(|error| sec_error(&error))?;
2455                }
2456                Err(error) => return Err(sec_error(&error)),
2457            }
2458        }
2459
2460        restrict(site)
2461    }
2462
2463    // -----------------------------------------------------------------------
2464    // Who the stored item is granted to
2465    // -----------------------------------------------------------------------
2466
2467    /// The name the keychain shows for this grant, if it ever shows one.
2468    const ACCESS_DESCRIPTOR: &str = "runner-manager user access token";
2469
2470    /// Whether items in this keychain are granted to **every** application
2471    /// rather than to the one binary that wrote them.
2472    ///
2473    /// # The failure this ends
2474    ///
2475    /// A keychain ACL names *applications*, and identifies an unsigned one by
2476    /// its code directory hash — so replacing the binary produces a program the
2477    /// ACL does not name. Replacing the binary is what an upgrade is. The
2478    /// daemon then reads `errSecAuthFailed` from the credential it is supposed
2479    /// to own, exits `13`, and launchd restarts it every fifteen seconds
2480    /// forever; watched on a real host upgrading 0.1.12 to 0.1.15, and again on
2481    /// 0.1.16 to 0.1.17.
2482    ///
2483    /// The remedy the store used to print — sign in again, from a graphical
2484    /// terminal, with the exact copy of the binary that will do the reading —
2485    /// works exactly once, until the next upgrade. It is not a remedy, it is a
2486    /// recurring outage with instructions.
2487    ///
2488    /// # Why widening the grant costs nothing on these two keychains
2489    ///
2490    /// The per-application ACL is only a boundary where something else is
2491    /// **not** already the boundary, and on both of these it is:
2492    ///
2493    /// * The **System Keychain** is decrypted with `/var/db/SystemKey`, which
2494    ///   is `root`-only. Every process that can read an item there is already
2495    ///   `root`, and `07-security.md` records that *"a local administrator or
2496    ///   `root` can read the token"* as an accepted trade-off of machine-scoped
2497    ///   storage. Granting every application does not admit one caller that the
2498    ///   master key was keeping out.
2499    /// * A **rooted** keychain is protected by the `0700` directory it sits in
2500    ///   and is unlocked with [`ROOTED_KEYCHAIN_PASSWORD`], a constant in a
2501    ///   public binary. Its ACL was never the thing holding anybody out either.
2502    ///
2503    /// The **login** keychain is the one place where the ACL is a real boundary
2504    /// — every process running as the operator can reach that keychain, and the
2505    /// per-application grant is what stops one of them reading this token
2506    /// silently. So it keeps the default.
2507    ///
2508    /// Widening would not help there anyway. The login keychain also records
2509    /// the *partition* of the code that created an item -- `cdhash:<hash>` for
2510    /// an ad-hoc signed binary -- and refuses every other partition without a
2511    /// prompt, whatever the application list says. Measured on macOS 27 with
2512    /// two ad-hoc builds of one program: an item granted to every application
2513    /// in the login keychain was refused to the second build, and the same item
2514    /// in a keychain of its own was not. What survives an upgrade there is the
2515    /// new build writing the item itself, which the daemon arranges while it
2516    /// upgrades (`hand_over_credential_to_new_binary` in the application).
2517    fn grants_every_application(site: &Site) -> bool {
2518        match site.kind {
2519            Kind::System | Kind::Rooted => true,
2520            Kind::Login => false,
2521        }
2522    }
2523
2524    /// Adds the item with its access already decided.
2525    ///
2526    /// # Why not add it and then widen it
2527    ///
2528    /// That was the first shape of this, and it deadlocks a headless process.
2529    /// `SecKeychainItemSetAccess` *changes* an existing item's access control,
2530    /// which is itself an ACL-guarded operation, and macOS asks the person at
2531    /// the desk to confirm it — through Authorization Services, which
2532    /// `SecKeychainSetUserInteractionAllowed(false)` does not suppress. The
2533    /// call does not fail; `SecurityAgent` starts and the process waits behind
2534    /// a panel that a daemon has no desktop to draw on. Watched here: the store
2535    /// hung indefinitely with `SecurityAgent` running.
2536    ///
2537    /// Supplying the access at creation asks nobody anything, because there is
2538    /// no existing access to be authorized against. It is also what
2539    /// `security add-generic-password -A` does, and for the same reason.
2540    fn add_granted_to_every_application(
2541        keychain: &SecKeychain,
2542        plaintext: &[u8],
2543    ) -> io::Result<()> {
2544        use core_foundation::base::TCFType as _;
2545
2546        let access = AnyApplicationAccess::create()?;
2547
2548        // The two attributes that identify a generic password. Both buffers
2549        // outlive the call below, which is the whole of the safety argument for
2550        // the raw pointers in the attribute list.
2551        let service_name = service();
2552        let account = ITEM;
2553        let mut attributes = [
2554            ffi::SecKeychainAttribute {
2555                tag: ffi::SEC_SERVICE_ITEM_ATTR,
2556                length: u32::try_from(service_name.len()).map_err(|_| {
2557                    io::Error::other(
2558                        "the keychain service name is longer than an attribute can carry",
2559                    )
2560                })?,
2561                data: service_name.as_ptr().cast::<std::ffi::c_void>().cast_mut(),
2562            },
2563            ffi::SecKeychainAttribute {
2564                tag: ffi::SEC_ACCOUNT_ITEM_ATTR,
2565                length: u32::try_from(account.len()).map_err(|_| {
2566                    io::Error::other(
2567                        "the keychain account name is longer than an attribute can carry",
2568                    )
2569                })?,
2570                data: account.as_ptr().cast::<std::ffi::c_void>().cast_mut(),
2571            },
2572        ];
2573        let mut list = ffi::SecKeychainAttributeList {
2574            count: 2,
2575            attr: attributes.as_mut_ptr(),
2576        };
2577        let length = u32::try_from(plaintext.len())
2578            .map_err(|_| io::Error::other("the value is longer than a keychain item can hold"))?;
2579
2580        // SAFETY: the attribute list, its two buffers and `plaintext` all
2581        // outlive the call; `keychain` and `access` are live references; the
2582        // item is not asked for, so nothing is returned that must be released.
2583        let status = unsafe {
2584            ffi::SecKeychainItemCreateFromContent(
2585                ffi::SEC_GENERIC_PASSWORD_ITEM_CLASS,
2586                &raw mut list,
2587                length,
2588                plaintext.as_ptr().cast(),
2589                keychain.as_concrete_TypeRef().cast(),
2590                access.raw(),
2591                std::ptr::null_mut(),
2592            )
2593        };
2594        os_status("SecKeychainItemCreateFromContent", status)
2595    }
2596
2597    /// A `SecAccess` whose every access-control entry names no application,
2598    /// which is how Security.framework spells *"any application, without
2599    /// prompting"*.
2600    ///
2601    /// `SecAccessCreate` with a null trusted list produces the system default:
2602    /// entries naming just the calling application. Each is then rewritten with
2603    /// a null application list, keeping the prompt text and selector the system
2604    /// chose. This is what `security add-generic-password -A` does, in the same
2605    /// order.
2606    struct AnyApplicationAccess(ffi::SecAccessRef);
2607
2608    impl AnyApplicationAccess {
2609        fn create() -> io::Result<Self> {
2610            use core_foundation::base::TCFType as _;
2611            use core_foundation::string::CFString;
2612
2613            let descriptor = CFString::new(ACCESS_DESCRIPTOR);
2614            let mut access: ffi::SecAccessRef = std::ptr::null_mut();
2615            // SAFETY: `descriptor` outlives the call; a null trusted list is
2616            // the documented way to ask for the default entries; `access` is a
2617            // valid out-pointer and is only read when the call reports success.
2618            let status = unsafe {
2619                ffi::SecAccessCreate(
2620                    descriptor.as_concrete_TypeRef().cast(),
2621                    std::ptr::null(),
2622                    &raw mut access,
2623                )
2624            };
2625            os_status("SecAccessCreate", status)?;
2626            if access.is_null() {
2627                return Err(io::Error::other(
2628                    "SecAccessCreate reported success and produced no access",
2629                ));
2630            }
2631            let owned = Self(access);
2632            owned.widen()?;
2633            Ok(owned)
2634        }
2635
2636        fn raw(&self) -> ffi::SecAccessRef {
2637            self.0
2638        }
2639
2640        /// Rewrites every entry to name no application.
2641        fn widen(&self) -> io::Result<()> {
2642            let mut list: ffi::CFArrayRef = std::ptr::null();
2643            // SAFETY: `self.0` is a live `SecAccessRef`; `list` is a valid
2644            // out-pointer, and the array it returns is owned by this call.
2645            let status = unsafe { ffi::SecAccessCopyACLList(self.0, &raw mut list) };
2646            os_status("SecAccessCopyACLList", status)?;
2647            let entries = CfOwned(list.cast());
2648
2649            // SAFETY: `list` came back retained from the call above.
2650            let count = unsafe { ffi::CFArrayGetCount(list) };
2651            for index in 0..count {
2652                // SAFETY: `index` is within the count the array just reported.
2653                let entry = unsafe { ffi::CFArrayGetValueAtIndex(list, index) };
2654                if entry.is_null() {
2655                    continue;
2656                }
2657                widen_one(entry.cast_mut().cast())?;
2658            }
2659            drop(entries);
2660            Ok(())
2661        }
2662    }
2663
2664    impl Drop for AnyApplicationAccess {
2665        fn drop(&mut self) {
2666            // SAFETY: created retained by `SecAccessCreate` and released once.
2667            unsafe { ffi::CFRelease(self.0.cast_const().cast()) };
2668        }
2669    }
2670
2671    /// One access-control entry, rewritten to name no application.
2672    ///
2673    /// An entry whose contents cannot be read in this simple form is left
2674    /// alone rather than failing the store: `SecAccessCreate`'s default
2675    /// includes entries that are not application lists at all, and the one that
2676    /// decides whether the value can be decrypted is not among them.
2677    fn widen_one(entry: ffi::SecACLRef) -> io::Result<()> {
2678        let mut applications: ffi::CFArrayRef = std::ptr::null();
2679        let mut description: ffi::CFStringRef = std::ptr::null();
2680        let mut prompt: u16 = 0;
2681        // SAFETY: `entry` is an element of a live ACL array, and all three
2682        // out-pointers are valid. Anything returned is owned by this call.
2683        let copied = unsafe {
2684            ffi::SecACLCopyContents(
2685                entry,
2686                &raw mut applications,
2687                &raw mut description,
2688                &raw mut prompt,
2689            )
2690        };
2691        if copied != 0 {
2692            return Ok(());
2693        }
2694        let previous = CfOwned(applications.cast());
2695        let text = CfOwned(description.cast());
2696
2697        // SAFETY: `entry` is live and `description` is either null or a live
2698        // string; a null application list is the documented "any application".
2699        let status =
2700            unsafe { ffi::SecACLSetContents(entry, std::ptr::null(), description, prompt) };
2701        drop(previous);
2702        drop(text);
2703        os_status("SecACLSetContents", status)
2704    }
2705
2706    /// A Core Foundation value this code owns a reference to.
2707    struct CfOwned(*const std::ffi::c_void);
2708
2709    impl Drop for CfOwned {
2710        fn drop(&mut self) {
2711            if !self.0.is_null() {
2712                // SAFETY: every construction site holds a reference returned by
2713                // a `Copy`/`Create` call, and releases it exactly once.
2714                unsafe { ffi::CFRelease(self.0) };
2715            }
2716        }
2717    }
2718
2719    /// `noErr` is success; anything else is named with the call that returned
2720    /// it, because an operator reading `-25244` needs to know which of five
2721    /// calls produced it.
2722    fn os_status(call: &'static str, status: i32) -> io::Result<()> {
2723        if status == 0 {
2724            Ok(())
2725        } else {
2726            Err(io::Error::other(Status {
2727                message: format!("{call} returned {status}"),
2728                code: status,
2729            }))
2730        }
2731    }
2732
2733    /// The Security and Core Foundation entry points that `security-framework`
2734    /// does not wrap.
2735    ///
2736    /// Declared here rather than pulled in as another dependency:
2737    /// `security-framework-sys` binds neither `SecAccess*` nor `SecACL*`, so a
2738    /// dependency would have to be a new one, for five functions whose
2739    /// signatures are fixed platform ABI.
2740    mod ffi {
2741        use std::ffi::c_void;
2742
2743        pub type CFArrayRef = *const c_void;
2744        pub type CFStringRef = *const c_void;
2745        pub type CFIndex = isize;
2746        pub type SecAccessRef = *mut c_void;
2747        pub type SecACLRef = *mut c_void;
2748        pub type SecKeychainRef = *mut c_void;
2749        pub type SecKeychainItemRef = *mut c_void;
2750
2751        /// `kSecGenericPasswordItemClass`, the four-character code `'genp'`.
2752        pub const SEC_GENERIC_PASSWORD_ITEM_CLASS: u32 = u32::from_be_bytes(*b"genp");
2753        /// `kSecServiceItemAttr`, `'svce'`.
2754        pub const SEC_SERVICE_ITEM_ATTR: u32 = u32::from_be_bytes(*b"svce");
2755        /// `kSecAccountItemAttr`, `'acct'`.
2756        pub const SEC_ACCOUNT_ITEM_ATTR: u32 = u32::from_be_bytes(*b"acct");
2757        /// `kSecUnlockStateStatus`, the bit `SecKeychainGetStatus` sets for an
2758        /// unlocked keychain.
2759        pub const SEC_UNLOCK_STATE_STATUS: u32 = 1;
2760
2761        /// `SecKeychainAttribute` from `SecBase.h`.
2762        #[repr(C)]
2763        pub struct SecKeychainAttribute {
2764            pub tag: u32,
2765            pub length: u32,
2766            pub data: *mut c_void,
2767        }
2768
2769        /// `SecKeychainAttributeList` from `SecBase.h`.
2770        #[repr(C)]
2771        pub struct SecKeychainAttributeList {
2772            pub count: u32,
2773            pub attr: *mut SecKeychainAttribute,
2774        }
2775
2776        #[link(name = "CoreFoundation", kind = "framework")]
2777        unsafe extern "C" {
2778            pub fn CFRelease(value: *const c_void);
2779            pub fn CFArrayGetCount(array: CFArrayRef) -> CFIndex;
2780            pub fn CFArrayGetValueAtIndex(array: CFArrayRef, index: CFIndex) -> *const c_void;
2781        }
2782
2783        #[link(name = "Security", kind = "framework")]
2784        unsafe extern "C" {
2785            pub fn SecAccessCreate(
2786                descriptor: CFStringRef,
2787                trusted_list: CFArrayRef,
2788                access: *mut SecAccessRef,
2789            ) -> i32;
2790            pub fn SecAccessCopyACLList(access: SecAccessRef, list: *mut CFArrayRef) -> i32;
2791            pub fn SecACLCopyContents(
2792                entry: SecACLRef,
2793                applications: *mut CFArrayRef,
2794                description: *mut CFStringRef,
2795                prompt_selector: *mut u16,
2796            ) -> i32;
2797            pub fn SecACLSetContents(
2798                entry: SecACLRef,
2799                applications: CFArrayRef,
2800                description: CFStringRef,
2801                prompt_selector: u16,
2802            ) -> i32;
2803            pub fn SecKeychainGetStatus(keychain: SecKeychainRef, status: *mut u32) -> i32;
2804            pub fn SecKeychainItemCreateFromContent(
2805                item_class: u32,
2806                attributes: *mut SecKeychainAttributeList,
2807                length: u32,
2808                data: *const c_void,
2809                keychain: SecKeychainRef,
2810                initial_access: SecAccessRef,
2811                item: *mut SecKeychainItemRef,
2812            ) -> i32;
2813        }
2814    }
2815
2816    pub(super) fn load(site: &Site, _scope: SecretScope) -> io::Result<Option<Vec<u8>>> {
2817        let _no_ui = without_user_interaction();
2818        let Some(keychain) = open(site, false)? else {
2819            return Ok(None);
2820        };
2821
2822        match keychain.find_generic_password(service(), ITEM) {
2823            Ok((password, _item)) => Ok(Some(password.as_ref().to_vec())),
2824            Err(error) if is_absence(&error) => Ok(None),
2825            // The one refusal an operator can act on, and the one they cannot
2826            // guess. See `locked_out`.
2827            Err(error) if error.code() == ERR_SEC_AUTH_FAILED => {
2828                Err(locked_out(site, &keychain, &error))
2829            }
2830            Err(error) => Err(sec_error(&error)),
2831        }
2832    }
2833
2834    /// Why a keychain refuses a program its own credential, and what ends it.
2835    ///
2836    /// # Three states wear the same number
2837    ///
2838    /// `errSecAuthFailed` is what a keychain says when the item is there and
2839    /// the caller may not have it, and there are three quite different reasons
2840    /// for that. Telling an operator the wrong one sends them to a remedy that
2841    /// cannot work, so this reads which one it is instead of guessing:
2842    ///
2843    /// 1. **The account may not open the keychain at all.** The System Keychain
2844    ///    is decrypted with `/var/db/SystemKey`, which is `root`-only, so an
2845    ///    ordinary `runner-manager status` gets `-25293` on a perfectly healthy
2846    ///    credential. Nothing is broken and nothing needs repairing — the value
2847    ///    belongs to the account the boot-mode daemon runs as.
2848    /// 2. **The keychain is locked for this session.** A login keychain is
2849    ///    unlocked for the desktop session that logged in, and an SSH session
2850    ///    is not that session: measured on macOS 27, even the binary that wrote
2851    ///    an item reads `-25293` over SSH while the LaunchAgent beside it reads
2852    ///    the same item fine. Nothing about the item is wrong, and signing in
2853    ///    from there cannot work either.
2854    /// 3. **The item was written by a different build.** The login keychain
2855    ///    partitions every item to the code that created it, and an ad-hoc
2856    ///    signed binary's partition is its code hash, so every release is a
2857    ///    stranger to the item the previous one wrote. The daemon hands its
2858    ///    credential to the new build while it upgrades (see
2859    ///    `daemon adopt-credential`); this is what is left when that did not
2860    ///    happen, and one sign-in by the build that will read it ends it.
2861    fn locked_out(
2862        site: &Site,
2863        keychain: &SecKeychain,
2864        error: &security_framework::base::Error,
2865    ) -> io::Error {
2866        let code = error.code();
2867        match site.kind {
2868            Kind::System if !is_root() => io::Error::new(
2869                io::ErrorKind::PermissionDenied,
2870                format!(
2871                    "Security.framework returned {code} ({error}). The machine-scoped store is the \
2872                     System Keychain, and what decrypts it is /var/db/SystemKey, which only \
2873                     root may read -- so this is what a healthy credential looks like to an \
2874                     account that is not the one holding it. The boot-mode daemon runs as root \
2875                     and reads it. Nothing here needs repairing: run this command with sudo if \
2876                     you need the value itself, or install with `--start-at login` to keep the \
2877                     token in your own login keychain instead."
2878                ),
2879            ),
2880            Kind::Login if is_locked(keychain) => io::Error::new(
2881                io::ErrorKind::PermissionDenied,
2882                format!(
2883                    "Security.framework returned {code} ({error}). {} is locked for this \
2884                     session, so nothing in it can be read from here -- the service, which runs \
2885                     in the desktop session, may be reading it fine. An SSH session does not \
2886                     share the desktop session's unlocked login keychain. Run this from a \
2887                     Terminal in the Mac's desktop session, or unlock the keychain for this \
2888                     session first with `security unlock-keychain`.",
2889                    site.path.display()
2890                ),
2891            ),
2892            Kind::Login => io::Error::other(format!(
2893                "Security.framework returned {code} ({error}). The item is there and this keychain \
2894                 does not grant it to the program asking. macOS ties a login-keychain item to the \
2895                 exact build that wrote it -- an ad-hoc signed binary is known by the hash of its \
2896                 code -- so every release, and every rebuild, is a different program to it. The \
2897                 service hands its credential to the new build while it upgrades; an item written \
2898                 by a version that could not, or by a binary the service does not run, needs one \
2899                 more sign-in by the build that will read it: run {}.",
2900                crate::service::sign_in_instruction(StartMode::Login)
2901            )),
2902            // The System Keychain as root, and a rooted keychain: both grant
2903            // every application, so only an item from before that can refuse.
2904            Kind::System | Kind::Rooted => io::Error::other(format!(
2905                "Security.framework returned {code} ({error}). The item is there and this keychain \
2906                 does not grant it to the program asking. An earlier version granted the stored \
2907                 token to the single binary that wrote it, and an upgrade replaces that binary -- \
2908                 so an item written by one of those versions locks out every later copy, the \
2909                 daemon's included. Signing in once more rewrites it so that every application \
2910                 may read it: run `runner-manager auth login`, with sudo if this is the \
2911                 machine-scoped store."
2912            )),
2913        }
2914    }
2915
2916    /// Whether `keychain` is locked as far as this process can tell.
2917    ///
2918    /// Unlocking is per security session, so the answer is this session's: a
2919    /// login keychain reads as unlocked to a LaunchAgent and as locked to an SSH
2920    /// login of the same account at the same moment. A status this process
2921    /// cannot read is treated as unlocked, so that the explanation falls back to
2922    /// the one it gave before this was asked.
2923    fn is_locked(keychain: &SecKeychain) -> bool {
2924        use core_foundation::base::TCFType as _;
2925
2926        let mut status = 0_u32;
2927        // SAFETY: `keychain` is a live reference and `status` a valid
2928        // out-pointer that is only read when the call reports success.
2929        let result = unsafe {
2930            ffi::SecKeychainGetStatus(keychain.as_concrete_TypeRef().cast(), &raw mut status)
2931        };
2932        result == 0 && status & ffi::SEC_UNLOCK_STATE_STATUS == 0
2933    }
2934
2935    pub(super) fn delete(site: &Site) -> io::Result<bool> {
2936        let _no_ui = without_user_interaction();
2937        let Some(keychain) = open(site, false)? else {
2938            return Ok(false);
2939        };
2940
2941        match keychain.find_generic_password(service(), ITEM) {
2942            Ok((password, item)) => {
2943                // The password buffer holds the value. Drop it before anything
2944                // else happens, so it exists for as few instructions as it can.
2945                drop(password);
2946                // `SecKeychainItem::delete` consumes the item and discards the
2947                // OSStatus, so the only way to know it worked is to look. An
2948                // `auth logout` that reported success without removing anything
2949                // would be a lie told during a credential-disclosure response.
2950                item.delete();
2951                match keychain.find_generic_password(service(), ITEM) {
2952                    Err(error) if is_absence(&error) => Ok(true),
2953                    Ok(_) => Err(io::Error::other(
2954                        "the keychain item is still present after being deleted",
2955                    )),
2956                    Err(error) => Err(sec_error(&error)),
2957                }
2958            }
2959            // `auth logout` is one of the remedies this store hands out, and
2960            // before this it could not run against the state that most needs
2961            // it: `find` is refused by the ACL, so the item this program may
2962            // not read was also an item it could not remove. The query-based
2963            // delete never asks for the data. See `replace_unreadable`.
2964            Err(error) if error.code() == ERR_SEC_AUTH_FAILED => {
2965                replace_unreadable(&keychain)?;
2966                Ok(true)
2967            }
2968            Err(error) if is_absence(&error) => Ok(false),
2969            Err(error) => Err(sec_error(&error)),
2970        }
2971    }
2972}
2973
2974// ---------------------------------------------------------------------------
2975
2976#[cfg(all(unix, not(target_os = "macos")))]
2977mod sys {
2978    //! A `0600` file, and the systemd credential that takes precedence over it.
2979    //!
2980    //! `05-infrastructure.md` gives the Linux machine store as *"`0600` file
2981    //! plus systemd credentials"*, and the two halves are not alternatives.
2982    //!
2983    //! **The file** is what `auth login` writes and what `auth logout` removes.
2984    //! It holds the token at rest with no encryption, because there is no key
2985    //! to encrypt it with that a boot-time service could also reach — a key in
2986    //! a second file is not a key, it is an indirection. Its mode is therefore
2987    //! the entire access control, which is why it is `0600` inside a `0700`
2988    //! directory and why [`super::SecretStore::protection`] reports the mode
2989    //! rather than something more reassuring.
2990    //!
2991    //! **The systemd credential** is a *read* path, not a write path. A unit
2992    //! given `LoadCredentialEncrypted=` has the value decrypted into a private
2993    //! `ramfs` at `$CREDENTIALS_DIRECTORY`, mounted read-only and visible to
2994    //! no other unit, and nothing is ever written to the agent's own disk. An
2995    //! operator who has gone to that trouble should not also have to keep a
2996    //! plaintext copy beside it, so a credential — when one is present — wins.
2997    //!
2998    //! That precedence is the reason `store` and `delete` refuse rather than
2999    //! pretend. A `store` whose value would be shadowed by the credential on
3000    //! the very next `load` has not stored anything useful, and an `auth
3001    //! logout` that removed the file while the credential kept the daemon
3002    //! authenticated would be a false negative in the one procedure —
3003    //! `05-infrastructure.md`'s credential-disclosure response — where a false
3004    //! negative is worst. Both say what the operator has to change instead.
3005    //!
3006    //! # Machine scope is `/var/lib`, and that is the point
3007    //!
3008    //! `$XDG_DATA_HOME` resolves under `$HOME`, and an account that has never
3009    //! logged in has no `$HOME` to speak of; `$XDG_RUNTIME_DIR` is cleared when
3010    //! the session ends, which is exactly the event a boot-time service starts
3011    //! before. `/var/lib/runner-manager` belongs to no session and survives a
3012    //! reboot, which is the whole requirement.
3013
3014    use std::io::{self, Write as _};
3015    use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _, PermissionsExt as _};
3016    use std::path::{Path, PathBuf};
3017
3018    use super::{
3019        APPLICATION, CREDENTIALS_DIRECTORY, DIRECTORY, ITEM, ORGANIZATION, QUALIFIER,
3020        SYSTEMD_CREDENTIAL, SecretScope, TEMP_PREFIX, overwrite, sweep_temporaries,
3021        trim_trailing_ascii_whitespace,
3022    };
3023
3024    /// Where a machine-scoped store lives. Not under any home directory and
3025    /// not under any runtime directory; see the module documentation.
3026    /// The FHS directory for state a program owns; the product's own segment
3027    /// is [`APPLICATION`], not a second spelling of it.
3028    const MACHINE_PREFIX: &str = "/var/lib";
3029
3030    /// The file, and the systemd credential that outranks it.
3031    #[derive(Debug, Clone, PartialEq, Eq)]
3032    pub(super) struct Site {
3033        file: PathBuf,
3034        credential: Option<PathBuf>,
3035    }
3036
3037    impl Site {
3038        /// Points this site at a credentials directory the caller names.
3039        pub(super) fn with_credentials_directory(mut self, directory: &Path) -> Self {
3040            self.credential = Some(directory.join(SYSTEMD_CREDENTIAL));
3041            self
3042        }
3043
3044        /// The systemd credential this site would read, if it has one.
3045        pub(super) fn credential(&self) -> Option<&Path> {
3046            self.credential.as_deref()
3047        }
3048    }
3049
3050    /// `$CREDENTIALS_DIRECTORY/<name>`, when systemd set one.
3051    fn credential_from_environment() -> Option<PathBuf> {
3052        std::env::var_os(CREDENTIALS_DIRECTORY)
3053            .filter(|value| !value.is_empty())
3054            .map(|value| PathBuf::from(value).join(SYSTEMD_CREDENTIAL))
3055    }
3056
3057    pub(super) fn standard_site(scope: SecretScope) -> Result<Site, String> {
3058        match scope {
3059            SecretScope::Machine => Ok(Site {
3060                file: Path::new(MACHINE_PREFIX)
3061                    .join(APPLICATION)
3062                    .join(DIRECTORY)
3063                    .join(ITEM),
3064                credential: credential_from_environment(),
3065            }),
3066            SecretScope::User => {
3067                let root = directories::ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION)
3068                    .ok_or_else(|| {
3069                        "the operating system reports no home directory for this account, \
3070                             so the user-scoped store cannot be resolved. A service account \
3071                             configured with no profile normally hits this; use the \
3072                             machine-scoped store, which is what --start-at boot installs."
3073                            .to_string()
3074                    })?
3075                    .data_local_dir()
3076                    .to_path_buf();
3077                Ok(Site {
3078                    file: root.join(DIRECTORY).join(ITEM),
3079                    // A user-scoped store is deliberately not for a service, so
3080                    // it never consults a service's credentials.
3081                    credential: None,
3082                })
3083            }
3084        }
3085    }
3086
3087    pub(super) fn rooted_site(scope: SecretScope, root: &Path) -> Result<Site, String> {
3088        // A directory per scope. A standard site gets its separation from the
3089        // root -- `/var/lib` against `$XDG_DATA_HOME` -- and under one
3090        // caller-named root there is none, so two stores would share a file
3091        // and `auth logout` under either scope would purge both.
3092        Ok(Site {
3093            file: root.join(DIRECTORY).join(scope.as_str()).join(ITEM),
3094            credential: None,
3095        })
3096    }
3097
3098    pub(super) fn describe(site: &Site) -> String {
3099        match &site.credential {
3100            Some(credential) => format!(
3101                "0600 file at {} (superseded by the systemd credential at {})",
3102                site.file.display(),
3103                credential.display()
3104            ),
3105            None => format!("0600 file at {}", site.file.display()),
3106        }
3107    }
3108
3109    pub(super) fn guard(site: &Site) -> PathBuf {
3110        // The object that actually holds the value being read. When systemd
3111        // supplied one, that is its credential file in the unit's private
3112        // ramfs, and reporting the agent's own file instead would describe the
3113        // protection of something nothing reads.
3114        match &site.credential {
3115            Some(credential) if credential.exists() => credential.clone(),
3116            _ => site.file.clone(),
3117        }
3118    }
3119
3120    /// The refusal both `store` and `delete` owe an operator whose unit
3121    /// supplies a credential.
3122    fn shadowed_by_credential(site: &Site, verb: &str) -> Option<io::Error> {
3123        let credential = site.credential.as_ref()?;
3124        if !credential.exists() {
3125            return None;
3126        }
3127        Some(io::Error::other(format!(
3128            "this process was started with the systemd credential `{SYSTEMD_CREDENTIAL}`, which \
3129             takes precedence over {}. {verb} Change the credential in the unit that supplies \
3130             it -- `systemd-creds` and `LoadCredentialEncrypted=` -- and restart the service.",
3131            site.file.display()
3132        )))
3133    }
3134
3135    pub(super) fn ensure_writable(site: &Site) -> io::Result<()> {
3136        if let Some(error) = shadowed_by_credential(
3137            site,
3138            "A token written here would be shadowed by it on the very next load.",
3139        ) {
3140            return Err(error);
3141        }
3142        super::ensure_directory_writable(&site.file)
3143    }
3144
3145    pub(super) fn store(site: &Site, _scope: SecretScope, plaintext: &[u8]) -> io::Result<()> {
3146        if let Some(error) = shadowed_by_credential(
3147            site,
3148            "A token written here would be shadowed by it on the very next load, so nothing \
3149             was written.",
3150        ) {
3151            return Err(error);
3152        }
3153
3154        let directory = site
3155            .file
3156            .parent()
3157            .ok_or_else(|| io::Error::other("the store path has no parent directory"))?;
3158        // `0700` through `mkdir(2)` rather than a following `chmod`, exactly as
3159        // `crate::paths::AppPaths::create_all` argues; and then set explicitly,
3160        // because `mkdir` applies the umask and a directory that was already
3161        // there may predate this rule.
3162        std::fs::DirBuilder::new()
3163            .mode(0o700)
3164            .recursive(true)
3165            .create(directory)?;
3166        std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700))?;
3167
3168        let temporary = directory.join(format!("{TEMP_PREFIX}{}.tmp", uuid::Uuid::new_v4()));
3169        let written = (|| -> io::Result<()> {
3170            // `mode` is applied by `open(2)`, so the file never exists at any
3171            // other permissions; `create_new` makes it exclusive, so a path
3172            // pre-created by another account is an error rather than a file
3173            // this process writes a token into.
3174            let mut file = std::fs::OpenOptions::new()
3175                .write(true)
3176                .create_new(true)
3177                .mode(0o600)
3178                .open(&temporary)?;
3179            file.write_all(plaintext)?;
3180            file.flush()?;
3181            file.sync_all()
3182        })();
3183        if let Err(error) = written {
3184            let _ = std::fs::remove_file(&temporary);
3185            return Err(error);
3186        }
3187
3188        if let Err(error) = std::fs::rename(&temporary, &site.file) {
3189            let _ = std::fs::remove_file(&temporary);
3190            return Err(error);
3191        }
3192
3193        // Best effort: without it the rename can still be lost to a power cut
3194        // that the file's own `sync_all` survived. Not worth failing a store
3195        // that has already succeeded.
3196        if let Ok(handle) = std::fs::File::open(directory) {
3197            let _ = handle.sync_all();
3198        }
3199        Ok(())
3200    }
3201
3202    pub(super) fn load(site: &Site, _scope: SecretScope) -> io::Result<Option<Vec<u8>>> {
3203        if let Some(credential) = &site.credential {
3204            match std::fs::read(credential) {
3205                // Trimmed, and *only* here. The operator produced this file
3206                // with `systemd-creds encrypt`, and `echo`, `printf '%s\n'` and
3207                // every text editor leave a newline on the end. Byte for byte
3208                // that newline becomes part of the token and fails much later
3209                // inside an `Authorization` header, where it reads as a bad
3210                // credential rather than as a bad read.
3211                Ok(bytes) => return Ok(Some(trim_trailing_ascii_whitespace(bytes))),
3212                // No credential of that name: fall through to the file. A unit
3213                // can be given credentials without being given this one.
3214                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
3215                Err(error) => return Err(error),
3216            }
3217        }
3218
3219        // Not trimmed. `store` writes the value verbatim and with no newline,
3220        // so anything trailing here is corruption rather than formatting, and
3221        // silently repairing it would hide the one thing `Corrupt` exists to
3222        // report.
3223        match std::fs::read(&site.file) {
3224            Ok(bytes) => Ok(Some(bytes)),
3225            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
3226            Err(error) => Err(error),
3227        }
3228    }
3229
3230    pub(super) fn delete(site: &Site) -> io::Result<bool> {
3231        let shadowed = shadowed_by_credential(
3232            site,
3233            "The file below was removed, but the credential is still supplying a token and \
3234             this host is not purged.",
3235        );
3236
3237        let removed = overwrite_then_remove(&site.file)?;
3238        if let Some(directory) = site.file.parent() {
3239            sweep_temporaries(directory);
3240        }
3241
3242        match shadowed {
3243            Some(error) => Err(error),
3244            None => Ok(removed),
3245        }
3246    }
3247
3248    /// Zeroes the file's bytes, then unlinks it.
3249    ///
3250    /// The zero fill is [`overwrite`], which lives in the parent module so that
3251    /// it is a function a test can watch rather than a side effect inside a
3252    /// delete; its documentation carries the "best effort, not a claim"
3253    /// reasoning in full.
3254    ///
3255    /// Not being able to overwrite is not a reason to skip the unlink. The
3256    /// unlink is the part that matters, it may still succeed, and a store that
3257    /// refused to purge because it could not scrub first would fail `auth
3258    /// logout` in exactly the situation where finishing it matters most.
3259    fn overwrite_then_remove(path: &Path) -> io::Result<bool> {
3260        let _ = overwrite(path);
3261
3262        match std::fs::remove_file(path) {
3263            Ok(()) => Ok(true),
3264            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
3265            Err(error) => Err(error),
3266        }
3267    }
3268}
3269
3270// ---------------------------------------------------------------------------
3271// The Linux-only half of the surface
3272// ---------------------------------------------------------------------------
3273
3274/// Systemd credentials, which exist on no other platform.
3275///
3276/// A separate `impl` block rather than a cross-platform method that does
3277/// nothing on two of three operating systems: a caller that names
3278/// [`PlatformSecretStore::with_credentials_directory`] is writing code that
3279/// only means something on Linux, and it should not compile anywhere else.
3280#[cfg(all(unix, not(target_os = "macos")))]
3281impl PlatformSecretStore {
3282    /// Points this store at a systemd credentials directory the caller names,
3283    /// instead of the one `$CREDENTIALS_DIRECTORY` named.
3284    ///
3285    /// [`PlatformSecretStore::standard`] already reads the environment
3286    /// variable, so a daemon started by systemd needs none of this. It is here
3287    /// for the two callers that cannot use the environment: a test, which must
3288    /// not mutate a process-wide variable that every other test is reading at
3289    /// the same time, and `service install`, which resolves what the unit it is
3290    /// about to write will set.
3291    #[must_use]
3292    pub fn with_credentials_directory(mut self, directory: impl AsRef<Path>) -> Self {
3293        self.site = self.site.with_credentials_directory(directory.as_ref());
3294        self
3295    }
3296
3297    /// The systemd credential this store reads before it reads its own file,
3298    /// when it has one.
3299    #[must_use]
3300    pub fn credential_path(&self) -> Option<&Path> {
3301        self.site.credential()
3302    }
3303}
3304
3305// ---------------------------------------------------------------------------
3306// Tests
3307// ---------------------------------------------------------------------------
3308
3309#[cfg(test)]
3310mod tests {
3311    use super::*;
3312
3313    use tempfile::TempDir;
3314
3315    #[test]
3316    fn a_system_keychain_write_refusal_says_which_sign_in_to_run() {
3317        let explained =
3318            system_keychain_write_refused("SecKeychainItemCreateFromContent returned -61");
3319        assert!(explained.starts_with("SecKeychainItemCreateFromContent returned -61. "));
3320        assert!(explained.contains("only root may write it"), "{explained}");
3321        assert!(
3322            explained.contains("`runner-manager auth login --start-at login`"),
3323            "{explained}"
3324        );
3325        assert!(
3326            explained.contains("`sudo runner-manager auth login --start-at boot`"),
3327            "{explained}"
3328        );
3329    }
3330
3331    /// Shaped like a real `ghu_` user access token and unmistakably not one.
3332    ///
3333    /// **Assembled at run time from fragments on purpose.** The whole literal
3334    /// therefore appears in no source file and in no compiled artifact, which
3335    /// is what lets `tests/no_token_outside_the_store.rs` scan this repository
3336    /// — `target/` included — and treat any hit as a real leak rather than as
3337    /// its own test data. A `const` here, or a `concat!`, would be folded into
3338    /// the binary and would make that scan meaningless.
3339    fn fixture_token() -> SecretString {
3340        SecretString::from(format!("{}{}", "ghu_", "d2FixtureNotARealCredential000000"))
3341    }
3342
3343    /// A second, equally fake token, for the tests that need two.
3344    fn other_token() -> SecretString {
3345        SecretString::from(format!("{}{}", "ghu_", "d2SecondFixtureNotARealOne000000"))
3346    }
3347
3348    fn exposed(secret: &SecretString) -> String {
3349        secret.expose_secret().to_string()
3350    }
3351
3352    fn rooted(scope: SecretScope, root: &TempDir) -> PlatformSecretStore {
3353        PlatformSecretStore::rooted_at(scope, root.path()).expect("a rooted store resolves")
3354    }
3355
3356    fn stored(store: &PlatformSecretStore) -> String {
3357        exposed(
3358            &store
3359                .load()
3360                .expect("the store is readable")
3361                .expect("a value was stored"),
3362        )
3363    }
3364
3365    // -----------------------------------------------------------------------
3366    // The scope is a function of the start mode, in both directions
3367    // -----------------------------------------------------------------------
3368
3369    #[test]
3370    fn the_scope_is_decided_by_the_start_mode() {
3371        assert_eq!(
3372            SecretScope::for_start_mode(StartMode::Boot),
3373            SecretScope::Machine,
3374            "a service that starts at boot has no login session to read a user-scoped store from"
3375        );
3376        assert_eq!(
3377            SecretScope::for_start_mode(StartMode::Login),
3378            SecretScope::User
3379        );
3380    }
3381
3382    #[test]
3383    fn every_scope_names_the_start_mode_it_is_the_answer_to() {
3384        for scope in [SecretScope::Machine, SecretScope::User] {
3385            assert_eq!(SecretScope::for_start_mode(scope.start_mode()), scope);
3386        }
3387        for mode in [StartMode::Boot, StartMode::Login] {
3388            assert_eq!(SecretScope::for_start_mode(mode).start_mode(), mode);
3389        }
3390    }
3391
3392    #[test]
3393    fn for_start_mode_opens_the_store_that_start_mode_obliges() {
3394        for mode in [StartMode::Boot, StartMode::Login] {
3395            let Ok(store) = PlatformSecretStore::for_start_mode(mode) else {
3396                // Resolution needs a home directory or a %ProgramData%. A host
3397                // with neither cannot run the product at all, and saying so is
3398                // more useful than a panic that names neither.
3399                panic!("the standard store for --start-at {mode} could not be resolved");
3400            };
3401            assert_eq!(store.scope(), SecretScope::for_start_mode(mode));
3402        }
3403    }
3404
3405    // -----------------------------------------------------------------------
3406    // Store, load, delete -- both variants
3407    // -----------------------------------------------------------------------
3408
3409    #[test]
3410    fn a_machine_scoped_store_round_trips() {
3411        let root = TempDir::new().expect("a temporary directory");
3412        let store = rooted(SecretScope::Machine, &root);
3413        let token = fixture_token();
3414
3415        assert!(
3416            store
3417                .load()
3418                .expect("an empty store reads cleanly")
3419                .is_none(),
3420            "nothing is stored before the first `auth login`"
3421        );
3422
3423        store.store(&token).expect("the token is stored");
3424        assert_eq!(stored(&store), exposed(&token));
3425
3426        assert_eq!(
3427            store.delete().expect("the token is purged"),
3428            Removal::Removed
3429        );
3430        assert!(
3431            store
3432                .load()
3433                .expect("a purged store reads cleanly")
3434                .is_none()
3435        );
3436    }
3437
3438    #[test]
3439    fn a_user_scoped_store_round_trips() {
3440        let root = TempDir::new().expect("a temporary directory");
3441        let store = rooted(SecretScope::User, &root);
3442        let token = fixture_token();
3443
3444        store.store(&token).expect("the token is stored");
3445        assert_eq!(stored(&store), exposed(&token));
3446        assert_eq!(
3447            store.delete().expect("the token is purged"),
3448            Removal::Removed
3449        );
3450        assert!(
3451            store
3452                .load()
3453                .expect("a purged store reads cleanly")
3454                .is_none()
3455        );
3456    }
3457
3458    #[test]
3459    fn the_two_variants_do_not_share_a_value() {
3460        // Both under one root, because that is the arrangement most likely to
3461        // let them collide by accident.
3462        let root = TempDir::new().expect("a temporary directory");
3463        let machine = rooted(SecretScope::Machine, &root);
3464        let user = rooted(SecretScope::User, &root);
3465
3466        machine.store(&fixture_token()).expect("stored");
3467        user.store(&other_token()).expect("stored");
3468
3469        assert_eq!(stored(&machine), exposed(&fixture_token()));
3470        assert_eq!(stored(&user), exposed(&other_token()));
3471
3472        machine.delete().expect("purged");
3473        assert!(
3474            machine.load().expect("readable").is_none(),
3475            "the machine store is empty"
3476        );
3477        assert_eq!(
3478            stored(&user),
3479            exposed(&other_token()),
3480            "purging one variant must not purge the other; `auth logout` under \
3481             --start-at boot has no business touching a user-scoped store"
3482        );
3483    }
3484
3485    #[test]
3486    fn storing_again_replaces_the_value() {
3487        let root = TempDir::new().expect("a temporary directory");
3488        let store = rooted(SecretScope::Machine, &root);
3489
3490        store.store(&fixture_token()).expect("stored");
3491        store.store(&other_token()).expect("stored again");
3492
3493        assert_eq!(
3494            stored(&store),
3495            exposed(&other_token()),
3496            "a re-issued token replaces the old one rather than being refused"
3497        );
3498    }
3499
3500    // -----------------------------------------------------------------------
3501    // Absence is not an error, and delete is idempotent
3502    // -----------------------------------------------------------------------
3503
3504    #[test]
3505    fn a_load_after_delete_reports_absence_rather_than_a_failure() {
3506        let root = TempDir::new().expect("a temporary directory");
3507        let store = rooted(SecretScope::Machine, &root);
3508        store.store(&fixture_token()).expect("stored");
3509        store.delete().expect("purged");
3510
3511        // Phrased as a match rather than as `is_none`, because the property
3512        // under test is which *arm* the caller lands in. An `Err` here is the
3513        // failure the Definition of Done names: "a load after delete reports
3514        // absence rather than an error that a caller might mistake for a
3515        // transient failure".
3516        match store.load() {
3517            Ok(None) => {}
3518            Ok(Some(_)) => panic!("the value survived a delete"),
3519            Err(error) => panic!("absence was reported as a failure a caller may retry: {error}"),
3520        }
3521    }
3522
3523    #[test]
3524    fn deleting_what_is_not_there_is_success_and_says_so() {
3525        let root = TempDir::new().expect("a temporary directory");
3526        let store = rooted(SecretScope::Machine, &root);
3527
3528        assert_eq!(
3529            store
3530                .delete()
3531                .expect("purging an empty store is not a failure"),
3532            Removal::AlreadyAbsent,
3533            "`auth logout` is run on every host during a credential-disclosure \
3534             response, including the ones that were never logged in"
3535        );
3536
3537        store.store(&fixture_token()).expect("stored");
3538        assert_eq!(store.delete().expect("purged"), Removal::Removed);
3539        assert_eq!(
3540            store.delete().expect("purged again"),
3541            Removal::AlreadyAbsent
3542        );
3543    }
3544
3545    /// "Delete leaves no recoverable remnant" — stated as the same property on
3546    /// all three platforms, and checked through whatever *carries* the value on
3547    /// each.
3548    ///
3549    /// The distinction is one CI caught rather than one this test was written
3550    /// with. A file-backed store keeps the value *in* the guard, so the remnant
3551    /// question is "is the file gone". A keychain-backed store keeps it in an
3552    /// item *inside* the guard, and the guard is a database that legitimately
3553    /// outlives every item it ever held — asserting the keychain disappears
3554    /// would be asserting that `auth logout` deletes the operator's login
3555    /// keychain, which would be a bug rather than a purge.
3556    ///
3557    /// So the two things asserted everywhere are the two that mean the same
3558    /// thing everywhere: nothing readable is left, and no byte of the value is
3559    /// lying in whatever does remain.
3560    #[test]
3561    fn deleting_leaves_no_recoverable_remnant() {
3562        let root = TempDir::new().expect("a temporary directory");
3563        let store = rooted(SecretScope::Machine, &root);
3564        store.store(&fixture_token()).expect("stored");
3565
3566        let guard = store.guard();
3567        assert!(guard.exists(), "something was written");
3568        store.delete().expect("purged");
3569
3570        assert!(
3571            store
3572                .load()
3573                .expect("a purged store reads cleanly")
3574                .is_none(),
3575            "the value is still readable through the store's own API"
3576        );
3577        if let Ok(bytes) = std::fs::read(&guard) {
3578            let token = exposed(&fixture_token());
3579            assert!(
3580                !bytes
3581                    .windows(token.len())
3582                    .any(|window| window == token.as_bytes()),
3583                "the value is still lying in {} after a purge",
3584                guard.display()
3585            );
3586        }
3587
3588        // A file-backed store additionally leaves nothing at all: neither the
3589        // named file, nor a `user-access-token.<uuid>.tmp` from a write that was
3590        // interrupted, which is the token on disk under a name nobody looks at.
3591        #[cfg(not(target_os = "macos"))]
3592        {
3593            assert!(
3594                !guard.exists(),
3595                "the stored value is still at {}",
3596                guard.display()
3597            );
3598            if let Some(directory) = guard.parent()
3599                && let Ok(entries) = std::fs::read_dir(directory)
3600            {
3601                let remnants: Vec<_> = entries
3602                    .flatten()
3603                    .map(|entry| entry.file_name().to_string_lossy().into_owned())
3604                    .filter(|name| name.starts_with("user-access-token"))
3605                    .collect();
3606                assert!(
3607                    remnants.is_empty(),
3608                    "a purge left {remnants:?} in {}",
3609                    directory.display()
3610                );
3611            }
3612        }
3613    }
3614
3615    /// The negative control for the sweep above: a temporary file from an
3616    /// interrupted write is a token on disk, and a purge that only removed the
3617    /// file it was told about would leave it there.
3618    #[cfg(not(target_os = "macos"))]
3619    #[test]
3620    fn a_purge_sweeps_a_temporary_left_by_an_interrupted_write() {
3621        let root = TempDir::new().expect("a temporary directory");
3622        let store = rooted(SecretScope::Machine, &root);
3623        store.store(&fixture_token()).expect("stored");
3624
3625        let directory = store
3626            .guard()
3627            .parent()
3628            .expect("the guard has a directory")
3629            .to_path_buf();
3630        let abandoned = directory.join(format!("{TEMP_PREFIX}00000000-dead-beef.tmp"));
3631        std::fs::write(&abandoned, exposed(&fixture_token())).expect("planted");
3632
3633        store.delete().expect("purged");
3634        assert!(
3635            !abandoned.exists(),
3636            "a purge left {} behind, which is the token on disk under a name nobody looks at",
3637            abandoned.display()
3638        );
3639    }
3640
3641    // -----------------------------------------------------------------------
3642    // What is not a value
3643    // -----------------------------------------------------------------------
3644
3645    #[test]
3646    fn an_empty_value_is_refused_rather_than_stored() {
3647        let root = TempDir::new().expect("a temporary directory");
3648        let store = rooted(SecretScope::Machine, &root);
3649
3650        let error = store
3651            .store(&SecretString::from(String::new()))
3652            .expect_err("an empty value is not a token");
3653        assert!(matches!(error, SecretStoreError::Store { .. }));
3654        assert!(
3655            store.load().expect("readable").is_none(),
3656            "a refused store wrote nothing"
3657        );
3658    }
3659
3660    #[test]
3661    fn bytes_that_are_not_a_token_are_reported_as_corrupt_and_not_as_absence() {
3662        let root = TempDir::new().expect("a temporary directory");
3663        let store = rooted(SecretScope::Machine, &root);
3664
3665        // `decode` is the half of the read path that is the same on all three
3666        // operating systems, and it is reachable here because these tests are a
3667        // child of the module. The platform half -- a DPAPI blob this key
3668        // cannot unprotect, a file of raw bytes -- is exercised per OS below.
3669        let error = store
3670            .decode(vec![0xff, 0xfe, 0xfd])
3671            .expect_err("invalid UTF-8 is not a token");
3672        assert!(
3673            matches!(error, SecretStoreError::Corrupt { .. }),
3674            "got {error:?}"
3675        );
3676        assert!(
3677            store.decode(Vec::new()).is_err(),
3678            "an empty value read back is a truncated write, not an absence"
3679        );
3680    }
3681
3682    #[test]
3683    fn no_error_this_module_produces_repeats_the_value() {
3684        let root = TempDir::new().expect("a temporary directory");
3685        let store = rooted(SecretScope::Machine, &root);
3686        let token = exposed(&fixture_token());
3687
3688        // Every variant that can be constructed without a real platform
3689        // failure, rendered both ways a caller can render one.
3690        let errors = vec![
3691            SecretStoreError::Resolve {
3692                scope: SecretScope::Machine,
3693                reason: "no %ProgramData%".to_string(),
3694            },
3695            store
3696                .store(&SecretString::from(String::new()))
3697                .expect_err("empty is refused"),
3698            store
3699                .decode(token.clone().into_bytes())
3700                .err()
3701                .unwrap_or_else(|| store.corrupt("a placeholder")),
3702            store.corrupt("the bytes there are not valid UTF-8"),
3703            store
3704                .protection()
3705                .err()
3706                .unwrap_or_else(|| SecretStoreError::Inspect {
3707                    scope: SecretScope::Machine,
3708                    guard: store.guard(),
3709                    source: crate::process::permissions_summary(std::path::Path::new(
3710                        "a-path-that-is-not-there",
3711                    ))
3712                    .expect_err("a missing path cannot be inspected"),
3713                }),
3714        ];
3715
3716        for error in errors {
3717            let rendered = format!("{error} / {error:?}");
3718            assert!(
3719                !rendered.contains(&token),
3720                "an error rendered the token: {rendered}"
3721            );
3722        }
3723    }
3724
3725    // -----------------------------------------------------------------------
3726    // Who can read it
3727    // -----------------------------------------------------------------------
3728
3729    #[test]
3730    fn a_stored_value_is_not_readable_by_an_unprivileged_local_user() {
3731        for scope in [SecretScope::Machine, SecretScope::User] {
3732            let root = TempDir::new().expect("a temporary directory");
3733            let store = rooted(scope, &root);
3734            store.store(&fixture_token()).expect("stored");
3735
3736            let protection = store.protection().expect("the guard is inspectable");
3737            assert!(
3738                !protection.readable_by_other_local_users(),
3739                "the {scope}-scoped store is readable by other local users: {protection}"
3740            );
3741        }
3742    }
3743
3744    /// The negative control for the assertion above.
3745    ///
3746    /// A check that only ever returns "not readable" proves nothing, and this
3747    /// one is the whole of the evidence for a `security_critical` Definition of
3748    /// Done item. So: take a store that has just passed, loosen the one thing
3749    /// that was protecting it, and require the same call to report the leak.
3750    #[test]
3751    fn the_readability_check_reports_a_guard_that_was_loosened() {
3752        let root = TempDir::new().expect("a temporary directory");
3753        let store = rooted(SecretScope::Machine, &root);
3754        store.store(&fixture_token()).expect("stored");
3755        let guard = store.guard();
3756
3757        assert!(
3758            !store
3759                .protection()
3760                .expect("inspectable")
3761                .readable_by_other_local_users(),
3762            "the control starts from a store that passes"
3763        );
3764
3765        #[cfg(unix)]
3766        {
3767            use std::os::unix::fs::PermissionsExt as _;
3768            std::fs::set_permissions(&guard, std::fs::Permissions::from_mode(0o644))
3769                .expect("the guard can be loosened");
3770        }
3771        #[cfg(windows)]
3772        {
3773            // Replaced with a file created the ordinary way, which inherits the
3774            // temporary directory's DACL instead of carrying a protected one of
3775            // its own. That is exactly the mistake the backend exists to avoid:
3776            // an inherited DACL is whatever the parent grants, and under
3777            // `%ProgramData%` the parent grants Builtin Users read.
3778            std::fs::remove_file(&guard).expect("the guard can be replaced");
3779            std::fs::write(&guard, b"not a protected file").expect("written");
3780        }
3781
3782        let protection = store
3783            .protection()
3784            .expect("the loosened guard is still inspectable");
3785        assert!(
3786            protection.readable_by_other_local_users(),
3787            "a loosened guard was reported as safe, so the assertion above proves nothing: \
3788             {protection}"
3789        );
3790    }
3791
3792    #[test]
3793    fn the_protection_names_the_object_it_inspected() {
3794        let root = TempDir::new().expect("a temporary directory");
3795        let store = rooted(SecretScope::Machine, &root);
3796        store.store(&fixture_token()).expect("stored");
3797
3798        let protection = store.protection().expect("inspectable");
3799        assert_eq!(protection.guard(), store.guard());
3800        assert!(
3801            !protection.description().is_empty(),
3802            "a protection with no description is not a diagnosis"
3803        );
3804    }
3805
3806    // -----------------------------------------------------------------------
3807    // What `host show` and `service status` print
3808    // -----------------------------------------------------------------------
3809
3810    #[test]
3811    fn the_active_store_is_reported_and_agrees_with_the_start_mode() {
3812        let root = TempDir::new().expect("a temporary directory");
3813
3814        for mode in [StartMode::Boot, StartMode::Login] {
3815            let store = rooted(SecretScope::for_start_mode(mode), &root);
3816            let active = ActiveStore::of(&store, mode);
3817
3818            assert_eq!(active.scope(), SecretScope::for_start_mode(mode));
3819            assert_eq!(active.start_mode(), mode);
3820            assert!(active.agrees_with_start_mode());
3821
3822            let rendered = active.to_string();
3823            assert!(
3824                rendered.contains(active.scope().as_str()),
3825                "`host show` must name the variant in use: {rendered}"
3826            );
3827            assert!(
3828                rendered.contains(&mode.to_string()),
3829                "`service status` must name the start mode: {rendered}"
3830            );
3831            assert!(
3832                !rendered.contains("MISMATCH"),
3833                "a matching pair must not be reported as a mismatch: {rendered}"
3834            );
3835        }
3836    }
3837
3838    #[test]
3839    fn a_store_that_disagrees_with_the_start_mode_says_so() {
3840        let root = TempDir::new().expect("a temporary directory");
3841        // The failure this exists to catch: a service switched to `--start-at
3842        // boot` while the token is still in the operator's user-scoped store.
3843        // The daemon starts, finds nothing, and the only clue is here.
3844        let store = rooted(SecretScope::User, &root);
3845        let active = ActiveStore::of(&store, StartMode::Boot);
3846
3847        assert!(!active.agrees_with_start_mode());
3848        let rendered = active.to_string();
3849        assert!(rendered.contains("MISMATCH"), "{rendered}");
3850        assert!(rendered.contains("machine"), "{rendered}");
3851    }
3852
3853    #[test]
3854    fn the_reported_location_is_not_the_value() {
3855        let root = TempDir::new().expect("a temporary directory");
3856        let store = rooted(SecretScope::Machine, &root);
3857        store.store(&fixture_token()).expect("stored");
3858
3859        let token = exposed(&fixture_token());
3860        let active = ActiveStore::of(&store, StartMode::Boot);
3861        for rendered in [
3862            store.location(),
3863            active.to_string(),
3864            format!("{store:?}"),
3865            format!("{active:?}"),
3866            store.protection().expect("inspectable").to_string(),
3867        ] {
3868            assert!(
3869                !rendered.contains(&token),
3870                "a report carried the value: {rendered}"
3871            );
3872        }
3873    }
3874
3875    // -----------------------------------------------------------------------
3876    // Where the standard stores live
3877    // -----------------------------------------------------------------------
3878
3879    /// The property that makes a machine-scoped store readable after a reboot,
3880    /// stated as a location rather than as a hope.
3881    ///
3882    /// Every per-user directory on all three operating systems hangs off the
3883    /// account's home directory, and an account that has never logged in does
3884    /// not have one mounted. A machine store under `$HOME` would work on the
3885    /// developer's laptop and fail on the first boot of the machine it was
3886    /// installed on, which is the failure this assertion exists to make
3887    /// impossible to introduce.
3888    #[test]
3889    fn the_machine_store_is_not_under_the_home_directory() {
3890        let store = PlatformSecretStore::standard(SecretScope::Machine)
3891            .expect("the machine store resolves");
3892        let guard = store.guard();
3893
3894        let Some(base) = directories::BaseDirs::new() else {
3895            panic!("this account has no home directory, so the assertion cannot be made");
3896        };
3897        assert!(
3898            !guard.starts_with(base.home_dir()),
3899            "the machine store at {} is under the home directory {}",
3900            guard.display(),
3901            base.home_dir().display()
3902        );
3903    }
3904
3905    #[test]
3906    fn the_user_store_is_under_the_home_directory() {
3907        // The mirror of the assertion above, and the reason `--start-at login`
3908        // means what it says: this store is gone when the operator logs out.
3909        let store =
3910            PlatformSecretStore::standard(SecretScope::User).expect("the user store resolves");
3911        let Some(base) = directories::BaseDirs::new() else {
3912            panic!("this account has no home directory, so the assertion cannot be made");
3913        };
3914        assert!(
3915            store.guard().starts_with(base.home_dir()),
3916            "the user store at {} is not under the home directory {}",
3917            store.guard().display(),
3918            base.home_dir().display()
3919        );
3920    }
3921
3922    #[test]
3923    fn the_standard_locations_are_the_documented_ones() {
3924        let machine = PlatformSecretStore::standard(SecretScope::Machine).expect("resolves");
3925        let user = PlatformSecretStore::standard(SecretScope::User).expect("resolves");
3926
3927        #[cfg(windows)]
3928        {
3929            let program_data = std::path::PathBuf::from(
3930                std::env::var_os("ProgramData").expect("Windows sets ProgramData"),
3931            );
3932            assert_eq!(
3933                machine.guard(),
3934                program_data
3935                    .join("IvanMurzak")
3936                    .join("runner-manager")
3937                    .join("secrets")
3938                    .join("user-access-token.dpapi")
3939            );
3940            assert!(user.location().contains("DPAPI"), "{}", user.location());
3941        }
3942        #[cfg(target_os = "macos")]
3943        {
3944            assert_eq!(
3945                machine.guard(),
3946                std::path::Path::new("/var/db/SystemKey"),
3947                "the System Keychain's protection is its root-only master key, \
3948                 not the world-readable database beside it"
3949            );
3950            assert!(
3951                machine
3952                    .location()
3953                    .contains("/Library/Keychains/System.keychain"),
3954                "{}",
3955                machine.location()
3956            );
3957            assert!(
3958                user.location().contains("login.keychain"),
3959                "{}",
3960                user.location()
3961            );
3962        }
3963        #[cfg(all(unix, not(target_os = "macos")))]
3964        {
3965            assert_eq!(
3966                machine.guard(),
3967                std::path::Path::new("/var/lib/runner-manager/secrets/user-access-token")
3968            );
3969            assert!(user.location().contains("0600"), "{}", user.location());
3970        }
3971    }
3972
3973    /// The independent oracle for the identity `secrets.rs` now *borrows* from
3974    /// [`crate::paths`] rather than spelling out.
3975    ///
3976    /// Borrowing removes the drift the reviewer named — two files can no longer
3977    /// disagree — but it moves the risk rather than removing it: a change in
3978    /// `paths.rs` now silently moves the secret store as well as the four
3979    /// application-data directories, and a token that has moved reads as simply
3980    /// absent. The literals below are the only place in this module that is
3981    /// *not* derived from those constants, which is exactly what makes them
3982    /// able to catch such a change.
3983    #[test]
3984    fn the_product_identity_is_the_one_paths_defines() {
3985        assert_eq!(QUALIFIER, "io.github");
3986        assert_eq!(ORGANIZATION, "IvanMurzak");
3987        assert_eq!(APPLICATION, "runner-manager");
3988
3989        #[cfg(target_os = "macos")]
3990        assert_eq!(
3991            sys::service(),
3992            "io.github.IvanMurzak.runner-manager",
3993            "the keychain service names the product; a change here moves every \
3994             stored item and the token reads as absent"
3995        );
3996    }
3997
3998    /// Finding 2's replacement, and the whole of what the zero fill claims.
3999    ///
4000    /// The test it replaces stored, deleted, and asserted the file was gone —
4001    /// which is what *removing* it proves too, so
4002    /// `overwrite`'s `write_all` could have been deleted outright and the suite
4003    /// would have stayed green. This one observes the fill itself: it reads the
4004    /// bytes back **before** anything unlinks them, so the subject of the test
4005    /// cannot be removed without it failing.
4006    ///
4007    /// Not `cfg`-gated to Linux, although Linux is the platform whose stored
4008    /// value is plaintext, because the mechanism is shared and a test that only
4009    /// a CI leg can run is a test its author never watched fail.
4010    #[cfg(not(target_os = "macos"))]
4011    #[test]
4012    fn overwrite_zeroes_every_byte_and_leaves_the_file_there() {
4013        let root = TempDir::new().expect("a temporary directory");
4014        let path = root.path().join("value");
4015        let token = exposed(&fixture_token());
4016        std::fs::write(&path, &token).expect("written");
4017
4018        assert!(overwrite(&path).expect("overwritten"), "the file was there");
4019
4020        let after = std::fs::read(&path).expect("still there, so the fill is observable");
4021        assert_eq!(
4022            after.len(),
4023            token.len(),
4024            "the overwrite must not truncate; a shorter file leaves the tail of the old \
4025             value in the block"
4026        );
4027        assert!(
4028            after.iter().all(|byte| *byte == 0),
4029            "the file still holds non-zero bytes after an overwrite: {after:?}"
4030        );
4031        assert!(
4032            !after
4033                .windows(token.len())
4034                .any(|window| window == token.as_bytes()),
4035            "the value survived the overwrite"
4036        );
4037    }
4038
4039    #[cfg(not(target_os = "macos"))]
4040    #[test]
4041    fn overwriting_what_is_not_there_is_not_a_failure() {
4042        let root = TempDir::new().expect("a temporary directory");
4043        assert!(
4044            !overwrite(&root.path().join("absent")).expect("absence is not an error"),
4045            "a store that was never written has nothing to scrub"
4046        );
4047    }
4048
4049    /// Finding 6's pure half, run on every leg.
4050    ///
4051    /// The systemd credential path that uses this is Linux-only, so its
4052    /// end-to-end test is too; the decision the trim makes is not
4053    /// platform-specific, and testing it here is what let this be watched
4054    /// failing on the machine it was written on.
4055    #[cfg(not(target_os = "macos"))]
4056    #[test]
4057    fn a_trailing_newline_is_not_part_of_the_token() {
4058        let token = exposed(&fixture_token());
4059
4060        for suffix in ["\n", "\r\n", "\n\n", " ", "\t\n", ""] {
4061            let raw = format!("{token}{suffix}").into_bytes();
4062            assert_eq!(
4063                trim_trailing_ascii_whitespace(raw),
4064                token.clone().into_bytes(),
4065                "a credential written with {suffix:?} on the end yielded a different token"
4066            );
4067        }
4068
4069        // Interior whitespace is left alone. A token has none, and silently
4070        // rewriting the middle of a value would be a worse bug than the one
4071        // this fixes.
4072        let interior = b"gh u_x\n".to_vec();
4073        assert_eq!(trim_trailing_ascii_whitespace(interior), b"gh u_x".to_vec());
4074
4075        // A value that is nothing but whitespace becomes empty, which `decode`
4076        // reports as `Corrupt` rather than as absence.
4077        assert!(trim_trailing_ascii_whitespace(b"\n\n".to_vec()).is_empty());
4078    }
4079
4080    // -----------------------------------------------------------------------
4081    // Windows
4082    // -----------------------------------------------------------------------
4083
4084    #[cfg(windows)]
4085    mod windows {
4086        use super::*;
4087        use crate::secrets::sys::{merge_grants, replacement_sddl, sddl, trustees};
4088
4089        /// How much a [`DeniedReplace`] takes away.
4090        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
4091        enum AlsoDeny {
4092            /// Only what denies a *replacing rename*. Creating a file in the
4093            /// directory still works, exactly as it does for operator B, so the
4094            /// temporary is still written if the store gets that far.
4095            Nothing,
4096            /// Additionally `FILE_ADD_FILE`, so nothing can be created in the
4097            /// directory at all. What makes
4098            /// [`the_refusal_is_made_before_anything_is_written`] able to tell
4099            /// a pre-write refusal from a post-write one.
4100            Creation,
4101        }
4102
4103        /// Reproduces what a second non-administrative operator meets, without
4104        /// needing a second account, and puts the rights back on **every** path
4105        /// out — including an unwind.
4106        ///
4107        /// **Both denies are required, and finding that out was the point of
4108        /// running this against the un-fixed code.** Windows grants delete two
4109        /// ways — `DELETE` on the object, or `FILE_DELETE_CHILD` on its parent
4110        /// — and `MOVEFILE_REPLACE_EXISTING`, which is what `std::fs::rename`
4111        /// is on Windows, takes either. Operator B has neither: the file's DACL
4112        /// names `SY`, `BA` and `OW`, and a stock `%ProgramData%` grants
4113        /// `BUILTIN\Users` no `DC`. The test process, by contrast, *owns* its
4114        /// temporary directory and so does hold `FILE_DELETE_CHILD` — with only
4115        /// the file denied, the rename went through and the simulation
4116        /// reproduced nothing while looking exactly right.
4117        ///
4118        /// # Why the restore is a `Drop` and not a line at the end of the body
4119        ///
4120        /// Because a failing assertion unwinds past that line, and what it
4121        /// leaves behind is not tidy: `Everyone:(D)` denies deleting the guard
4122        /// and `Everyone:(DC)` denies deleting the directory's children, so
4123        /// `TempDir::drop` — which ignores removal errors — cannot clean up. A
4124        /// DPAPI blob of the fixture token would sit in `%TEMP%` under an ACL
4125        /// ordinary cleanup cannot remove, on exactly the run where somebody is
4126        /// already debugging a failure.
4127        struct DeniedReplace {
4128            file: std::path::PathBuf,
4129            directory: std::path::PathBuf,
4130            restored: bool,
4131        }
4132
4133        impl DeniedReplace {
4134            fn new(file: &std::path::Path, directory: &std::path::Path, also: AlsoDeny) -> Self {
4135                let denied = Self {
4136                    file: file.to_path_buf(),
4137                    directory: directory.to_path_buf(),
4138                    restored: false,
4139                };
4140                // Constructed first, so that a failure in any `icacls` below
4141                // unwinds through this guard's `Drop` and undoes the ones that
4142                // did take effect.
4143                icacls(&[&denied.file.display().to_string(), "/deny", "*S-1-1-0:(D)"]);
4144                icacls(&[
4145                    &denied.directory.display().to_string(),
4146                    "/deny",
4147                    "*S-1-1-0:(DC)",
4148                ]);
4149                if also == AlsoDeny::Creation {
4150                    icacls(&[
4151                        &denied.directory.display().to_string(),
4152                        "/deny",
4153                        "*S-1-1-0:(WD)",
4154                    ]);
4155                }
4156                denied
4157            }
4158
4159            /// Puts the rights back now, for a test that needs to go on and
4160            /// observe the store working again. Idempotent, so [`Drop`] running
4161            /// afterwards is a no-op.
4162            fn restore(&mut self) {
4163                if self.restored {
4164                    return;
4165                }
4166                self.restored = true;
4167                // `/remove:d` drops every deny ACE this SID has on the object,
4168                // so one call per object undoes all of the above.
4169                //
4170                // Deliberately not asserting: this runs on the unwind path,
4171                // where a panic would abort the process and replace a readable
4172                // test failure with one nobody can diagnose.
4173                for path in [&self.file, &self.directory] {
4174                    let arguments = [
4175                        path.display().to_string(),
4176                        "/remove:d".into(),
4177                        "*S-1-1-0".into(),
4178                    ];
4179                    match run_icacls(&arguments.each_ref().map(String::as_str)) {
4180                        Ok(output) if output.status.success() => {}
4181                        other => eprintln!(
4182                            "could not restore the ACL on {}: {other:?}; {} may survive in \
4183                             the temporary directory",
4184                            path.display(),
4185                            path.display()
4186                        ),
4187                    }
4188                }
4189            }
4190        }
4191
4192        impl Drop for DeniedReplace {
4193            fn drop(&mut self) {
4194                self.restore();
4195            }
4196        }
4197
4198        fn run_icacls(arguments: &[&str]) -> std::io::Result<std::process::Output> {
4199            std::process::Command::new("icacls.exe")
4200                .args(arguments)
4201                .output()
4202        }
4203
4204        fn icacls(arguments: &[&str]) {
4205            let output = run_icacls(arguments).expect("icacls is present on every Windows");
4206            assert!(
4207                output.status.success(),
4208                "icacls {arguments:?} failed: {}{}",
4209                String::from_utf8_lossy(&output.stdout),
4210                String::from_utf8_lossy(&output.stderr)
4211            );
4212        }
4213
4214        /// The DACL string alone, with no OS in the way.
4215        #[test]
4216        fn a_replacement_carries_the_previous_owner_and_a_first_write_does_not() {
4217            for scope in [SecretScope::Machine, SecretScope::User] {
4218                assert_eq!(
4219                    replacement_sddl(scope, &[]),
4220                    sddl(scope),
4221                    "a first write has nothing to carry, so it gets the constant DACL and \
4222                     nothing else"
4223                );
4224                assert_eq!(
4225                    replacement_sddl(scope, &["S-1-5-21-1-2-3-1001".to_owned()]),
4226                    format!("{}(A;;FA;;;S-1-5-21-1-2-3-1001)", sddl(scope)),
4227                    "one carried grant is one appended ACE"
4228                );
4229            }
4230        }
4231
4232        /// The second renewal, which is where carrying only the owner undoes
4233        /// itself.
4234        ///
4235        /// After one renewal the file is owned by `LocalSystem` and the
4236        /// operator is named by the ACE that renewal carried. A mechanism that
4237        /// rebuilds the DACL from the owner alone reads `S-1-5-18` here, drops
4238        /// the operator, and locks them out again eight hours after the fix
4239        /// appeared to work.
4240        ///
4241        /// Driving `store` cannot reach this: a test process is one account and
4242        /// cannot make the owner move. So the rule is tested where it lives.
4243        #[test]
4244        fn a_grant_survives_a_renewal_by_an_account_that_is_not_the_previous_owner() {
4245            let operator = "S-1-5-21-9-8-7-1001";
4246            let after_one_renewal = format!("{}(A;;FA;;;{operator})", sddl(SecretScope::Machine));
4247
4248            assert_eq!(
4249                merge_grants(Some("S-1-5-18"), &after_one_renewal),
4250                vec![operator.to_owned()],
4251                "the owner is now LocalSystem, which `SY` already grants; what must survive is \
4252                 the operator named in the DACL the previous renewal wrote"
4253            );
4254
4255            assert_eq!(
4256                merge_grants(Some(operator), sddl(SecretScope::Machine)),
4257                vec![operator.to_owned()],
4258                "and the first renewal, where the operator is still the owner and the DACL \
4259                 names nobody, carries the same one account"
4260            );
4261
4262            assert!(
4263                merge_grants(None, "").is_empty(),
4264                "a first write has no owner and no DACL to read, and carries nothing"
4265            );
4266
4267            assert_eq!(
4268                merge_grants(Some(operator), &after_one_renewal),
4269                vec![operator.to_owned()],
4270                "an account reachable both ways is named once, so the DACL cannot grow by an \
4271                 ACE per write"
4272            );
4273
4274            // The CI runner's own case, which an ordinary developer machine
4275            // does not reach: Windows renders a well-known account's ACE by
4276            // alias, so what the previous renewal wrote as `S-1-5-21-...-500`
4277            // reads back as `LA`. Carrying only trustees spelled `S-1-` drops
4278            // it and the operator is locked out one renewal later.
4279            let after_a_renewal_for_a_builtin =
4280                format!("{}(A;;FA;;;LA)", sddl(SecretScope::Machine));
4281            assert_eq!(
4282                merge_grants(Some("S-1-5-18"), &after_a_renewal_for_a_builtin),
4283                vec!["LA".to_owned()],
4284                "a grant is a grant whichever spelling the DACL reads back in"
4285            );
4286
4287            assert!(
4288                merge_grants(None, sddl(SecretScope::Machine)).is_empty(),
4289                "and the constant DACL's own aliases are not carried, or every write would \
4290                 double the base"
4291            );
4292        }
4293
4294        /// Every trustee, because after the first replacement one of them is
4295        /// where the operator's access lives — and Windows chooses the
4296        /// spelling, not this code.
4297        #[test]
4298        fn the_trustee_scan_reads_both_spellings() {
4299            assert_eq!(
4300                trustees("D:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;OW)(A;;FA;;;S-1-5-21-9-8-7-1001)"),
4301                vec![
4302                    "SY".to_owned(),
4303                    "BA".to_owned(),
4304                    "OW".to_owned(),
4305                    "S-1-5-21-9-8-7-1001".to_owned(),
4306                ],
4307                "the scan reads trustees; deciding which of them are already granted is \
4308                 `merge_grants`'s job and not this one's"
4309            );
4310            assert_eq!(
4311                trustees("D:P(A;;FA;;;LA)"),
4312                vec!["LA".to_owned()],
4313                "an alias is a trustee too, which is what CI's built-in Administrator account \
4314                 reads back as"
4315            );
4316        }
4317
4318        /// The lockout, as a test: renewal writes as a different account, and
4319        /// the operator who signed in must still be able to read the store.
4320        ///
4321        /// # What this can and cannot reproduce
4322        ///
4323        /// A test process is one account, so it cannot *become* `LocalSystem`
4324        /// and take ownership the way a daemon does. What it pins is the
4325        /// mechanism that makes that survivable: after a replacement, the
4326        /// previous owner is named in the DACL by SID rather than left to
4327        /// `OW` — so when the owner does move, the grant does not move with it.
4328        ///
4329        /// Un-fixed, the second description equals the first: `OW` and nothing
4330        /// else, which is exactly the state that locked an operator out of
4331        /// their own credential on 2026-08-29.
4332        ///
4333        /// # What it asserts, and why not the SID
4334        ///
4335        /// That the very first write already names somebody beyond the
4336        /// constant, and that the DACL then stops changing. Growth would be an
4337        /// ACE per write; a return to the constant would be the account
4338        /// dropped, which is the lockout.
4339        ///
4340        /// The first write counts because `OW` is not enough on its own: the
4341        /// system deletes the OWNER RIGHTS ACE whenever an owner changes, so a
4342        /// store that leans on it loses the grant to a single `takeown`. The
4343        /// writer is named by SID from the start.
4344        ///
4345        /// It deliberately does not name the account. Windows chooses the
4346        /// spelling: this machine's operator reads back as
4347        /// `S-1-5-21-…-1001`, and CI's, being the built-in Administrator,
4348        /// reads back as the alias `LA`. An earlier version of this test
4349        /// asserted the SID and failed on CI for that reason alone —
4350        /// and the same assumption was in the code, where it was a real
4351        /// defect. The spelling-sensitive rule is pinned in
4352        /// [`the_trustee_scan_reads_both_spellings`] instead.
4353        #[test]
4354        fn replacing_a_stored_credential_keeps_the_previous_owners_grant() {
4355            let root = TempDir::new().expect("a temporary directory");
4356            let store = rooted(SecretScope::Machine, &root);
4357            let base = sddl(SecretScope::Machine);
4358
4359            store.store(&fixture_token()).expect("the first write");
4360            let first = store
4361                .protection()
4362                .expect("the first file's DACL is readable")
4363                .description()
4364                .to_string();
4365            assert_ne!(
4366                first, base,
4367                "even a first write names the account that made it, because `OW` alone does \
4368                 not survive a change of owner"
4369            );
4370
4371            let mut previous: Option<String> = Some(first);
4372            for round in 1..=3 {
4373                store.store(&other_token()).expect("the replacement");
4374                let dacl = store
4375                    .protection()
4376                    .expect("the replacement's DACL is readable")
4377                    .description()
4378                    .to_string();
4379                assert_ne!(
4380                    dacl, base,
4381                    "write {round}: the account that owned what was replaced must stay granted \
4382                     explicitly, because a writer under another account takes `OW` with it"
4383                );
4384                if let Some(previous) = &previous {
4385                    assert_eq!(
4386                        &dacl, previous,
4387                        "write {round}: and the set must settle -- a DACL that keeps growing is \
4388                         an ACE per renewal, and one that shrinks back to the constant is the \
4389                         lockout returning"
4390                    );
4391                }
4392                previous = Some(dacl);
4393            }
4394
4395            assert_eq!(
4396                store
4397                    .load()
4398                    .expect("the replacement is readable")
4399                    .map(|secret| secret.expose_secret().to_string()),
4400                Some(other_token().expose_secret().to_string()),
4401                "carrying an ACE forward must not disturb what the store holds"
4402            );
4403        }
4404
4405        /// Denies *reading* the store, which is the state a renewal under a
4406        /// service account leaves an operator in, and puts the right back on
4407        /// every path out including an unwind.
4408        ///
4409        /// A deny ACE for `Everyone` binds administrators too, which is what
4410        /// makes this reproducible on CI's elevated runner as well as on an
4411        /// ordinary developer machine.
4412        struct DeniedRead {
4413            file: std::path::PathBuf,
4414            restored: bool,
4415        }
4416
4417        impl DeniedRead {
4418            fn new(file: &std::path::Path) -> Self {
4419                let denied = Self {
4420                    file: file.to_path_buf(),
4421                    restored: false,
4422                };
4423                icacls(&[&denied.file.display().to_string(), "/deny", "*S-1-1-0:(R)"]);
4424                denied
4425            }
4426
4427            fn restore(&mut self) {
4428                if self.restored {
4429                    return;
4430                }
4431                self.restored = true;
4432                let arguments = [
4433                    self.file.display().to_string(),
4434                    "/remove:d".into(),
4435                    "*S-1-1-0".into(),
4436                ];
4437                // Not asserted: this runs on the unwind path, where a panic
4438                // would replace a readable failure with an aborted process.
4439                match run_icacls(&arguments.each_ref().map(String::as_str)) {
4440                    Ok(output) if output.status.success() => {}
4441                    other => eprintln!(
4442                        "could not restore the ACL on {}: {other:?}",
4443                        self.file.display()
4444                    ),
4445                }
4446            }
4447        }
4448
4449        impl Drop for DeniedRead {
4450            fn drop(&mut self) {
4451                self.restore();
4452            }
4453        }
4454
4455        /// A store the operator may not read says how to get it back.
4456        ///
4457        /// Carrying the previous owner keeps this from *starting*, but it
4458        /// cannot end it on a host where it already happened: the file grants
4459        /// nobody but the service and has nothing left in its DACL to carry, so
4460        /// every renewal rebuilds the same DACL. Without a message the operator
4461        /// is told `Access is denied` by every command, forever, with no way to
4462        /// find out that one elevated `takeown` ends it.
4463        ///
4464        /// Watched on a real host on 2026-08-30, on 0.1.13, which had the carry
4465        /// and still could not read its own credential.
4466        #[test]
4467        fn a_store_this_account_may_not_read_names_the_command_that_gives_it_back() {
4468            let root = TempDir::new().expect("a temporary directory");
4469            let store = rooted(SecretScope::Machine, &root);
4470            store.store(&fixture_token()).expect("the first write");
4471
4472            let guard = store.guard();
4473            let _denied = DeniedRead::new(&guard);
4474
4475            let error = store
4476                .load()
4477                .expect_err("a store this account may not read is not a store it can load");
4478            let rendered = error.to_string();
4479
4480            for expected in ["icacls", "/grant", &guard.display().to_string(), "elevated"] {
4481                assert!(
4482                    rendered.contains(expected),
4483                    "the refusal must name the remedy and the file it applies to. Wanted \
4484                     {expected:?} in: {rendered}"
4485                );
4486            }
4487            assert!(
4488                !rendered.contains(fixture_token().expose_secret()),
4489                "and it must not carry the value it could not read"
4490            );
4491            assert!(
4492                rendered.contains("Do NOT use `takeown`"),
4493                "and it must warn off the repair that looks right and is not. Changing the \
4494                 owner makes Windows delete the OWNER RIGHTS ACE, so the account ends up \
4495                 owning a file it still cannot read -- permanently. Offered on a real host on \
4496                 2026-08-30, where it appeared to work only because the check that followed \
4497                 ran in the same elevated prompt. Got: {rendered}"
4498            );
4499        }
4500
4501        /// Finding 1: a second operator is refused with a remedy, before
4502        /// anything is written.
4503        ///
4504        /// The behaviour is right — one machine-scoped credential per host, and
4505        /// replacing it is an administrator's call; see the backend's module
4506        /// documentation for why widening the DACL is the wrong answer. What
4507        /// this pins is that the refusal *says so*, that it is classifiable as
4508        /// a permission problem, and that it leaves nothing on disk.
4509        #[test]
4510        fn a_second_operator_is_refused_with_a_remedy_and_writes_nothing() {
4511            let root = TempDir::new().expect("a temporary directory");
4512            let store = rooted(SecretScope::Machine, &root);
4513            store
4514                .store(&fixture_token())
4515                .expect("the first operator stores");
4516
4517            let guard = store.guard();
4518            let directory = guard
4519                .parent()
4520                .expect("the guard has a directory")
4521                .to_path_buf();
4522            let mut denied = DeniedReplace::new(&guard, &directory, AlsoDeny::Nothing);
4523
4524            let error = store
4525                .store(&other_token())
4526                .expect_err("a value this account may not replace is not a value it may store");
4527
4528            let rendered = error.to_string();
4529            for expected in [
4530                "one machine-scoped credential per host",
4531                "auth logout",
4532                "elevated",
4533                "--start-at login",
4534                "Nothing was written",
4535            ] {
4536                assert!(
4537                    rendered.contains(expected),
4538                    "the refusal does not name {expected:?}, so an operator cannot act on \
4539                     it: {rendered}"
4540                );
4541            }
4542            assert!(
4543                matches!(
4544                    &error,
4545                    SecretStoreError::Store { source, .. }
4546                        if source.kind() == std::io::ErrorKind::PermissionDenied
4547                ),
4548                "a caller cannot classify this as a permission problem: {error:?}"
4549            );
4550
4551            // Nothing was written, so the refusal did not also leave a valid
4552            // machine-decryptable blob of a live token under a name nobody
4553            // looks at. This is the half the old code got wrong: it found out
4554            // at the rename, after the blob was already on disk.
4555            let strays: Vec<_> = std::fs::read_dir(&directory)
4556                .expect("readable")
4557                .flatten()
4558                .map(|entry| entry.file_name().to_string_lossy().into_owned())
4559                .filter(|name| name.ends_with(".tmp"))
4560                .collect();
4561            assert!(strays.is_empty(), "a refused store left {strays:?}");
4562
4563            // And the first operator's value is untouched.
4564            denied.restore();
4565            assert_eq!(stored(&store), exposed(&fixture_token()));
4566
4567            // With the denial lifted, the same call succeeds -- which is what
4568            // makes the assertions above a statement about the denial rather
4569            // than about the store being broken.
4570            store
4571                .store(&other_token())
4572                .expect("stored once allowed again");
4573            assert_eq!(stored(&store), exposed(&other_token()));
4574        }
4575
4576        /// The ordering the fix is built around: the refusal is made **before**
4577        /// anything is encrypted or written.
4578        ///
4579        /// The test above cannot tell. The rename path re-derives the identical
4580        /// diagnosis — on `PermissionDenied` it calls `cannot_replace` itself —
4581        /// so moving the probe back behind the write leaves all five message
4582        /// assertions and the `kind()` assertion passing, and `discard` removes
4583        /// the temporary either way, so the `strays` assertion does not catch it
4584        /// either. The property stated at `store`'s call site and twice in the
4585        /// module documentation was asserted by nothing: the same shape as the
4586        /// zero fill that could be deleted with the suite staying green.
4587        ///
4588        /// [`AlsoDeny::Creation`] closes that. With `FILE_ADD_FILE` denied on
4589        /// the directory:
4590        ///
4591        /// - a probe that runs **first** still produces the remedy, because it
4592        ///   only reads the target's ACL, and nothing has been written;
4593        /// - a probe that runs **after** the write never runs at all, because
4594        ///   `create_protected_file` dies first with a bare denial that carries
4595        ///   no remedy in it.
4596        ///
4597        /// So this pins the ordering rather than the wording.
4598        #[test]
4599        fn the_refusal_is_made_before_anything_is_written() {
4600            let root = TempDir::new().expect("a temporary directory");
4601            let store = rooted(SecretScope::Machine, &root);
4602            store
4603                .store(&fixture_token())
4604                .expect("the first operator stores");
4605
4606            let guard = store.guard();
4607            let directory = guard
4608                .parent()
4609                .expect("the guard has a directory")
4610                .to_path_buf();
4611            let _denied = DeniedReplace::new(&guard, &directory, AlsoDeny::Creation);
4612
4613            let error = store
4614                .store(&other_token())
4615                .expect_err("nothing can be created here, so nothing can be stored");
4616
4617            let rendered = error.to_string();
4618            for expected in [
4619                "one machine-scoped credential per host",
4620                "auth logout",
4621                "elevated",
4622                "--start-at login",
4623                "Nothing was written",
4624            ] {
4625                assert!(
4626                    rendered.contains(expected),
4627                    "the refusal does not name {expected:?}. With creation denied, the only \
4628                     way to produce that text is a check that ran BEFORE the write -- so this \
4629                     message came from `create_protected_file` instead, and the store reached \
4630                     the write before it refused: {rendered}"
4631                );
4632            }
4633        }
4634
4635        /// Finding 3: the DPAPI output buffer is zeroed before it is freed.
4636        ///
4637        /// Asserted against a buffer Rust owns, because once `LocalFree` has
4638        /// run there is nothing left to look at. `copy_and_scrub` is the whole
4639        /// of what `take_blob` does to that buffer before freeing it, so this
4640        /// is the property and not a rehearsal of it.
4641        #[test]
4642        fn the_dpapi_buffer_is_scrubbed_before_it_is_freed() {
4643            let token = exposed(&fixture_token());
4644            let mut buffer = token.clone().into_bytes();
4645            let length = buffer.len();
4646
4647            // SAFETY: `buffer` is a live allocation of exactly `length` bytes
4648            // and nothing else refers to it for the duration of the call.
4649            let copy = unsafe { sys::copy_and_scrub(buffer.as_mut_ptr(), length) };
4650
4651            assert_eq!(
4652                copy,
4653                token.clone().into_bytes(),
4654                "the caller must still receive the value"
4655            );
4656            assert!(
4657                buffer.iter().all(|byte| *byte == 0),
4658                "the source buffer still holds the token after the copy, and on the \
4659                 unprotect path that buffer is handed back to the heap by LocalFree: \
4660                 {buffer:?}"
4661            );
4662        }
4663
4664        #[test]
4665        fn a_win32_error_keeps_its_kind_through_the_hresult_wrapper() {
4666            // ERROR_ACCESS_DENIED as windows-rs reports it: HRESULT 0x80070005.
4667            // Handed straight to `from_raw_os_error` this is `Uncategorized`,
4668            // and finding 1's refusal could not be classified by a caller.
4669            let denied = ::windows::core::Error::from_hresult(::windows::core::HRESULT(
4670                0x8007_0005_u32 as i32,
4671            ));
4672            assert_eq!(
4673                sys::io_error(&denied).kind(),
4674                std::io::ErrorKind::PermissionDenied
4675            );
4676
4677            // ERROR_FILE_NOT_FOUND, 0x80070002.
4678            let missing = ::windows::core::Error::from_hresult(::windows::core::HRESULT(
4679                0x8007_0002_u32 as i32,
4680            ));
4681            assert_eq!(sys::io_error(&missing).kind(), std::io::ErrorKind::NotFound);
4682        }
4683
4684        #[test]
4685        fn both_dacls_are_protected_and_name_no_broad_trustee() {
4686            for scope in [SecretScope::Machine, SecretScope::User] {
4687                let sddl = sys::sddl(scope);
4688                assert!(
4689                    sddl.starts_with("D:P"),
4690                    "an unprotected DACL inherits whatever %ProgramData% grants, which is \
4691                     Builtin Users read: {sddl}"
4692                );
4693                for broad in ["WD", "AU", "BU", "IU", "AN"] {
4694                    assert!(
4695                        !sddl.contains(&format!(";{broad})")),
4696                        "{scope}: {sddl} names the broad trustee {broad}"
4697                    );
4698                }
4699            }
4700        }
4701
4702        #[test]
4703        fn only_the_machine_dacl_carries_the_local_system_ace() {
4704            // `process.rs` argues at length that a `SY` ACE adds nothing to the
4705            // JIT handoff, and it is right there: that file's writer and reader
4706            // are one process. Here they are not -- `auth login` writes as the
4707            // operator and the daemon reads as the service account -- so `SY`
4708            // is load-bearing on the machine store and out of place on the
4709            // user store, which is deliberately not for a service.
4710            assert!(sys::sddl(SecretScope::Machine).contains("(A;;FA;;;SY)"));
4711            assert!(!sys::sddl(SecretScope::User).contains(";SY)"));
4712        }
4713
4714        #[test]
4715        fn the_dacl_on_disk_is_the_one_the_backend_asked_for() {
4716            let root = TempDir::new().expect("a temporary directory");
4717            for scope in [SecretScope::Machine, SecretScope::User] {
4718                let store = rooted(scope, &root);
4719                store.store(&fixture_token()).expect("stored");
4720
4721                let description = store.protection().expect("inspectable").description;
4722                assert!(
4723                    description.contains("D:P"),
4724                    "{scope}: the stored file did not keep its protected DACL: {description}"
4725                );
4726                assert!(
4727                    description.contains("FA;;;BA"),
4728                    "{scope}: an administrator must still be able to clean up: {description}"
4729                );
4730                // The one ACE that keeps a *non-administrative* operator able
4731                // to read back what they just stored. Nothing else in the DACL
4732                // names them, so if `CreateFileW` had quietly dropped it the
4733                // round trip would still pass on an administrator's machine and
4734                // fail on everybody else's.
4735                assert!(
4736                    description.contains(";OW)") || description.contains(";S-1-3-4)"),
4737                    "{scope}: the OWNER RIGHTS ACE did not survive to disk, so a \
4738                     non-administrative operator cannot read their own token: {description}"
4739                );
4740                if scope == SecretScope::Machine {
4741                    assert!(
4742                        description.contains("FA;;;SY"),
4743                        "a LocalSystem daemon must be able to read the machine store: \
4744                         {description}"
4745                    );
4746                }
4747            }
4748        }
4749
4750        #[test]
4751        fn the_bytes_on_disk_are_not_the_value() {
4752            let root = TempDir::new().expect("a temporary directory");
4753            let store = rooted(SecretScope::Machine, &root);
4754            store.store(&fixture_token()).expect("stored");
4755
4756            let blob = std::fs::read(store.guard()).expect("the blob is readable");
4757            let token = exposed(&fixture_token());
4758            assert!(
4759                !blob
4760                    .windows(token.len())
4761                    .any(|window| window == token.as_bytes()),
4762                "the DPAPI blob contains the plaintext token"
4763            );
4764        }
4765
4766        #[test]
4767        fn bytes_that_are_not_a_blob_are_reported_as_corrupt() {
4768            let root = TempDir::new().expect("a temporary directory");
4769            let store = rooted(SecretScope::Machine, &root);
4770            store.store(&fixture_token()).expect("stored");
4771
4772            // A blob truncated by an interrupted write, or one written by
4773            // another machine and copied here. Either way DPAPI refuses it, and
4774            // the caller must be told to purge rather than to retry.
4775            std::fs::write(store.guard(), b"this is not a DPAPI blob").expect("planted");
4776            let error = store.load().expect_err("a foreign blob is not a value");
4777            assert!(
4778                matches!(error, SecretStoreError::Corrupt { .. }),
4779                "got {error:?}"
4780            );
4781        }
4782    }
4783
4784    // -----------------------------------------------------------------------
4785    // Unix -- the mode bits, on both Unixes
4786    // -----------------------------------------------------------------------
4787
4788    #[cfg(unix)]
4789    mod unix {
4790        use super::*;
4791
4792        use std::os::unix::fs::PermissionsExt as _;
4793
4794        fn mode_of(path: &std::path::Path) -> u32 {
4795            std::fs::metadata(path)
4796                .unwrap_or_else(|error| panic!("{} is not there: {error}", path.display()))
4797                .permissions()
4798                .mode()
4799                & 0o777
4800        }
4801
4802        /// Asked before anything irreversible, so it has to agree with what a
4803        /// write would meet -- in both directions.
4804        #[test]
4805        fn ensure_writable_refuses_exactly_when_the_store_directory_is_not_writable() {
4806            // SAFETY: `geteuid` reads this process's own credentials, takes no
4807            // pointer, and cannot fail.
4808            if unsafe { libc::geteuid() } == 0 {
4809                // Modes refuse nobody running as root, so the refusal half
4810                // cannot be staged here.
4811                return;
4812            }
4813            for scope in [SecretScope::Machine, SecretScope::User] {
4814                let root = TempDir::new().expect("a temporary directory");
4815                let store = rooted(scope, &root);
4816                store
4817                    .ensure_writable()
4818                    .unwrap_or_else(|error| panic!("{scope}: nothing written yet: {error}"));
4819                store.store(&fixture_token()).expect("stored");
4820                store
4821                    .ensure_writable()
4822                    .unwrap_or_else(|error| panic!("{scope}: the writer's own store: {error}"));
4823
4824                let guard = store.guard();
4825                let directory = guard.parent().expect("the guard has a directory");
4826                std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o500))
4827                    .expect("the directory is made read-only");
4828                let refusal = store.ensure_writable();
4829                // A new entry beside the store, which is what both backends'
4830                // replacing write creates first. Not `store` itself: the Linux
4831                // backend re-applies `0700` to a directory this test's account
4832                // owns, so it would undo the staging rather than meet it. The
4833                // production case -- a directory another account owns -- is
4834                // one no chmod of ours can repair.
4835                let write = std::fs::File::create(directory.join("probe.tmp"));
4836                std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700))
4837                    .expect("the directory is restored for cleanup");
4838
4839                let refusal = refusal.expect_err("a read-only directory refuses a write");
4840                assert!(
4841                    matches!(
4842                        &refusal,
4843                        SecretStoreError::Store { source, .. }
4844                            if source.kind() == std::io::ErrorKind::PermissionDenied
4845                    ),
4846                    "{scope}: got {refusal:?}"
4847                );
4848                assert!(
4849                    write.is_err(),
4850                    "{scope}: the check refused an entry the directory then accepted"
4851                );
4852            }
4853        }
4854
4855        #[test]
4856        fn the_guard_is_0600_and_its_directory_is_0700() {
4857            for scope in [SecretScope::Machine, SecretScope::User] {
4858                let root = TempDir::new().expect("a temporary directory");
4859                let store = rooted(scope, &root);
4860                store.store(&fixture_token()).expect("stored");
4861
4862                let guard = store.guard();
4863                assert_eq!(
4864                    mode_of(&guard),
4865                    0o600,
4866                    "{scope}: {} is not 0600",
4867                    guard.display()
4868                );
4869                let directory = guard.parent().expect("the guard has a directory");
4870                assert_eq!(
4871                    mode_of(directory),
4872                    0o700,
4873                    "{scope}: {} is not 0700",
4874                    directory.display()
4875                );
4876            }
4877        }
4878    }
4879
4880    // -----------------------------------------------------------------------
4881    // Linux -- systemd credentials
4882    // -----------------------------------------------------------------------
4883
4884    #[cfg(all(unix, not(target_os = "macos")))]
4885    mod linux {
4886        use super::*;
4887
4888        use std::os::unix::fs::PermissionsExt as _;
4889
4890        /// Writes `bytes` where systemd would have put a credential.
4891        fn plant_credential(directory: &std::path::Path, bytes: &[u8]) -> std::path::PathBuf {
4892            std::fs::create_dir_all(directory).expect("the credentials directory");
4893            let path = directory.join(SYSTEMD_CREDENTIAL);
4894            std::fs::write(&path, bytes).expect("the credential is written");
4895            // systemd mounts the credentials directory read-only and gives each
4896            // credential 0400. Reproduced so that the protection assertion sees
4897            // what production would.
4898            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400))
4899                .expect("the credential is tightened");
4900            path
4901        }
4902
4903        #[test]
4904        fn a_systemd_credential_is_read_in_preference_to_the_file() {
4905            let root = TempDir::new().expect("a temporary directory");
4906            let credentials = TempDir::new().expect("a credentials directory");
4907
4908            // The file first, so that reading the credential is a preference
4909            // rather than the only thing there is to read.
4910            let plain = rooted(SecretScope::Machine, &root);
4911            plain.store(&other_token()).expect("stored");
4912
4913            plant_credential(credentials.path(), exposed(&fixture_token()).as_bytes());
4914            let store =
4915                rooted(SecretScope::Machine, &root).with_credentials_directory(credentials.path());
4916
4917            assert_eq!(
4918                stored(&store),
4919                exposed(&fixture_token()),
4920                "a unit given LoadCredentialEncrypted= must not be overridden by a stale file"
4921            );
4922            assert_eq!(
4923                store.credential_path(),
4924                Some(credentials.path().join(SYSTEMD_CREDENTIAL).as_path())
4925            );
4926            assert!(
4927                store.location().contains("systemd credential"),
4928                "`host show` must say where the value is actually coming from: {}",
4929                store.location()
4930            );
4931        }
4932
4933        /// Finding 6, end to end: what an operator's `systemd-creds encrypt`
4934        /// actually produces.
4935        ///
4936        /// `echo`, `printf '%s\n'` and every text editor leave a newline. Used
4937        /// byte for byte it becomes part of the token and fails much later
4938        /// inside an `Authorization` header, where it reads as a bad credential
4939        /// rather than as a bad read — which is the failure this pins.
4940        #[test]
4941        fn a_credential_written_with_a_trailing_newline_yields_the_token_without_it() {
4942            let root = TempDir::new().expect("a temporary directory");
4943            let credentials = TempDir::new().expect("a credentials directory");
4944            let token = exposed(&fixture_token());
4945
4946            plant_credential(credentials.path(), format!("{token}\n").as_bytes());
4947            let store =
4948                rooted(SecretScope::Machine, &root).with_credentials_directory(credentials.path());
4949
4950            assert_eq!(stored(&store), token);
4951        }
4952
4953        /// The file half of the same decision, which must *not* be trimmed.
4954        ///
4955        /// `store` writes no newline, so anything trailing in its own file is
4956        /// corruption rather than formatting, and repairing it silently would
4957        /// hide the one thing `Corrupt` exists to report.
4958        #[test]
4959        fn the_stores_own_file_is_read_back_verbatim() {
4960            let root = TempDir::new().expect("a temporary directory");
4961            let store = rooted(SecretScope::Machine, &root);
4962            store.store(&fixture_token()).expect("stored");
4963
4964            let token = exposed(&fixture_token());
4965            std::fs::write(store.guard(), format!("{token}\n")).expect("planted");
4966
4967            assert_eq!(
4968                stored(&store),
4969                format!("{token}\n"),
4970                "the store's own file is used verbatim; only a systemd credential is trimmed"
4971            );
4972        }
4973
4974        #[test]
4975        fn a_credentials_directory_without_this_credential_falls_back_to_the_file() {
4976            let root = TempDir::new().expect("a temporary directory");
4977            let credentials = TempDir::new().expect("a credentials directory");
4978
4979            let plain = rooted(SecretScope::Machine, &root);
4980            plain.store(&fixture_token()).expect("stored");
4981
4982            // A unit can be given credentials without being given this one.
4983            std::fs::write(credentials.path().join("something.else"), b"x").expect("written");
4984            let store =
4985                rooted(SecretScope::Machine, &root).with_credentials_directory(credentials.path());
4986
4987            assert_eq!(stored(&store), exposed(&fixture_token()));
4988        }
4989
4990        #[test]
4991        fn the_credential_is_what_protection_inspects_when_there_is_one() {
4992            let root = TempDir::new().expect("a temporary directory");
4993            let credentials = TempDir::new().expect("a credentials directory");
4994            let planted =
4995                plant_credential(credentials.path(), exposed(&fixture_token()).as_bytes());
4996
4997            let store =
4998                rooted(SecretScope::Machine, &root).with_credentials_directory(credentials.path());
4999            let protection = store.protection().expect("inspectable");
5000
5001            assert_eq!(protection.guard(), planted);
5002            assert!(!protection.readable_by_other_local_users(), "{protection}");
5003        }
5004
5005        #[test]
5006        fn storing_under_a_systemd_credential_is_refused_rather_than_shadowed() {
5007            let root = TempDir::new().expect("a temporary directory");
5008            let credentials = TempDir::new().expect("a credentials directory");
5009            plant_credential(credentials.path(), exposed(&fixture_token()).as_bytes());
5010
5011            // The same site without the credential, so the test can name the
5012            // file the refusal must not have written.
5013            let file = rooted(SecretScope::Machine, &root).guard();
5014
5015            let store =
5016                rooted(SecretScope::Machine, &root).with_credentials_directory(credentials.path());
5017            let error = store
5018                .store(&other_token())
5019                .expect_err("a write that the next read would ignore is not a write");
5020            let rendered = error.to_string();
5021            assert!(rendered.contains(SYSTEMD_CREDENTIAL), "{rendered}");
5022
5023            // And nothing was written, so the refusal did not also leave a
5024            // second copy of a token on disk.
5025            assert!(!file.exists(), "{} was written anyway", file.display());
5026        }
5027
5028        #[test]
5029        fn purging_under_a_systemd_credential_removes_the_file_and_reports_the_remainder() {
5030            let root = TempDir::new().expect("a temporary directory");
5031            let credentials = TempDir::new().expect("a credentials directory");
5032
5033            let plain = rooted(SecretScope::Machine, &root);
5034            plain.store(&other_token()).expect("stored");
5035            let file = plain.guard();
5036            plant_credential(credentials.path(), exposed(&fixture_token()).as_bytes());
5037
5038            let store =
5039                rooted(SecretScope::Machine, &root).with_credentials_directory(credentials.path());
5040            let error = store
5041                .delete()
5042                .expect_err("this host is not purged and `auth logout` must not say it is");
5043
5044            assert!(!file.exists(), "the file it could remove was removed");
5045            assert!(
5046                error.to_string().contains(SYSTEMD_CREDENTIAL),
5047                "the operator has to be told what is still supplying a token: {error}"
5048            );
5049        }
5050
5051        #[test]
5052        #[serial_test::serial]
5053        fn the_standard_machine_store_reads_the_credentials_directory_from_the_environment() {
5054            let credentials = TempDir::new().expect("a credentials directory");
5055
5056            // SAFETY: `serial_test` guarantees no other test in this binary is
5057            // running, and this is the only test that touches this variable.
5058            unsafe {
5059                std::env::set_var(CREDENTIALS_DIRECTORY, credentials.path());
5060            }
5061            let store = PlatformSecretStore::standard(SecretScope::Machine).expect("resolves");
5062            let resolved = store.credential_path().map(std::path::Path::to_path_buf);
5063
5064            // SAFETY: as above.
5065            unsafe {
5066                std::env::remove_var(CREDENTIALS_DIRECTORY);
5067            }
5068
5069            assert_eq!(
5070                resolved,
5071                Some(credentials.path().join(SYSTEMD_CREDENTIAL)),
5072                "a daemon started by systemd gets its credential without being told to"
5073            );
5074
5075            let without = PlatformSecretStore::standard(SecretScope::Machine).expect("resolves");
5076            assert_eq!(
5077                without.credential_path(),
5078                None,
5079                "a daemon started by anything else must not invent one"
5080            );
5081        }
5082
5083        #[test]
5084        fn a_user_scoped_store_never_consults_a_service_credential() {
5085            let store = PlatformSecretStore::standard(SecretScope::User).expect("resolves");
5086            assert_eq!(store.credential_path(), None);
5087        }
5088    }
5089}