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