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