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