Skip to main content

drep/
auth.rs

1//! The credential store: API keys drep holds on the user's behalf.
2//!
3//! `drep.toml` is a repository file. It names an endpoint, a model and a
4//! protocol, all of which are shareable, and it is meant to survive being
5//! committed - which is why `api_key = "${VAR}"` names an environment variable
6//! rather than holding a secret. That indirection is the right answer for CI,
7//! where the key arrives as a secret and nobody is at a keyboard, and the wrong
8//! answer for a person setting drep up on their laptop: it makes the first-run
9//! experience "now go and export something into the right shell profile".
10//!
11//! So keys live here instead, once per machine, outside any repository:
12//!
13//! ```text
14//! ~/.config/drep/auth.toml     (macOS: ~/Library/Application Support/dev.slb350.drep)
15//! ```
16//!
17//! ## Keyed by endpoint, not by provider name
18//!
19//! A key authenticates a *host*, so the endpoint is what it belongs to. Keying
20//! by a preset name instead would mean a config that named no preset - a custom
21//! endpoint, or one edited by hand after `drep init` - could not find its own
22//! credential, and two presets pointed at the same host would each need their
23//! own copy of one key.
24//!
25//! The endpoint is normalised before use (see [`normalise`]) so a trailing
26//! slash or a difference in case does not hide a key from the config that
27//! stored it.
28//!
29//! There is deliberately no `load_default`/`save_default` pair. Both were thin
30//! wrappers over `default_path()`, which reads the environment - so nothing
31//! could test them without `std::env::set_var`, and the mutation gate found
32//! them undetectable. Every caller resolves the path once, at its own entry
33//! point, and passes it down; that is also what keeps tests off the real store.
34//!
35//! ## Resolution order
36//!
37//! [`resolve`] fills in what `drep.toml` left unset, in this order:
38//!
39//! 1. an explicit `api_key` - a literal, or a `${VAR}` the loader has already
40//!    substituted;
41//! 2. `api_key_command`, an argv drep runs to mint one;
42//! 3. a key held here for the same endpoint;
43//! 4. nothing, which `LlmClient::new` turns into `not-needed`.
44//!
45//! An explicit value in the file always wins. A user who writes
46//! `api_key = "${OPENROUTER_API_KEY}"` has said where the key comes from, and
47//! silently preferring a stored one would make the file lie about what the run
48//! used. The command sits above the store for the same reason: a file naming a
49//! command has not left the question unanswered.
50
51use std::collections::BTreeMap;
52use std::path::{Path, PathBuf};
53
54use serde::{Deserialize, Serialize};
55use thiserror::Error;
56
57use crate::config::Config;
58
59mod command;
60pub use command::KeyCommandError;
61
62/// Where a provider's key came from, for [`doctor`](crate::cli::doctor) to report.
63///
64/// The point of naming the source is that "it works on my machine" and "it works
65/// in CI" are different configurations, and the difference is invisible in
66/// `drep.toml` once a stored key exists.
67///
68/// The variant order is the resolution order.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum KeySource {
71    /// The config named an environment variable or a literal, and it resolved.
72    Config,
73    /// The config named an `api_key_command`, which drep runs to mint a key.
74    Command,
75    /// The config named nothing and the store had one for this endpoint.
76    Store,
77    /// Neither. The provider will authenticate as `not-needed`, which a local
78    /// server accepts and a cloud one answers with a 401.
79    Missing,
80}
81
82impl KeySource {
83    /// Every source, in resolution order.
84    ///
85    /// Exists so the label-collision test cannot pass on a stale subset, the way
86    /// `Severity::ALL` drives its parser test: a variant added without wording of
87    /// its own has to fail something.
88    pub const ALL: [Self; 4] = [Self::Config, Self::Command, Self::Store, Self::Missing];
89
90    /// What `doctor` prints for this source.
91    ///
92    /// The single definition of the wording. `doctor` hand-wrote its own copy
93    /// of these three strings while this method sat unused, which is two places
94    /// to keep in step for no gain.
95    pub fn label(&self) -> &'static str {
96        match self {
97            Self::Config => "from drep.toml",
98            Self::Command => "from api_key_command",
99            Self::Store => "from the drep auth store",
100            Self::Missing => "not set - run `drep auth login` or add `api_key` to drep.toml",
101        }
102    }
103}
104
105/// Keys held for this machine, keyed by normalised endpoint.
106///
107/// `Serialize`/`Deserialize` drive the on-disk TOML directly; there is no
108/// separate wire type because the file is drep's own and has one shape.
109#[derive(Default, Serialize, Deserialize)]
110pub struct AuthStore {
111    /// Endpoint to key. A `BTreeMap` so the file is written in a stable order
112    /// and a re-save produces no spurious diff.
113    #[serde(default)]
114    keys: BTreeMap<String, String>,
115}
116
117/// Hand-written so a key cannot reach a log.
118///
119/// The same reasoning as `LlmConfig` and `LlmClient`: a derived `Debug` prints
120/// every value, so one `{:?}` anywhere would emit every credential the user has.
121/// The endpoints are printed because they are not secret and they are the useful
122/// half when debugging.
123impl std::fmt::Debug for AuthStore {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        f.debug_struct("AuthStore")
126            .field("endpoints", &self.keys.keys().collect::<Vec<_>>())
127            .finish()
128    }
129}
130
131/// What can go wrong reading or writing the store.
132#[derive(Debug, Error)]
133pub enum AuthError {
134    #[error("no config directory for this platform; set the key in drep.toml instead")]
135    NoConfigDir,
136
137    #[error("could not read {0}: {1}")]
138    Read(PathBuf, std::io::Error),
139
140    #[error("could not write {0}: {1}")]
141    Write(PathBuf, std::io::Error),
142
143    #[error("could not parse {0}: {1}")]
144    Parse(PathBuf, String),
145
146    /// Serializing the store failed. Distinct from [`Self::Parse`], which is
147    /// about reading: a write failure reported as "could not parse" sends the
148    /// reader looking at the file rather than at what drep tried to write.
149    #[error("could not serialize the auth store: {0}")]
150    Serialize(String),
151
152    #[error("refusing to store an empty key for {0}")]
153    EmptyKey(String),
154
155    /// A key carrying a control character cannot be sent as an HTTP header, so
156    /// storing it would defer a guaranteed failure to the first request.
157    #[error("the key for {0} contains a character that cannot be sent in a header")]
158    UnusableKey(String),
159
160    /// A configured `api_key_command` did not produce a credential.
161    ///
162    /// Fatal, and fatal *here*: the provider chain does not exist yet, so there
163    /// is nothing to fail over to - and failing over would be the wrong answer
164    /// anyway, for the reason a 401 does not fail over. Routing around a broken
165    /// credential path is what hides it.
166    ///
167    /// The entry is numbered in file order, matching `ConfigError`, because that
168    /// is the numbering a user can count blocks in `drep.toml` to check.
169    #[error("[[llm]] #{} in file order: {cause}", index + 1)]
170    KeyCommand {
171        index: usize,
172        cause: KeyCommandError,
173    },
174}
175
176/// The environment variable that relocates the store.
177pub const PATH_VAR: &str = "DREP_AUTH_PATH";
178
179/// The store location: [`PATH_VAR`] if set, else the platform's config dir.
180///
181/// The platform path comes from `directories::ProjectDirs` with the same triple
182/// `Cache::default_root` uses, so drep's two user-level directories are siblings
183/// under one application identity rather than two unrelated paths.
184///
185/// The override exists because there is otherwise **no way to run drep against a
186/// scratch store**. `directories` follows each platform's own convention rather
187/// than the XDG variables, so on macOS `XDG_CONFIG_HOME` is ignored entirely and
188/// a command run to try something out writes into the real store - which is how
189/// a test key ended up in one. It also serves the ordinary case of keeping
190/// credentials somewhere deliberate, such as a mounted volume.
191pub fn default_path() -> Result<PathBuf, AuthError> {
192    path_from(std::env::var_os(PATH_VAR))
193}
194
195/// [`default_path`] with the override supplied rather than read.
196///
197/// Split out so the override can be tested without writing to the process
198/// environment. `std::env::set_var` is `unsafe` in edition 2024 because another
199/// thread reading the environment concurrently is a data race, and `cargo test`
200/// runs tests on several threads - a "single-threaded test process" safety
201/// comment would simply have been untrue.
202pub fn path_from(overridden: Option<std::ffi::OsString>) -> Result<PathBuf, AuthError> {
203    if let Some(path) = overridden {
204        return Ok(PathBuf::from(path));
205    }
206    directories::ProjectDirs::from("dev", "slb350", "drep")
207        .map(|dirs| dirs.config_dir().join("auth.toml"))
208        .ok_or(AuthError::NoConfigDir)
209}
210
211impl AuthStore {
212    /// An empty store, for a machine that has never stored a key.
213    pub fn new() -> Self {
214        Self::default()
215    }
216
217    /// Read the store at `path`.
218    ///
219    /// A **missing file is an empty store**, not an error: never having stored a
220    /// key is the normal first-run state, and making the caller distinguish it
221    /// from a real read failure would put that branch at every call site. A file
222    /// that exists but cannot be read or parsed *is* an error, because silently
223    /// treating a corrupt store as empty would send a user to re-paste keys they
224    /// already have.
225    pub fn load(path: &Path) -> Result<Self, AuthError> {
226        let content = match std::fs::read_to_string(path) {
227            Ok(content) => content,
228            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Self::new()),
229            Err(err) => return Err(AuthError::Read(path.to_path_buf(), err)),
230        };
231        toml::from_str(&content)
232            .map_err(|err: toml::de::Error| AuthError::Parse(path.to_path_buf(), err.to_string()))
233    }
234
235    /// Write the store to `path`, creating the directory if needed.
236    ///
237    /// The file is created mode 0600 and the directory 0700 on Unix, and the
238    /// mode is applied to an *existing* file too - a store written before this
239    /// ran, or one whose mode a user widened, is narrowed on the next save
240    /// rather than left as found.
241    pub fn save(&self, path: &Path) -> Result<(), AuthError> {
242        // A bare filename has `Some("")` as its parent, which is not a
243        // directory anything can create.
244        if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
245            ensure_dir_private(parent)?;
246        }
247
248        let body =
249            toml::to_string_pretty(self).map_err(|err| AuthError::Serialize(err.to_string()))?;
250
251        // Created 0600 from the outset rather than written and then chmodded:
252        // between those two steps the key sits in a world-readable file, which
253        // is a window another process on a shared machine can read.
254        write_private(path, &body)
255    }
256
257    /// The key held for `endpoint`, if any.
258    pub fn get(&self, endpoint: &str) -> Option<&str> {
259        self.keys.get(&normalise(endpoint)).map(String::as_str)
260    }
261
262    /// Store `key` for `endpoint`, replacing any previous one.
263    ///
264    /// The rule about what a credential may be is `vet`'s, not this method's;
265    /// only the wording is here, because a paste at the prompt and a helper's
266    /// stdout send the reader to different fixes.
267    pub fn set(&mut self, endpoint: &str, key: &str) -> Result<(), AuthError> {
268        let key = vet(key).map_err(|defect| match defect {
269            CredentialDefect::Empty => AuthError::EmptyKey(endpoint.to_string()),
270            CredentialDefect::Unusable => AuthError::UnusableKey(endpoint.to_string()),
271        })?;
272        self.keys.insert(normalise(endpoint), key);
273        Ok(())
274    }
275
276    /// Forget the key for `endpoint`. Returns whether one was held.
277    pub fn remove(&mut self, endpoint: &str) -> bool {
278        self.keys.remove(&normalise(endpoint)).is_some()
279    }
280
281    /// Every endpoint with a stored key, in sorted order. Never the keys.
282    pub fn endpoints(&self) -> Vec<&str> {
283        self.keys.keys().map(String::as_str).collect()
284    }
285
286    /// Whether the store holds nothing.
287    pub fn is_empty(&self) -> bool {
288        self.keys.is_empty()
289    }
290}
291
292/// Why a credential cannot be used, whatever produced it.
293///
294/// Two variants because they send the reader to different fixes: nothing arrived
295/// at all, or something arrived that cannot be sent.
296pub(crate) enum CredentialDefect {
297    Empty,
298    Unusable,
299}
300
301/// Trim a candidate credential and refuse one that cannot become a header value.
302///
303/// One definition, because this is a safety property, and `crate::http`'s module
304/// doc already records what happens to a safety property written twice: "written
305/// once and forgotten once". It was written twice - here for a key pasted at the
306/// prompt, and in `auth::command::run` for one a helper printed - and the two
307/// copies had already drifted cosmetically. The minted path is the one that never
308/// passes through [`AuthStore::set`], so a defect class added at the prompt would
309/// silently not have applied to the credential a gateway helper produces, which is
310/// the only path whose value nobody ever sees.
311///
312/// The wording stays with each caller: this returns [`CredentialDefect`] rather
313/// than an `AuthError`, so the store can name the endpoint and the helper can name
314/// the program. That is the mechanism-shared, classification-private split
315/// `crate::http` documents for the same pair of callers.
316///
317/// **Both ends** are trimmed. Every helper that prints a token prints a newline
318/// after it, and a leading space is one `printf ' %s'` or one `cut` field away;
319/// trimming only the tail accepted `" sk-live-..."`, which passes the
320/// control-character guard and is then stripped again by the transport, so the
321/// value sent was not the value checked and the endpoint answered 401.
322///
323/// A control character is refused because every use of a credential is an HTTP
324/// header value, which cannot carry one. Rejecting it here turns a paste or a
325/// helper that picked up a stray escape sequence into a message the user can act
326/// on, rather than a transport failure on the first file of the first push.
327pub(crate) fn vet(candidate: &str) -> Result<String, CredentialDefect> {
328    let candidate = candidate.trim();
329    if candidate.is_empty() {
330        return Err(CredentialDefect::Empty);
331    }
332    if candidate.chars().any(char::is_control) {
333        return Err(CredentialDefect::Unusable);
334    }
335    Ok(candidate.to_owned())
336}
337
338/// Canonical form of an endpoint for use as a store key.
339///
340/// Lowercased and stripped of trailing slashes. `https://API.Z.AI/v1/` and
341/// `https://api.z.ai/v1` are the same host and the same credential, and a store
342/// that disagreed would report "no key" for a config that had just stored one -
343/// a failure with no visible cause, since both spellings look right.
344///
345/// Deliberately no more than that: the path is significant (`/v1` and
346/// `/anthropic/v1` are different APIs on the same host, and can carry different
347/// keys), so nothing beyond the trailing slash is trimmed.
348pub fn normalise(endpoint: &str) -> String {
349    let trimmed = endpoint.trim().trim_end_matches('/');
350
351    // Only the scheme and authority are case-insensitive; a URL *path* is not.
352    // Lowercasing the whole thing collapsed `/API/v1` and `/api/v1` onto one
353    // entry, which for a host serving both would hand one endpoint's key to the
354    // other - the same class of mistake as keying on the model alone.
355    match trimmed.find("://") {
356        Some(scheme_end) => format!(
357            "{}://{}",
358            trimmed[..scheme_end].to_ascii_lowercase(),
359            lower_authority(&trimmed[scheme_end + 3..])
360        ),
361        // No scheme, which is what `localhost:11434/v1` looks like. It still has
362        // an authority and a path, and the same rule applies to both halves -
363        // lowercasing the whole string collapsed `/V1` onto `/v1` for exactly
364        // the endpoints a user is most likely to type by hand.
365        None => lower_authority(trimmed),
366    }
367}
368
369/// Lowercase everything before the first `/` and leave the rest alone.
370fn lower_authority(rest: &str) -> String {
371    let host_len = rest.find('/').unwrap_or(rest.len());
372    format!(
373        "{}{}",
374        rest[..host_len].to_ascii_lowercase(),
375        &rest[host_len..]
376    )
377}
378
379/// Fill in keys the config left unset, and report where each one came from.
380///
381/// Returns one [`KeySource`] per entry in `config.llm`, positionally - including
382/// the disabled ones, so the position in the returned slice is the position in the
383/// file. No production caller reads it: `check` discards it, and `doctor` calls
384/// [`source_of`] against the raw TOML tree instead, because `config::load` fails
385/// on an unset `${VAR}` and that is exactly the config `doctor` is most useful on.
386/// It is retained for the tests, which assert the precedence rule per entry
387/// against the one function that decides it, and the positional shape is what lets
388/// them name an entry by the line the user wrote.
389///
390/// **Disabled entries are skipped**, matching every other pass over the provider
391/// list: `${VAR}` expansion and field validation already leave a parked entry
392/// alone, and looking a key up for one would report a missing credential for a
393/// provider that is never contacted. For an `api_key_command` that also means no
394/// subprocess: some helpers are rate-limited and some prompt for a fingerprint,
395/// so spending a real credential call on a provider drep will not contact is
396/// worse than the missing-key report it avoids.
397///
398/// This is the one pass where a credential command runs, so each entry's command
399/// runs exactly once per process however many files the run reviews. A
400/// short-lived credential re-minted per file would fail per file, which the
401/// chain's demotion logic reads as an endpoint problem. There is deliberately no
402/// disk cache and no TTL behind that: drep is a short-lived process, so a
403/// credential written to disk buys nothing and adds a file worth stealing.
404///
405/// Once per process for every *enabled* entry, whether or not the chain reaches
406/// it. Deferring to first use would put credential resolution inside the request
407/// path, and "a broken credential is fatal rather than a provider drep quietly
408/// asks instead" holds precisely because resolution happens before the chain
409/// exists. An unset `${VAR}` in the same position is equally fatal, one layer up,
410/// in `ConfigError::EnvVarUnset`. `enabled = false` is the control for a fallback
411/// whose helper should not be spent.
412pub async fn resolve(config: &mut Config, store: &AuthStore) -> Result<Vec<KeySource>, AuthError> {
413    let mut sources = Vec::with_capacity(config.llm.len());
414    for (index, llm) in config.llm.iter_mut().enumerate() {
415        let source = source_of(
416            Declared {
417                api_key: llm.api_key.as_deref(),
418                has_api_key_command: llm.api_key_command.is_some(),
419                endpoint: llm.endpoint.as_deref(),
420                enabled: llm.enabled,
421            },
422            store,
423        );
424        match source {
425            // `source_of` returns `Command` only when the argv is present, so
426            // the default here is unreachable through it; `command::run` reports
427            // an empty argv rather than panicking if it ever becomes reachable.
428            KeySource::Command => {
429                let argv = llm.api_key_command.as_deref().unwrap_or_default();
430                let key = command::run_bounded(argv)
431                    .await
432                    .map_err(|cause| AuthError::KeyCommand { index, cause })?;
433                llm.api_key = Some(key);
434            }
435            KeySource::Store => {
436                if let Some(endpoint) = llm.endpoint.as_deref()
437                    && let Some(key) = store.get(endpoint)
438                {
439                    llm.api_key = Some(key.to_string());
440                }
441            }
442            KeySource::Config | KeySource::Missing => {}
443        }
444        sources.push(source);
445    }
446    Ok(sources)
447}
448
449/// Run one entry's `api_key_command` and discard the credential.
450///
451/// For `doctor`, whose contract is "what will actually run here": it has to
452/// really invoke the helper, because a helper that no longer authenticates is
453/// precisely what `api_key_command` exists to make visible. Returning `()`
454/// rather than the key is what makes printing it impossible at the call site,
455/// instead of a rule the reporting code has to remember.
456pub async fn probe_key_command(argv: &[String]) -> Result<(), KeyCommandError> {
457    command::run_bounded(argv).await.map(drop)
458}
459
460/// What one `[[llm]]` entry declares about its own credential.
461///
462/// Named fields rather than positional arguments, following `ExplicitFields`:
463/// `check` fills these from a loaded `Config` and `doctor` from the raw TOML
464/// tree, and two `Option<&str>` plus two `bool` in a call is a swap waiting to
465/// happen that the compiler would not catch.
466pub struct Declared<'a> {
467    pub api_key: Option<&'a str>,
468    pub has_api_key_command: bool,
469    pub endpoint: Option<&'a str>,
470    pub enabled: bool,
471}
472
473/// Where a provider's key will come from, given what its config names.
474///
475/// The precedence rule itself, in one place. [`resolve`] applies it to a loaded
476/// `Config`; `doctor` applies it to the *raw* TOML tree, which it has to read
477/// separately so a `${VAR}` prints as itself rather than being swallowed by the
478/// variable-not-set error. Two readers of one rule is the shape
479/// `config::env_var_refs_in` already exists to prevent: doctor once carried a
480/// narrower copy of that scanner and reported a config as fine that `check`
481/// refused to load.
482pub fn source_of(declared: Declared<'_>, store: &AuthStore) -> KeySource {
483    if !declared.enabled {
484        return KeySource::Missing;
485    }
486    if declared.api_key.is_some() {
487        return KeySource::Config;
488    }
489    if declared.has_api_key_command {
490        return KeySource::Command;
491    }
492    match declared.endpoint {
493        Some(endpoint) if store.get(endpoint).is_some() => KeySource::Store,
494        _ => KeySource::Missing,
495    }
496}
497
498/// Create `dir` if it is missing, narrowing it to 0700 only when drep made it.
499///
500/// Only a directory drep creates is narrowed. `DREP_AUTH_PATH` can name any
501/// path, so chmodding whatever happens to be its parent would let
502/// `/etc/drep.toml` turn `/etc` into 0700 - breaking the system to protect one
503/// file. An existing directory is the user's, and the store file's own 0600 is
504/// what actually guards the key.
505///
506/// Shared with the model-quirks cache, which sits in the same directory: a
507/// second copy of this rule that only called `create_dir_all` would leave the
508/// credential store's directory world-readable whenever the cache happened to
509/// be written first.
510pub(crate) fn ensure_dir_private(dir: &Path) -> Result<(), AuthError> {
511    let existed = dir.exists();
512    std::fs::create_dir_all(dir).map_err(|err| AuthError::Write(dir.to_path_buf(), err))?;
513    if !existed {
514        restrict(dir, 0o700)?;
515    }
516    Ok(())
517}
518
519/// Write `body` to `path`, creating it readable only by its owner.
520///
521/// `File::create` plus a later `chmod` leaves the key in a 0644 file for the
522/// duration of the write. `NamedTempFile` exclusively creates a random 0600
523/// sibling, so there is no readable window and no predictable name for a
524/// planted symlink. The mode is re-applied before publication as an explicit
525/// invariant rather than depending on the temporary-file crate's default.
526///
527/// One function with the `cfg` around the mode call, rather than two whole
528/// implementations: a `#[cfg(not(unix))]` twin is not compiled here, so
529/// mutating it changes nothing and the mutation gate reports an undetectable
530/// survivor on every run. Windows has no mode bits, and `directories` puts the
531/// file under the user's own roaming profile there.
532fn write_private(path: &Path, body: &str) -> Result<(), AuthError> {
533    use std::io::Write;
534
535    // Written beside the target and renamed over it, never into it. Opening the
536    // real path with `truncate` destroys the existing store before a byte of the
537    // replacement is written, so a crash, a full disk or a serialization failure
538    // in that window leaves the file empty or half-written - and this is the one
539    // file drep holds that cannot be regenerated. `rename` is atomic within a
540    // directory, so a reader sees either the whole old store or the whole new
541    // one, which is why the temporary is a sibling rather than in the system
542    // temp dir.
543    let parent = temporary_parent(path);
544    // A random, exclusively-created name prevents an attacker from planting a
545    // sibling symlink that receives the serialized credentials when opened.
546    let mut temporary = tempfile::NamedTempFile::new_in(parent)
547        .map_err(|err| AuthError::Write(path.to_path_buf(), err))?;
548    temporary
549        .write_all(body.as_bytes())
550        .map_err(|err| AuthError::Write(path.to_path_buf(), err))?;
551    // Before the rename, not after: a rename that publishes a file whose
552    // contents are still in the page cache can survive a crash as an empty one.
553    temporary
554        .as_file()
555        .sync_all()
556        .map_err(|err| AuthError::Write(path.to_path_buf(), err))?;
557
558    // The temporary carries the mode, and `rename` keeps it - so the published
559    // store is 0600 whatever the mode of the file it replaced, which is how a
560    // store a user widened is narrowed again.
561    restrict(temporary.path(), 0o600)?;
562
563    temporary
564        .persist(path)
565        .map(|_| ())
566        .map_err(|err| AuthError::Write(path.to_path_buf(), err.error))
567}
568
569/// Directory in which an atomic replacement must be created.
570fn temporary_parent(path: &Path) -> &Path {
571    path.parent()
572        .filter(|parent| !parent.as_os_str().is_empty())
573        .unwrap_or_else(|| Path::new("."))
574}
575
576/// Narrow `path` to `mode` on Unix. A no-op elsewhere.
577///
578/// Windows has no mode bits and `directories` puts the file under the user's
579/// roaming profile, which is already user-scoped; failing the save there would
580/// refuse to store a key for no gain.
581#[cfg(unix)]
582fn restrict(path: &Path, mode: u32) -> Result<(), AuthError> {
583    use std::os::unix::fs::PermissionsExt;
584    std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
585        .map_err(|err| AuthError::Write(path.to_path_buf(), err))
586}
587
588#[cfg(not(unix))]
589fn restrict(_path: &Path, _mode: u32) -> Result<(), AuthError> {
590    Ok(())
591}
592
593#[cfg(test)]
594mod tests;