Skip to main content

omni_dev/utils/
secret_env.rs

1//! The one resolver for secret environment variables and their companions.
2//!
3//! Covers `_FILE` and `_COMMAND` (issues #2006 and #2011,
4//! [ADR-0089](../../docs/adrs/adr-0089.md),
5//! [ADR-0090](../../docs/adrs/adr-0090.md),
6//! [STYLE-0030](../../docs/STYLE_GUIDE.md)).
7//!
8//! Every secret omni-dev reads from the environment (or the settings.json
9//! `env` fallback) is registered in [`SECRET_ENV_VARS`] and read only through
10//! [`secret_var`] / [`secret_var_any`]. Each accepts a `<NAME>_FILE` companion
11//! naming a file whose contents are the secret — the Docker/Kubernetes secrets
12//! convention — so a secret can stay out of `env` listings,
13//! `/proc/<pid>/environ`, shell history, child processes, and settings.json.
14//! Each also accepts a `<NAME>_COMMAND` companion naming a helper program
15//! whose output is the secret, fetched on demand from a store that does not
16//! keep it as plaintext on disk — see the `command` submodule for its rules.
17//!
18//! The rules, each pinned by a test below:
19//!
20//! - Empty values (`NAME=`, `NAME_FILE=`) count as unset, so an exported
21//!   variable can still be neutralised for one invocation.
22//! - Two of `NAME`, `NAME_FILE` and `NAME_COMMAND` set **in the same layer**
23//!   is an error naming them. Layers (process env, then the active profile's
24//!   or base settings `env`) are resolved as triples through
25//!   [`EnvSource::var_triple`], so a `NAME_FILE` in the process env overrides
26//!   a `NAME` in settings.json.
27//! - Aliases keep their existing precedence: the first name whose triple is
28//!   set wins, and a conflict is only ever within one name's triple.
29//! - The path must be absolute; symlinks are followed and the **target** must
30//!   be a regular file. On Unix it must be either owned by the effective uid
31//!   and owner-only (`mode & 0o077 == 0`), or owned by root and not writable
32//!   by group or other (`mode & 0o022 == 0`) — the shape of Kubernetes and
33//!   Docker Swarm secrets. Windows has no check.
34//! - Exactly one trailing `\n` or `\r\n` is trimmed; an empty result is an
35//!   error, not "unset".
36//! - Errors name variables, paths, modes and uids — never the file's bytes.
37
38use std::fmt;
39use std::io::Read;
40use std::path::{Path, PathBuf};
41
42use thiserror::Error;
43
44use crate::utils::env::EnvSource;
45use crate::utils::secret::Secret;
46
47mod command;
48
49pub use command::{TIMEOUT_VAR as COMMAND_TIMEOUT_VAR, TTL_VAR as COMMAND_TTL_VAR};
50
51/// Suffix of the companion variable naming a file that holds the secret.
52pub const FILE_SUFFIX: &str = "_FILE";
53
54/// Suffix of the companion variable naming a helper command whose output is
55/// the secret (ADR-0090).
56pub const COMMAND_SUFFIX: &str = "_COMMAND";
57
58/// Every secret environment variable omni-dev reads. Each is read only
59/// through this module and so always accepts a `<NAME>_FILE` companion.
60///
61/// The grep guard in this module's tests fails the build if a secret-shaped
62/// variable is read anywhere else, or if a new one is neither listed here nor
63/// in [`EXEMPT_SECRET_ENV_VARS`].
64pub const SECRET_ENV_VARS: &[&str] = &[
65    // AI backends
66    "ANTHROPIC_API_KEY",
67    "CLAUDE_API_KEY",
68    "ANTHROPIC_AUTH_TOKEN",
69    "OPENAI_API_KEY",
70    "OPENAI_AUTH_TOKEN",
71    // Jev
72    "TYPESAFE_API_KEY",
73    "OMNI_DEV_JEV_API_KEY",
74    // Atlassian
75    "ATLASSIAN_API_TOKEN",
76    "ATLASSIAN_PAT",
77    // Datadog
78    "DATADOG_API_KEY",
79    "DATADOG_APP_KEY",
80    // Gmail (the client secret is exempt — see EXEMPT_SECRET_ENV_VARS)
81    "GMAIL_REFRESH_TOKEN",
82    // Drive
83    "DRIVE_CLIENT_SECRET",
84    "DRIVE_REFRESH_TOKEN",
85    // Snowflake
86    "SNOWFLAKE_TOKEN",
87    "SNOWFLAKE_PRIVATE_KEY",
88    // Browser bridge
89    "OMNI_BRIDGE_TOKEN",
90];
91
92/// Secret-shaped names that are deliberately **not** read through this
93/// resolver, each with the reason. Keep this list short: every entry is an
94/// exception to STYLE-0030.
95pub const EXEMPT_SECRET_ENV_VARS: &[(&str, &str)] = &[
96    (
97        "GH_TOKEN",
98        "GitHub CLI child-environment output, never read by omni-dev; PAT mode \
99         preserves gh's own credential resolution and App mode mints this value",
100    ),
101    (
102        "GITHUB_TOKEN",
103        "GitHub CLI child-environment output, never read by omni-dev; set alongside \
104         GH_TOKEN so inherited credentials cannot override App authentication",
105    ),
106    (
107        "GMAIL_CLIENT_SECRET",
108        "GMAIL_CLIENT_SECRET_FILE already names a Google client_secret.json for \
109         `gmail auth import`; an installed-app OAuth client secret is not \
110         confidential, and that command already reads it from a file (ADR-0089)",
111    ),
112    (
113        "SNOWFLAKE_PRIVATE_KEY_PASSPHRASE",
114        "only read to reject encrypted keys, which are unsupported; include it \
115         once encrypted keys are supported (#2006)",
116    ),
117    (
118        "PROGRAMMATIC_ACCESS_TOKEN",
119        "a Snowflake authenticator wire value, not an environment variable",
120    ),
121];
122
123/// Why a secret could not be resolved. Messages name variables, paths, modes
124/// and uids only — never the secret's bytes.
125#[derive(Debug, Error)]
126pub enum SecretEnvError {
127    /// `NAME` and `NAME_FILE` are both set in the same layer.
128    #[error("both {name} and {file_var} are set; set only one of them")]
129    Conflict {
130        /// The variable holding the secret directly.
131        name: String,
132        /// Its `_FILE` companion.
133        file_var: String,
134    },
135
136    /// Two or more of `NAME`, `NAME_FILE` and `NAME_COMMAND` are set in the
137    /// same layer, at least one of them the command.
138    #[error("{} are set; set only one of them", join_names(set))]
139    CommandConflict {
140        /// The variables that are set, in `NAME`, `NAME_FILE`, `NAME_COMMAND`
141        /// order.
142        set: Vec<String>,
143    },
144
145    /// The `_COMMAND` value cannot be split into a program and arguments.
146    #[error("cannot run the command named by {command_var}: {reason}")]
147    CommandSplit {
148        /// The variable naming the command.
149        command_var: String,
150        /// What is wrong with it.
151        reason: &'static str,
152    },
153
154    /// The command's program could not be started.
155    #[error("cannot run {program}, named by {command_var}: {source}{hint}")]
156    CommandSpawn {
157        /// The variable naming the command.
158        command_var: String,
159        /// The program (`argv[0]`); the arguments are never shown.
160        program: String,
161        /// A remedy for a program not found, or empty.
162        hint: &'static str,
163        /// The underlying I/O error.
164        source: std::io::Error,
165    },
166
167    /// The command ran but did not exit successfully.
168    #[error("{program}, named by {command_var}, failed ({status}){detail}")]
169    CommandFailed {
170        /// The variable naming the command.
171        command_var: String,
172        /// The program (`argv[0]`); the arguments are never shown.
173        program: String,
174        /// `exit code N` or the signal that ended it.
175        status: String,
176        /// Its escaped, truncated standard error as `: <text>`, or empty.
177        detail: String,
178    },
179
180    /// The command did not finish in time and was killed.
181    #[error(
182        "{program}, named by {command_var}, did not finish within {secs}s and was killed; \
183         raise {COMMAND_TIMEOUT_VAR} if it is waiting for you to approve a prompt"
184    )]
185    CommandTimedOut {
186        /// The variable naming the command.
187        command_var: String,
188        /// The program (`argv[0]`); the arguments are never shown.
189        program: String,
190        /// The timeout that expired.
191        secs: u64,
192    },
193
194    /// The command wrote more than the accepted amount to standard output.
195    #[error("{program}, named by {command_var}, wrote more than {limit} bytes of output")]
196    CommandOutputTooLarge {
197        /// The variable naming the command.
198        command_var: String,
199        /// The program (`argv[0]`); the arguments are never shown.
200        program: String,
201        /// The most output accepted.
202        limit: usize,
203    },
204
205    /// The command succeeded but wrote nothing (after trimming one newline).
206    #[error("{program}, named by {command_var}, printed no secret")]
207    CommandEmptyOutput {
208        /// The variable naming the command.
209        command_var: String,
210        /// The program (`argv[0]`); the arguments are never shown.
211        program: String,
212    },
213
214    /// The command's output is not valid UTF-8.
215    #[error("the output of {program}, named by {command_var}, is not valid UTF-8")]
216    CommandNotUtf8 {
217        /// The variable naming the command.
218        command_var: String,
219        /// The program (`argv[0]`); the arguments are never shown.
220        program: String,
221    },
222
223    /// The `_FILE` value is not an absolute path.
224    #[error("{file_var} must be an absolute path, got '{}'", path.display())]
225    RelativePath {
226        /// The variable naming the file.
227        file_var: String,
228        /// The offending path.
229        path: PathBuf,
230    },
231
232    /// The file could not be opened, inspected, or read.
233    #[error("cannot read the secret file {} named by {file_var}: {source}", path.display())]
234    Unreadable {
235        /// The variable naming the file.
236        file_var: String,
237        /// The file's path.
238        path: PathBuf,
239        /// The underlying I/O error.
240        source: std::io::Error,
241    },
242
243    /// The file could not be written.
244    #[error("cannot write the secret file {} named by {file_var}: {source}", path.display())]
245    Unwritable {
246        /// The key naming the file.
247        file_var: String,
248        /// The file's path.
249        path: PathBuf,
250        /// The underlying I/O error.
251        source: std::io::Error,
252    },
253
254    /// An existing secret file to be replaced holds JSON, which no secret
255    /// this resolver stores does: most likely a `client_secret.json`.
256    #[error(
257        "the secret file {} named by {file_var} holds JSON, not a bare secret; refusing to \
258         replace it. Point {file_var} at a file of its own",
259        path.display()
260    )]
261    HoldsJson {
262        /// The key naming the file.
263        file_var: String,
264        /// The file's path.
265        path: PathBuf,
266    },
267
268    /// The path (after following symlinks) is not a regular file.
269    #[error("the secret file {} named by {file_var} is not a regular file", path.display())]
270    NotAFile {
271        /// The variable naming the file.
272        file_var: String,
273        /// The file's path.
274        path: PathBuf,
275    },
276
277    /// The file is empty (after trimming one trailing newline).
278    #[error("the secret file {} named by {file_var} is empty", path.display())]
279    Empty {
280        /// The variable naming the file.
281        file_var: String,
282        /// The file's path.
283        path: PathBuf,
284    },
285
286    /// The file's contents are not valid UTF-8.
287    #[error("the secret file {} named by {file_var} is not valid UTF-8", path.display())]
288    NotUtf8 {
289        /// The variable naming the file.
290        file_var: String,
291        /// The file's path.
292        path: PathBuf,
293    },
294
295    /// A file owned by the current user grants group or other permission
296    /// bits.
297    #[error(
298        "the secret file {path} named by {file_var} has mode {mode}, which grants group or \
299         other access; restrict it with `chmod 600 {path}`",
300        path = path.display(),
301        mode = OctalMode(*mode)
302    )]
303    LoosePermissions {
304        /// The variable naming the file.
305        file_var: String,
306        /// The file's path.
307        path: PathBuf,
308        /// The permission bits found (`st_mode & 0o777`).
309        mode: u32,
310    },
311
312    /// A root-owned file that group or other users can write.
313    #[error(
314        "the secret file {path} named by {file_var} is owned by root but has mode {mode}, \
315         which lets group or other users write it; remove that with `chmod go-w {path}`",
316        path = path.display(),
317        mode = OctalMode(*mode)
318    )]
319    WritableByOthers {
320        /// The variable naming the file.
321        file_var: String,
322        /// The file's path.
323        path: PathBuf,
324        /// The permission bits found (`st_mode & 0o777`).
325        mode: u32,
326    },
327
328    /// The file is owned by neither the current user nor root.
329    #[error(
330        "the secret file {} named by {file_var} is owned by uid {owner}, which is neither \
331         the current user (uid {expected}) nor root; take ownership of it (or copy it) and \
332         `chmod 600` it",
333        path.display()
334    )]
335    WrongOwner {
336        /// The variable naming the file.
337        file_var: String,
338        /// The file's path.
339        path: PathBuf,
340        /// The file's owner uid.
341        owner: u32,
342        /// The effective uid of this process.
343        expected: u32,
344    },
345}
346
347/// `a, b and c` / `a and b`, for a conflict message.
348fn join_names(names: &[String]) -> String {
349    match names {
350        [] => String::new(),
351        [only] => only.clone(),
352        [init @ .., last] => format!("{} and {last}", init.join(", ")),
353    }
354}
355
356/// Formats permission bits as `0644`.
357struct OctalMode(u32);
358
359impl fmt::Display for OctalMode {
360    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
361        write!(f, "{:04o}", self.0)
362    }
363}
364
365/// Returns `<name>_FILE`.
366#[must_use]
367pub fn file_var_name(name: &str) -> String {
368    format!("{name}{FILE_SUFFIX}")
369}
370
371/// Returns `<name>_COMMAND`.
372#[must_use]
373pub fn command_var_name(name: &str) -> String {
374    format!("{name}{COMMAND_SUFFIX}")
375}
376
377/// Resolves the secret `name`, from `name` itself, from the file its
378/// `name_FILE` companion points at, or from the output of the helper its
379/// `name_COMMAND` companion names. `Ok(None)` means none is set.
380///
381/// # Errors
382///
383/// See [`SecretEnvError`]: two set in one layer; a `_FILE` that is relative,
384/// unreadable, not a regular file, empty, not UTF-8, or neither the current
385/// user's and owner-only nor root's and read-only to others; or a `_COMMAND`
386/// that cannot be split or run, fails, times out, or prints nothing usable.
387pub fn secret_var(env: &impl EnvSource, name: &str) -> Result<Option<Secret>, SecretEnvError> {
388    debug_assert!(
389        SECRET_ENV_VARS.contains(&name),
390        "{name} must be registered in SECRET_ENV_VARS"
391    );
392    let file_var = file_var_name(name);
393    let command_var = command_var_name(name);
394    let (value, file, command) = env.var_triple(name, &file_var, &command_var);
395    let value = value.filter(|v| !v.is_empty());
396    let file = file.filter(|v| !v.is_empty());
397    let command = command.filter(|v| !v.is_empty());
398    match (value.as_deref(), file.as_deref(), command.as_deref()) {
399        (value, file, None) => resolve_secret_pair(name, value, &file_var, file),
400        (None, None, Some(command)) => {
401            command::resolve(&command_var, command, command::Limits::from_env(env)).map(Some)
402        }
403        (value, file, Some(_)) => Err(SecretEnvError::CommandConflict {
404            set: [
405                value.map(|_| name),
406                file.map(|_| file_var.as_str()),
407                Some(command_var.as_str()),
408            ]
409            .into_iter()
410            .flatten()
411            .map(str::to_string)
412            .collect(),
413        }),
414    }
415}
416
417/// Resolves one already-read secret pair: the secret held directly
418/// (`value`, labelled `name`) or a path to a file holding it (`file`,
419/// labelled `file_var`). `Ok(None)` means neither is set.
420///
421/// [`secret_var`]'s rules, for secrets that are not environment variables:
422/// the named-account `client_secret`/`client_secret_file` and
423/// `refresh_token`/`refresh_token_file` fields in settings.json (#2008). The
424/// labels only name the pair in errors, e.g.
425/// `drive.accounts.work.client_secret`.
426///
427/// # Errors
428///
429/// As [`secret_var`].
430pub fn resolve_secret_pair(
431    name: &str,
432    value: Option<&str>,
433    file_var: &str,
434    file: Option<&str>,
435) -> Result<Option<Secret>, SecretEnvError> {
436    let value = value.filter(|v| !v.is_empty());
437    let file = file.filter(|v| !v.is_empty());
438    match (value, file) {
439        (Some(_), Some(_)) => Err(SecretEnvError::Conflict {
440            name: name.to_string(),
441            file_var: file_var.to_string(),
442        }),
443        (Some(value), None) => Ok(Some(Secret::new(value))),
444        (None, Some(path)) => read_secret_file(file_var, Path::new(path)).map(Some),
445        (None, None) => Ok(None),
446    }
447}
448
449/// [`secret_var`] over aliases in precedence order: the first name whose
450/// pair is set wins. A conflict is only ever within one name's pair.
451///
452/// # Errors
453///
454/// As [`secret_var`], for the first name whose pair is set.
455pub fn secret_var_any(
456    env: &impl EnvSource,
457    names: &[&str],
458) -> Result<Option<Secret>, SecretEnvError> {
459    for name in names {
460        if let Some(secret) = secret_var(env, name)? {
461            return Ok(Some(secret));
462        }
463    }
464    Ok(None)
465}
466
467/// Whether any of `name`, `name_FILE` or `name_COMMAND` is set (non-empty).
468///
469/// Reads no file and runs no command — a status report must never prompt. For
470/// presence-only reports such as `auth status`.
471#[must_use]
472pub fn secret_var_is_set(env: &impl EnvSource, name: &str) -> bool {
473    debug_assert!(
474        SECRET_ENV_VARS.contains(&name),
475        "{name} must be registered in SECRET_ENV_VARS"
476    );
477    let (value, file, command) =
478        env.var_triple(name, &file_var_name(name), &command_var_name(name));
479    let set = |v: Option<String>| v.is_some_and(|v| !v.is_empty());
480    set(value) || set(file) || set(command)
481}
482
483/// Reads a secret from `path`, which the variable `file_var` named.
484///
485/// Checks the path is absolute, follows symlinks, requires a regular file
486/// that is either the current user's and owner-only or root's and not
487/// writable by others (Unix), and trims exactly one trailing newline.
488///
489/// # Errors
490///
491/// See [`SecretEnvError`].
492pub fn read_secret_file(file_var: &str, path: &Path) -> Result<Secret, SecretEnvError> {
493    if !path.is_absolute() {
494        return Err(SecretEnvError::RelativePath {
495            file_var: file_var.to_string(),
496            path: path.to_path_buf(),
497        });
498    }
499    let unreadable = |source| SecretEnvError::Unreadable {
500        file_var: file_var.to_string(),
501        path: path.to_path_buf(),
502        source,
503    };
504    // Stat before opening so a FIFO or device never blocks the open, then
505    // re-check the opened handle so the checks apply to what is actually read.
506    check_metadata(
507        file_var,
508        path,
509        &std::fs::metadata(path).map_err(unreadable)?,
510    )?;
511    let mut file = std::fs::File::open(path).map_err(unreadable)?;
512    check_metadata(file_var, path, &file.metadata().map_err(unreadable)?)?;
513    let mut bytes = Vec::new();
514    file.read_to_end(&mut bytes).map_err(unreadable)?;
515    let mut text = String::from_utf8(bytes).map_err(|_| SecretEnvError::NotUtf8 {
516        file_var: file_var.to_string(),
517        path: path.to_path_buf(),
518    })?;
519    trim_one_newline(&mut text);
520    if text.is_empty() {
521        return Err(SecretEnvError::Empty {
522            file_var: file_var.to_string(),
523            path: path.to_path_buf(),
524        });
525    }
526    Ok(Secret::new(text))
527}
528
529/// Stores `secret` in the file at `path`, which the key `file_var` named.
530///
531/// [`plan_secret_file_write`] then [`SecretFileWrite::write`], for a single
532/// file; the next [`read_secret_file`] returns `secret`. Returns whether
533/// anything was written.
534///
535/// # Errors
536///
537/// As [`plan_secret_file_write`] and [`SecretFileWrite::write`].
538pub fn write_secret_file(
539    file_var: &str,
540    path: &Path,
541    secret: &Secret,
542) -> Result<bool, SecretEnvError> {
543    match plan_secret_file_write(file_var, path, secret)? {
544        Some(write) => write.write(secret).map(|()| true),
545        None => Ok(false),
546    }
547}
548
549/// A checked, not yet performed, write of a secret file: see
550/// [`plan_secret_file_write`].
551#[derive(Debug)]
552pub struct SecretFileWrite {
553    file_var: String,
554    path: PathBuf,
555    target: PathBuf,
556    replaces_existing: bool,
557}
558
559/// Checks that `secret` can be stored in the file at `path`, which the key
560/// `file_var` named, without writing anything. `Ok(None)` means the file
561/// already holds `secret`, so there is nothing to write.
562///
563/// Used when a login or import has a new value for a settings.json secret
564/// field whose `_file` companion is set (#2008): the value goes where the
565/// user said it lives, never back into settings.json. Checking every file
566/// before writing any keeps a multi-file update from failing half way on
567/// anything but an I/O error.
568///
569/// - A file that already holds `secret` is left alone. That keeps a file
570///   shared by several accounts untouched, and keeps a root-owned read-only
571///   mount usable while the secret is unchanged.
572/// - An existing file is only replaced when [`read_secret_file`] accepts it
573///   (or it is empty) and it doesn't hold JSON, so a mispointed path can't
574///   destroy a `client_secret.json` or a file that isn't the user's own.
575/// - Symlinks are followed to the target, as [`read_secret_file`] does; a
576///   dangling one is refused rather than replaced by a regular file.
577/// - The parent directory must already exist.
578///
579/// # Errors
580///
581/// [`SecretEnvError::RelativePath`] for a relative path,
582/// [`SecretEnvError::HoldsJson`] for a JSON file, [`read_secret_file`]'s
583/// errors for an existing file it rejects, and
584/// [`SecretEnvError::Unwritable`] when the directory is missing.
585pub fn plan_secret_file_write(
586    file_var: &str,
587    path: &Path,
588    secret: &Secret,
589) -> Result<Option<SecretFileWrite>, SecretEnvError> {
590    if !path.is_absolute() {
591        return Err(SecretEnvError::RelativePath {
592            file_var: file_var.to_string(),
593            path: path.to_path_buf(),
594        });
595    }
596    let unwritable = |source| SecretEnvError::Unwritable {
597        file_var: file_var.to_string(),
598        path: path.to_path_buf(),
599        source,
600    };
601    let (target, replaces_existing) = match std::fs::symlink_metadata(path) {
602        Err(e) if e.kind() == std::io::ErrorKind::NotFound => (path.to_path_buf(), false),
603        Err(e) => return Err(unwritable(e)),
604        Ok(_) => {
605            match read_secret_file(file_var, path) {
606                Ok(current) if current.expose_secret() == secret.expose_secret() => {
607                    return Ok(None)
608                }
609                Ok(current) if current.expose_secret().trim_start().starts_with('{') => {
610                    return Err(SecretEnvError::HoldsJson {
611                        file_var: file_var.to_string(),
612                        path: path.to_path_buf(),
613                    })
614                }
615                Ok(_) | Err(SecretEnvError::Empty { .. }) => {}
616                Err(e) => return Err(e),
617            }
618            (std::fs::canonicalize(path).map_err(unwritable)?, true)
619        }
620    };
621    if !target.parent().is_some_and(Path::is_dir) {
622        return Err(unwritable(std::io::Error::new(
623            std::io::ErrorKind::NotFound,
624            "its directory does not exist",
625        )));
626    }
627    Ok(Some(SecretFileWrite {
628        file_var: file_var.to_string(),
629        path: path.to_path_buf(),
630        target,
631        replaces_existing,
632    }))
633}
634
635impl SecretFileWrite {
636    /// Whether this write replaces a file that exists (with another value).
637    #[must_use]
638    pub fn replaces_existing(&self) -> bool {
639        self.replaces_existing
640    }
641
642    /// Writes `secret` (plus a trailing newline) atomically: a `0600` temp
643    /// file with a unique name is created beside the target, synced, and
644    /// renamed over it. A failed write leaves no temp file behind.
645    ///
646    /// # Errors
647    ///
648    /// [`SecretEnvError::Unwritable`] when the file can't be written.
649    pub fn write(self, secret: &Secret) -> Result<(), SecretEnvError> {
650        use std::io::Write;
651        let unwritable = |source| SecretEnvError::Unwritable {
652            file_var: self.file_var.clone(),
653            path: self.path.clone(),
654            source,
655        };
656        let dir = self.target.parent().unwrap_or(Path::new("/"));
657        // tempfile creates the file owner-only (0600) on Unix and removes it
658        // on drop unless it is persisted.
659        let mut temp = tempfile::Builder::new()
660            .prefix(".omni-dev-secret-")
661            .tempfile_in(dir)
662            .map_err(unwritable)?;
663        temp.write_all(secret.expose_secret().as_bytes())
664            .and_then(|()| temp.write_all(b"\n"))
665            .and_then(|()| temp.as_file().sync_all())
666            .map_err(unwritable)?;
667        temp.persist(&self.target)
668            .map_err(|e| unwritable(e.error))?;
669        Ok(())
670    }
671}
672
673/// Removes exactly one trailing `\n` or `\r\n`, and nothing else: secrets may
674/// legitimately contain other whitespace.
675fn trim_one_newline(text: &mut String) {
676    if text.ends_with("\r\n") {
677        text.truncate(text.len() - 2);
678    } else if text.ends_with('\n') {
679        text.truncate(text.len() - 1);
680    }
681}
682
683/// Requires a regular file and, on Unix, passes [`check_unix_security`].
684fn check_metadata(
685    file_var: &str,
686    path: &Path,
687    metadata: &std::fs::Metadata,
688) -> Result<(), SecretEnvError> {
689    if !metadata.is_file() {
690        return Err(SecretEnvError::NotAFile {
691            file_var: file_var.to_string(),
692            path: path.to_path_buf(),
693        });
694    }
695    #[cfg(unix)]
696    {
697        use std::os::unix::fs::MetadataExt;
698        check_unix_security(
699            file_var,
700            path,
701            metadata.mode(),
702            metadata.uid(),
703            nix::unistd::geteuid().as_raw(),
704        )?;
705    }
706    Ok(())
707}
708
709/// The uid of `root`, whose read-only files are accepted as secrets.
710#[cfg(unix)]
711const ROOT_UID: u32 = 0;
712
713/// The pure half of [`check_metadata`]'s Unix check, so the root-owned and
714/// wrong-owner cases are testable without root.
715///
716/// - A **root-owned** file is accepted when no group or other user can write
717///   it (`mode & 0o022 == 0`). Group and other *read* is allowed, because
718///   that is how Kubernetes (`fsGroup`, default `0644`) and Docker Swarm
719///   (`0444`) project secrets. Only root could have put it there, so a
720///   same-user process can't have planted or swapped it.
721/// - A file **owned by the effective uid** must be owner-only
722///   (`mode & 0o077 == 0`): on a workstation there is no reason for anyone
723///   else to see it.
724/// - Any other owner is refused.
725#[cfg(unix)]
726fn check_unix_security(
727    file_var: &str,
728    path: &Path,
729    mode: u32,
730    owner: u32,
731    euid: u32,
732) -> Result<(), SecretEnvError> {
733    let mode = mode & 0o777;
734    if owner == ROOT_UID {
735        if mode & 0o022 != 0 {
736            return Err(SecretEnvError::WritableByOthers {
737                file_var: file_var.to_string(),
738                path: path.to_path_buf(),
739                mode,
740            });
741        }
742        return Ok(());
743    }
744    if owner != euid {
745        return Err(SecretEnvError::WrongOwner {
746            file_var: file_var.to_string(),
747            path: path.to_path_buf(),
748            owner,
749            expected: euid,
750        });
751    }
752    if mode & 0o077 != 0 {
753        return Err(SecretEnvError::LoosePermissions {
754            file_var: file_var.to_string(),
755            path: path.to_path_buf(),
756            mode,
757        });
758    }
759    Ok(())
760}
761
762#[cfg(test)]
763#[allow(clippy::unwrap_used, clippy::expect_used)]
764mod tests {
765    use super::*;
766    use crate::test_support::env::MapEnv;
767
768    const NAME: &str = "DATADOG_API_KEY";
769    const FILE_VAR: &str = "DATADOG_API_KEY_FILE";
770    const SECRET_BYTES: &str = "sekret-bytes-never-logged";
771
772    /// Writes `contents` to a fresh file with `mode` in its own temp dir.
773    fn secret_file(contents: &[u8], mode: u32) -> (tempfile::TempDir, PathBuf) {
774        let dir = tempfile::tempdir().unwrap();
775        let path = dir.path().join("secret");
776        std::fs::write(&path, contents).unwrap();
777        set_mode(&path, mode);
778        (dir, path)
779    }
780
781    #[cfg(unix)]
782    fn set_mode(path: &Path, mode: u32) {
783        use std::os::unix::fs::PermissionsExt;
784        std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
785    }
786
787    #[cfg(not(unix))]
788    fn set_mode(_path: &Path, _mode: u32) {}
789
790    fn env_with_file(path: &Path) -> MapEnv {
791        MapEnv::new().with(FILE_VAR, path.to_str().unwrap())
792    }
793
794    fn resolve(env: &MapEnv) -> Result<Option<String>, SecretEnvError> {
795        secret_var(env, NAME).map(|s| s.map(|s| s.expose_secret().to_string()))
796    }
797
798    #[test]
799    fn only_the_variable_is_used_verbatim() {
800        let env = MapEnv::new().with(NAME, " value with spaces \n");
801        assert_eq!(
802            resolve(&env).unwrap().as_deref(),
803            Some(" value with spaces \n")
804        );
805    }
806
807    #[test]
808    fn only_the_file_is_read() {
809        let (_dir, path) = secret_file(b"from-file\n", 0o600);
810        assert_eq!(
811            resolve(&env_with_file(&path)).unwrap().as_deref(),
812            Some("from-file")
813        );
814    }
815
816    #[test]
817    fn neither_set_is_none() {
818        assert!(resolve(&MapEnv::new()).unwrap().is_none());
819    }
820
821    #[test]
822    fn empty_values_count_as_unset() {
823        let env = MapEnv::new().with(NAME, "").with(FILE_VAR, "");
824        assert!(resolve(&env).unwrap().is_none());
825        assert!(!secret_var_is_set(&env, NAME));
826        // An empty NAME does not conflict with a set NAME_FILE.
827        let (_dir, path) = secret_file(b"f", 0o600);
828        let env = env_with_file(&path).with(NAME, "");
829        assert_eq!(resolve(&env).unwrap().as_deref(), Some("f"));
830    }
831
832    #[test]
833    fn both_set_is_a_conflict_naming_both() {
834        let (_dir, path) = secret_file(b"f", 0o600);
835        let env = env_with_file(&path).with(NAME, "v");
836        let err = resolve(&env).unwrap_err();
837        assert!(matches!(err, SecretEnvError::Conflict { .. }));
838        let msg = err.to_string();
839        assert!(msg.contains(NAME) && msg.contains(FILE_VAR), "{msg}");
840    }
841
842    #[test]
843    fn empty_file_is_an_error_not_unset() {
844        for contents in [&b""[..], b"\n", b"\r\n"] {
845            let (_dir, path) = secret_file(contents, 0o600);
846            let err = resolve(&env_with_file(&path)).unwrap_err();
847            assert!(matches!(err, SecretEnvError::Empty { .. }), "{contents:?}");
848        }
849    }
850
851    #[test]
852    fn missing_file_is_unreadable_and_names_the_path() {
853        let dir = tempfile::tempdir().unwrap();
854        let path = dir.path().join("absent");
855        let err = resolve(&env_with_file(&path)).unwrap_err();
856        assert!(matches!(err, SecretEnvError::Unreadable { .. }));
857        assert!(err.to_string().contains(path.to_str().unwrap()));
858        assert!(err.to_string().contains(FILE_VAR));
859    }
860
861    #[test]
862    fn a_directory_is_not_a_file() {
863        let dir = tempfile::tempdir().unwrap();
864        let err = resolve(&env_with_file(dir.path())).unwrap_err();
865        assert!(matches!(err, SecretEnvError::NotAFile { .. }));
866    }
867
868    #[test]
869    fn relative_path_is_rejected() {
870        let env = MapEnv::new().with(FILE_VAR, "secrets/key");
871        let err = resolve(&env).unwrap_err();
872        assert!(matches!(err, SecretEnvError::RelativePath { .. }));
873        assert!(err.to_string().contains("absolute"));
874    }
875
876    #[test]
877    fn trims_exactly_one_trailing_crlf() {
878        let (_dir, path) = secret_file(b"crlf\r\n", 0o600);
879        assert_eq!(
880            resolve(&env_with_file(&path)).unwrap().as_deref(),
881            Some("crlf")
882        );
883    }
884
885    #[test]
886    fn trims_only_one_newline_and_keeps_other_whitespace() {
887        let (_dir, path) = secret_file(b"  in ner \t\n\n", 0o600);
888        assert_eq!(
889            resolve(&env_with_file(&path)).unwrap().as_deref(),
890            Some("  in ner \t\n")
891        );
892    }
893
894    #[test]
895    fn non_utf8_is_rejected() {
896        let (_dir, path) = secret_file(&[0xff, 0xfe, b'x'], 0o600);
897        let err = resolve(&env_with_file(&path)).unwrap_err();
898        assert!(matches!(err, SecretEnvError::NotUtf8 { .. }));
899    }
900
901    #[cfg(unix)]
902    #[test]
903    fn mode_0600_and_0400_are_accepted() {
904        for mode in [0o600, 0o400] {
905            let (_dir, path) = secret_file(b"ok", mode);
906            assert_eq!(
907                resolve(&env_with_file(&path)).unwrap().as_deref(),
908                Some("ok")
909            );
910        }
911    }
912
913    #[cfg(unix)]
914    #[test]
915    fn mode_0644_is_rejected_with_the_mode_and_the_fix() {
916        let (_dir, path) = secret_file(SECRET_BYTES.as_bytes(), 0o644);
917        let err = resolve(&env_with_file(&path)).unwrap_err();
918        assert!(matches!(
919            err,
920            SecretEnvError::LoosePermissions { mode: 0o644, .. }
921        ));
922        let msg = err.to_string();
923        assert!(msg.contains("0644"), "{msg}");
924        assert!(
925            msg.contains(&format!("chmod 600 {}", path.display())),
926            "{msg}"
927        );
928        assert!(!msg.contains(SECRET_BYTES), "{msg}");
929    }
930
931    #[cfg(unix)]
932    #[test]
933    fn any_group_or_other_bit_is_rejected() {
934        for mode in [0o640, 0o604, 0o610, 0o601] {
935            let (_dir, path) = secret_file(b"x", mode);
936            // omni-dev: coverage ignore reason="the loop above runs this 4 times and every mode fails the same way; verified locally that llvm-cov still reports 0 hits on the matches!( line — a region-attribution artifact on the nested assert!/matches! macro call, not an untested path"
937            assert!(
938                matches!(
939                    resolve(&env_with_file(&path)).unwrap_err(),
940                    SecretEnvError::LoosePermissions { .. }
941                ),
942                "{mode:o}"
943            );
944            // omni-dev: coverage end
945        }
946    }
947
948    #[cfg(unix)]
949    #[test]
950    fn another_users_file_is_rejected_by_the_pure_check() {
951        let path = Path::new("/home/other/key");
952        let err = check_unix_security(FILE_VAR, path, 0o100_600, 502, 501).unwrap_err();
953        assert!(matches!(
954            err,
955            SecretEnvError::WrongOwner {
956                owner: 502,
957                expected: 501,
958                ..
959            }
960        ));
961        let msg = err.to_string();
962        assert!(msg.contains("uid 502") && msg.contains("uid 501"), "{msg}");
963        // The file type bits are masked off before the mode check.
964        assert!(check_unix_security(FILE_VAR, path, 0o100_600, 501, 501).is_ok());
965    }
966
967    /// Container secrets are root-owned and often group/world-readable:
968    /// Kubernetes projects `0644` (or `0440` with `fsGroup`), Docker Swarm
969    /// `0444`. They are accepted whether the process runs as root or not.
970    #[cfg(unix)]
971    #[test]
972    fn root_owned_read_only_secrets_are_accepted() {
973        let path = Path::new("/run/secrets/key");
974        for mode in [0o100_400, 0o100_440, 0o100_444, 0o100_600, 0o100_644] {
975            for euid in [0, 501] {
976                assert!(
977                    check_unix_security(FILE_VAR, path, mode, ROOT_UID, euid).is_ok(),
978                    "mode {mode:o} euid {euid}"
979                );
980            }
981        }
982    }
983
984    #[cfg(unix)]
985    #[test]
986    fn root_owned_secrets_writable_by_others_are_rejected() {
987        let path = Path::new("/run/secrets/key");
988        for mode in [0o100_664, 0o100_646, 0o100_666, 0o100_620] {
989            let err = check_unix_security(FILE_VAR, path, mode, ROOT_UID, 501).unwrap_err();
990            assert!(
991                matches!(err, SecretEnvError::WritableByOthers { .. }),
992                "mode {mode:o}"
993            );
994            let msg = err.to_string();
995            assert!(msg.contains("chmod go-w /run/secrets/key"), "{msg}");
996        }
997    }
998
999    /// Running as root doesn't loosen the rule for root's own files beyond
1000    /// the container shape: still no group/other write.
1001    #[cfg(unix)]
1002    #[test]
1003    fn root_process_still_rejects_group_writable_root_files() {
1004        let path = Path::new("/run/secrets/key");
1005        assert!(matches!(
1006            check_unix_security(FILE_VAR, path, 0o100_660, ROOT_UID, ROOT_UID).unwrap_err(),
1007            SecretEnvError::WritableByOthers { .. }
1008        ));
1009    }
1010
1011    #[cfg(unix)]
1012    #[test]
1013    fn symlinks_are_followed_and_the_target_is_checked() {
1014        let (dir, target) = secret_file(b"via-link\n", 0o600);
1015        let link = dir.path().join("link");
1016        std::os::unix::fs::symlink(&target, &link).unwrap();
1017        assert_eq!(
1018            resolve(&env_with_file(&link)).unwrap().as_deref(),
1019            Some("via-link")
1020        );
1021
1022        set_mode(&target, 0o644);
1023        assert!(matches!(
1024            resolve(&env_with_file(&link)).unwrap_err(),
1025            SecretEnvError::LoosePermissions { mode: 0o644, .. }
1026        ));
1027    }
1028
1029    #[test]
1030    fn no_error_message_contains_the_secret_bytes() {
1031        let (_dir, path) = secret_file(SECRET_BYTES.as_bytes(), 0o600);
1032        let env = env_with_file(&path).with(NAME, SECRET_BYTES);
1033        let err = resolve(&env).unwrap_err();
1034        assert!(!err.to_string().contains(SECRET_BYTES));
1035        assert!(!format!("{err:?}").contains(SECRET_BYTES));
1036    }
1037
1038    #[test]
1039    fn aliases_keep_precedence_and_only_conflict_within_a_pair() {
1040        let (_dir, path) = secret_file(b"from-file", 0o600);
1041        let names = ["OPENAI_API_KEY", "OPENAI_AUTH_TOKEN"];
1042
1043        // A_FILE with B set: A wins, no conflict.
1044        let env = MapEnv::new()
1045            .with("OPENAI_API_KEY_FILE", path.to_str().unwrap())
1046            .with("OPENAI_AUTH_TOKEN", "b");
1047        let got = secret_var_any(&env, &names).unwrap().unwrap();
1048        assert_eq!(got.expose_secret(), "from-file");
1049
1050        // Only the lower-precedence alias's file is set.
1051        let env = MapEnv::new().with("OPENAI_AUTH_TOKEN_FILE", path.to_str().unwrap());
1052        let got = secret_var_any(&env, &names).unwrap().unwrap();
1053        assert_eq!(got.expose_secret(), "from-file");
1054
1055        // A conflict within the winning pair is still an error.
1056        let env = MapEnv::new()
1057            .with("OPENAI_API_KEY", "a")
1058            .with("OPENAI_API_KEY_FILE", path.to_str().unwrap());
1059        assert!(matches!(
1060            secret_var_any(&env, &names).unwrap_err(),
1061            SecretEnvError::Conflict { .. }
1062        ));
1063
1064        assert!(secret_var_any(&MapEnv::new(), &names).unwrap().is_none());
1065    }
1066
1067    #[test]
1068    fn is_set_does_not_read_the_file() {
1069        let env = MapEnv::new().with(FILE_VAR, "/definitely/not/there");
1070        assert!(secret_var_is_set(&env, NAME));
1071        assert!(secret_var_is_set(&MapEnv::new().with(NAME, "v"), NAME));
1072        assert!(!secret_var_is_set(&MapEnv::new(), NAME));
1073    }
1074
1075    // ── `_COMMAND` (#2011, ADR-0090) ──
1076
1077    const COMMAND_VAR: &str = "DATADOG_API_KEY_COMMAND";
1078
1079    /// Zero disables the cache, so a test never sees another's result.
1080    fn command_env(command: &str) -> MapEnv {
1081        MapEnv::new()
1082            .with(COMMAND_VAR, command)
1083            .with(COMMAND_TTL_VAR, "0")
1084    }
1085
1086    #[cfg(unix)]
1087    #[test]
1088    fn only_the_command_is_run() {
1089        let env = command_env("/usr/bin/printf %s from-command");
1090        assert_eq!(resolve(&env).unwrap().as_deref(), Some("from-command"));
1091    }
1092
1093    #[test]
1094    fn is_set_counts_a_command_without_running_it() {
1095        // Would fail if run: the program does not exist.
1096        let env = MapEnv::new().with(COMMAND_VAR, "/definitely/not/there");
1097        assert!(secret_var_is_set(&env, NAME));
1098        assert!(!secret_var_is_set(
1099            &MapEnv::new().with(COMMAND_VAR, ""),
1100            NAME
1101        ));
1102    }
1103
1104    #[cfg(unix)]
1105    #[test]
1106    fn is_set_never_spawns_the_command() {
1107        let dir = tempfile::tempdir().unwrap();
1108        let marker = dir.path().join("ran");
1109        let env = command_env(&format!("/usr/bin/touch {}", marker.display()));
1110        assert!(secret_var_is_set(&env, NAME));
1111        assert!(!marker.exists(), "a status check ran the helper");
1112    }
1113
1114    #[test]
1115    fn an_empty_command_counts_as_unset() {
1116        let env = MapEnv::new().with(COMMAND_VAR, "").with(NAME, "direct");
1117        assert_eq!(resolve(&env).unwrap().as_deref(), Some("direct"));
1118        assert_eq!(resolve(&MapEnv::new().with(COMMAND_VAR, "")).unwrap(), None);
1119    }
1120
1121    #[test]
1122    fn a_command_with_either_other_source_is_a_conflict_naming_every_one_set() {
1123        let (_tmp, path) = secret_file(b"x\n", 0o600);
1124        let file = path.to_str().unwrap();
1125        let cases: [(MapEnv, &str); 3] = [
1126            (
1127                MapEnv::new().with(NAME, "v").with(COMMAND_VAR, "/bin/true"),
1128                "DATADOG_API_KEY and DATADOG_API_KEY_COMMAND are set",
1129            ),
1130            (
1131                MapEnv::new()
1132                    .with(FILE_VAR, file)
1133                    .with(COMMAND_VAR, "/bin/true"),
1134                "DATADOG_API_KEY_FILE and DATADOG_API_KEY_COMMAND are set",
1135            ),
1136            (
1137                MapEnv::new()
1138                    .with(NAME, "v")
1139                    .with(FILE_VAR, file)
1140                    .with(COMMAND_VAR, "/bin/true"),
1141                "DATADOG_API_KEY, DATADOG_API_KEY_FILE and DATADOG_API_KEY_COMMAND are set",
1142            ),
1143        ];
1144        for (env, expected) in cases {
1145            let err = resolve(&env).unwrap_err();
1146            assert!(
1147                matches!(err, SecretEnvError::CommandConflict { .. }),
1148                "{err}"
1149            );
1150            assert!(err.to_string().starts_with(expected), "{err}");
1151            assert!(err.to_string().ends_with("set only one of them"), "{err}");
1152        }
1153    }
1154
1155    #[test]
1156    fn a_conflict_never_runs_the_command() {
1157        let dir = tempfile::tempdir().unwrap();
1158        let marker = dir.path().join("ran");
1159        let env = MapEnv::new()
1160            .with(NAME, "v")
1161            .with(COMMAND_VAR, &format!("/usr/bin/touch {}", marker.display()));
1162        assert!(resolve(&env).is_err());
1163        assert!(!marker.exists());
1164    }
1165
1166    #[cfg(unix)]
1167    #[test]
1168    fn aliases_keep_precedence_across_commands() {
1169        let names = ["OPENAI_API_KEY", "OPENAI_AUTH_TOKEN"];
1170        let env = MapEnv::new()
1171            .with("OPENAI_API_KEY_COMMAND", "/usr/bin/printf %s first")
1172            .with("OPENAI_AUTH_TOKEN", "second")
1173            .with(COMMAND_TTL_VAR, "0");
1174        assert_eq!(
1175            secret_var_any(&env, &names)
1176                .unwrap()
1177                .unwrap()
1178                .expose_secret(),
1179            "first"
1180        );
1181        // A conflict within the winning name is still an error; the later
1182        // alias is never consulted.
1183        let env = MapEnv::new()
1184            .with("OPENAI_API_KEY", "a")
1185            .with("OPENAI_API_KEY_COMMAND", "/bin/true")
1186            .with("OPENAI_AUTH_TOKEN", "b");
1187        assert!(secret_var_any(&env, &names).is_err());
1188    }
1189
1190    #[cfg(unix)]
1191    #[test]
1192    fn a_failing_command_reports_the_variable_and_never_the_arguments() {
1193        let env = command_env("/bin/sh -c 'exit 7' --token=hunter2");
1194        let err = resolve(&env).unwrap_err();
1195        let text = err.to_string();
1196        assert!(text.contains(COMMAND_VAR), "{text}");
1197        assert!(text.contains("exit code 7"), "{text}");
1198        assert!(!text.contains("hunter2"), "{text}");
1199    }
1200
1201    #[test]
1202    #[cfg(unix)]
1203    fn the_command_knobs_are_read_through_the_env_source() {
1204        // A 1s timeout from the MapEnv, not the process environment.
1205        let env = MapEnv::new()
1206            .with(COMMAND_VAR, "/bin/sleep 5")
1207            .with(COMMAND_TIMEOUT_VAR, "1")
1208            .with(COMMAND_TTL_VAR, "0");
1209        let err = resolve(&env).unwrap_err();
1210        assert!(
1211            matches!(err, SecretEnvError::CommandTimedOut { .. }),
1212            "{err}"
1213        );
1214    }
1215
1216    #[test]
1217    fn join_names_handles_every_arity() {
1218        let names = |v: &[&str]| v.iter().map(ToString::to_string).collect::<Vec<_>>();
1219        assert_eq!(join_names(&[]), "");
1220        assert_eq!(join_names(&names(&["A"])), "A");
1221        assert_eq!(join_names(&names(&["A", "B"])), "A and B");
1222        assert_eq!(join_names(&names(&["A", "B", "C"])), "A, B and C");
1223    }
1224
1225    #[test]
1226    fn registry_and_exemptions_are_disjoint_and_unique() {
1227        let mut seen = std::collections::BTreeSet::new();
1228        for name in SECRET_ENV_VARS
1229            .iter()
1230            .chain(EXEMPT_SECRET_ENV_VARS.iter().map(|(n, _)| n))
1231        {
1232            assert!(seen.insert(*name), "{name} is listed twice");
1233            assert!(!name.ends_with(FILE_SUFFIX), "{name}");
1234            assert!(!name.ends_with(COMMAND_SUFFIX), "{name}");
1235        }
1236    }
1237
1238    // ── Grep guards (STYLE-0030) ──
1239
1240    /// One production source file: its path relative to `src/` and its text
1241    /// with inline `#[cfg(test)] mod … { … }` blocks and comment lines removed.
1242    struct Source {
1243        rel: String,
1244        code: String,
1245    }
1246
1247    fn production_sources() -> Vec<Source> {
1248        fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
1249            for entry in std::fs::read_dir(dir).unwrap() {
1250                let path = entry.unwrap().path();
1251                if path.is_dir() {
1252                    walk(&path, out);
1253                } else if path.extension().is_some_and(|e| e == "rs") {
1254                    out.push(path);
1255                }
1256            }
1257        }
1258        let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
1259        let mut files = Vec::new();
1260        walk(&src, &mut files);
1261        files
1262            .into_iter()
1263            .filter_map(|path| {
1264                let rel = path
1265                    .strip_prefix(&src)
1266                    .unwrap()
1267                    .to_string_lossy()
1268                    .replace('\\', "/");
1269                let name = path.file_name().unwrap().to_string_lossy();
1270                let is_test_file = rel == "test_support.rs"
1271                    || name == "tests.rs"
1272                    || name.ends_with("_tests.rs")
1273                    || rel.contains("/tests/");
1274                if is_test_file {
1275                    return None;
1276                }
1277                let text = std::fs::read_to_string(&path).unwrap();
1278                let code = strip_test_modules(&rel, &text)
1279                    .lines()
1280                    .filter(|l| !l.trim_start().starts_with("//"))
1281                    .collect::<Vec<_>>()
1282                    .join("\n");
1283                Some(Source { rel, code })
1284            })
1285            .collect()
1286    }
1287
1288    /// Removes each `#[cfg(test)]`-gated `mod name { … }` block, matching
1289    /// braces with [`matching_brace`] so braces inside strings, chars and
1290    /// comments don't count. Panics on a block that never closes, so a lexer
1291    /// gap fails loudly instead of silently hiding the rest of the file.
1292    fn strip_test_modules(rel: &str, text: &str) -> String {
1293        let mut out = String::new();
1294        let mut rest = text;
1295        while let Some(at) = rest.find("#[cfg(test)]") {
1296            out.push_str(&rest[..at]);
1297            let after = &rest[at..];
1298            let brace = after.find('{');
1299            let semi = after.find(';');
1300            // Skip any further attribute lines, then look for `mod …`.
1301            let mut item = after["#[cfg(test)]".len()..].trim_start();
1302            while item.starts_with("#[") {
1303                item = item.find('\n').map_or("", |nl| item[nl..].trim_start());
1304            }
1305            let is_mod_block = (item.starts_with("mod ") || item.starts_with("pub(crate) mod "))
1306                && brace.is_some_and(|b| semi.is_none_or(|s| b < s));
1307            if !is_mod_block {
1308                out.push_str("#[cfg(test)]");
1309                rest = &after["#[cfg(test)]".len()..];
1310                continue;
1311            }
1312            let start = brace.unwrap();
1313            let end = matching_brace(&after[start..])
1314                .unwrap_or_else(|| panic!("{rel}: unbalanced #[cfg(test)] module"));
1315            rest = &after[start + end + 1..];
1316        }
1317        out.push_str(rest);
1318        out
1319    }
1320
1321    /// Byte offset of the `}` closing the `{` that `text` starts with,
1322    /// skipping string, raw-string, char and byte literals and comments.
1323    /// Lifetimes (`'a`) are told apart from char literals by the closing `'`.
1324    fn matching_brace(text: &str) -> Option<usize> {
1325        let b = text.as_bytes();
1326        let mut depth = 0usize;
1327        let mut i = 0;
1328        while i < b.len() {
1329            match b[i] {
1330                b'/' if b.get(i + 1) == Some(&b'/') => {
1331                    i = text[i..].find('\n').map_or(b.len(), |n| i + n);
1332                    continue;
1333                }
1334                b'/' if b.get(i + 1) == Some(&b'*') => {
1335                    i = text[i + 2..].find("*/").map_or(b.len(), |n| i + 2 + n + 2);
1336                    continue;
1337                }
1338                b'r' if !is_ident_byte(b, i) && matches!(b.get(i + 1), Some(b'"' | b'#')) => {
1339                    let hashes = b[i + 1..].iter().take_while(|&&c| c == b'#').count();
1340                    if b.get(i + 1 + hashes) == Some(&b'"') {
1341                        let close = format!("\"{}", "#".repeat(hashes));
1342                        let body = i + 2 + hashes;
1343                        i = text[body..]
1344                            .find(&close)
1345                            .map_or(b.len(), |n| body + n + close.len());
1346                        continue;
1347                    }
1348                }
1349                b'"' => {
1350                    i += 1;
1351                    while i < b.len() && b[i] != b'"' {
1352                        i += if b[i] == b'\\' { 2 } else { 1 };
1353                    }
1354                }
1355                b'\'' => {
1356                    // A char literal: '\x…' or a single (possibly multi-byte)
1357                    // char followed by a closing quote. Otherwise a lifetime.
1358                    if b.get(i + 1) == Some(&b'\\') {
1359                        if let Some(n) = text[i + 2..].find('\'') {
1360                            i += 2 + n;
1361                        }
1362                    } else if let Some(c) = text[i + 1..].chars().next() {
1363                        if text[i + 1 + c.len_utf8()..].starts_with('\'') {
1364                            i += 1 + c.len_utf8();
1365                        }
1366                    } // omni-dev: coverage ignore-line reason="exercised by strip_test_modules_ignores_braces_in_literals_and_comments's '{' char literal; verified locally that llvm-cov still reports 0 hits — a region-attribution artifact on this closing brace, not an untested path"
1367                }
1368                b'{' => depth += 1,
1369                b'}' => {
1370                    depth = depth.checked_sub(1)?;
1371                    if depth == 0 {
1372                        return Some(i);
1373                    }
1374                }
1375                _ => {}
1376            }
1377            i += 1;
1378        }
1379        None
1380    }
1381
1382    /// Whether the byte before `i` continues an identifier (so an `r` there
1383    /// is not a raw-string prefix).
1384    fn is_ident_byte(b: &[u8], i: usize) -> bool {
1385        i > 0 && (b[i - 1].is_ascii_alphanumeric() || b[i - 1] == b'_')
1386    }
1387
1388    /// Every `"UPPER_SNAKE"` string literal in `code`.
1389    fn env_like_literals(code: &str) -> Vec<String> {
1390        let re = regex::Regex::new(r#""([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)""#).unwrap();
1391        re.captures_iter(code).map(|c| c[1].to_string()).collect()
1392    }
1393
1394    fn is_secret_shaped(name: &str) -> bool {
1395        [
1396            "_API_KEY",
1397            "_APP_KEY",
1398            "_TOKEN",
1399            "_SECRET",
1400            "_PRIVATE_KEY",
1401            "_PASSWORD",
1402            "_PASSPHRASE",
1403            "_CREDENTIALS",
1404        ]
1405        .iter()
1406        .any(|s| name.ends_with(s))
1407    }
1408
1409    fn is_known(name: &str) -> bool {
1410        SECRET_ENV_VARS.contains(&name) || EXEMPT_SECRET_ENV_VARS.iter().any(|(n, _)| *n == name)
1411    }
1412
1413    /// (a) A new secret-shaped variable must be registered (and so read
1414    /// through the resolver) or exempted with a reason.
1415    #[test]
1416    fn every_secret_shaped_literal_is_registered_or_exempt() {
1417        let mut unknown = Vec::new();
1418        for source in production_sources() {
1419            for name in env_like_literals(&source.code) {
1420                if is_secret_shaped(&name) && !is_known(&name) {
1421                    unknown.push(format!("{}: {name}", source.rel)); // omni-dev: coverage ignore-line reason="only runs if an unregistered secret-shaped literal exists; the assert below that unknown is empty is this test's whole point, so a passing run never takes this branch"
1422                }
1423            }
1424        }
1425        assert!(
1426            unknown.is_empty(),
1427            "secret-shaped variables must be added to SECRET_ENV_VARS (and read via \
1428             secret_var) or to EXEMPT_SECRET_ENV_VARS with a reason (STYLE-0030): \
1429             {unknown:#?}"
1430        );
1431    }
1432
1433    /// (b) No registered secret is read through a plain env accessor outside
1434    /// this module — neither by literal nor through a `const` bound to one.
1435    #[test]
1436    fn registered_secrets_are_never_read_through_plain_accessors() {
1437        let sources = production_sources();
1438        let const_re =
1439            regex::Regex::new(r#"const ([A-Z][A-Z0-9_]*): &str = "([A-Z0-9_]+)";"#).unwrap();
1440        // Every spelling that denotes a registered secret: the literal, and
1441        // every const ident bound to it anywhere in the crate.
1442        let mut spellings: Vec<(String, &str)> = SECRET_ENV_VARS
1443            .iter()
1444            .map(|n| (format!("\"{n}\""), *n))
1445            .collect();
1446        for source in &sources {
1447            for c in const_re.captures_iter(&source.code) {
1448                if let Some(name) = SECRET_ENV_VARS.iter().find(|n| **n == &c[2]) {
1449                    spellings.push((c[1].to_string(), name));
1450                }
1451            }
1452        }
1453        let ident_re = |s: &str| regex::Regex::new(&format!(r"\b{}\b", regex::escape(s))).unwrap();
1454        let spelling_res: Vec<_> = spellings
1455            .iter()
1456            .map(|(s, n)| {
1457                let re = if s.starts_with('"') {
1458                    regex::Regex::new(&regex::escape(s)).unwrap()
1459                } else {
1460                    ident_re(s)
1461                };
1462                (re, *n)
1463            })
1464            .collect();
1465        let call_re = regex::Regex::new(
1466            r"(?:\.var|\bvar_any|\bget_env_var|\bnon_empty_var|\benv::var|\bvar_os|\btruthy_var)\s*\(",
1467        )
1468        .unwrap();
1469
1470        let mut offenders = Vec::new();
1471        for source in &sources {
1472            if source.rel == "utils/secret_env.rs" {
1473                continue;
1474            }
1475            for m in call_re.find_iter(&source.code) {
1476                let args = balanced_args(&source.code[m.end()..]);
1477                for (re, name) in &spelling_res {
1478                    if re.is_match(args) {
1479                        // omni-dev: coverage ignore reason="only runs if a registered secret is read through a plain accessor outside this module; offenders.is_empty() below is this test's whole point"
1480                        offenders.push(format!("{}: `{}…` reads {name}", source.rel, m.as_str()));
1481                        // omni-dev: coverage end
1482                    }
1483                }
1484            }
1485        }
1486        assert!(
1487            offenders.is_empty(),
1488            "registered secrets must be read through utils::secret_env (STYLE-0030): \
1489             {offenders:#?}"
1490        );
1491    }
1492
1493    /// The text up to the `)` closing a call whose `(` was just consumed.
1494    fn balanced_args(after_paren: &str) -> &str {
1495        let mut depth = 1usize;
1496        for (i, c) in after_paren.char_indices() {
1497            match c {
1498                '(' => depth += 1,
1499                ')' => {
1500                    depth -= 1;
1501                    if depth == 0 {
1502                        return &after_paren[..i];
1503                    }
1504                }
1505                _ => {}
1506            }
1507        }
1508        after_paren
1509    }
1510
1511    /// (c) No registered `<NAME>_FILE` or `<NAME>_COMMAND` collides with a
1512    /// variable that already means something else: the companion names must
1513    /// not appear as literals anywhere in production code (they are only ever
1514    /// built by [`file_var_name`] and [`command_var_name`]).
1515    #[test]
1516    fn no_registered_file_companion_collides_with_an_existing_variable() {
1517        let companions: Vec<String> = SECRET_ENV_VARS
1518            .iter()
1519            .flat_map(|n| [file_var_name(n), command_var_name(n)])
1520            .collect();
1521        let mut collisions = Vec::new();
1522        for source in production_sources() {
1523            for name in env_like_literals(&source.code) {
1524                if companions.contains(&name) {
1525                    collisions.push(format!("{}: {name}", source.rel)); // omni-dev: coverage ignore-line reason="only runs if a _FILE companion collides with an existing variable; collisions.is_empty() below is this test's whole point"
1526                }
1527            }
1528        }
1529        assert!(
1530            collisions.is_empty(),
1531            "a registered secret's _FILE or _COMMAND companion is already a variable with \
1532             another meaning; rename it or exempt the secret (ADR-0089, ADR-0090): \
1533             {collisions:#?}"
1534        );
1535        // The known collision is why GMAIL_CLIENT_SECRET is exempt.
1536        assert!(!SECRET_ENV_VARS.contains(&"GMAIL_CLIENT_SECRET"));
1537    }
1538
1539    /// (d) Only the resolver, the settings writers and the `claude-cli` scrub
1540    /// know how a `_COMMAND` companion is spelled: everything else reaches a
1541    /// helper through [`secret_var`], so no call site can run one itself or
1542    /// skip the timeout, the cap, or the cache (ADR-0090).
1543    #[test]
1544    fn only_the_resolver_writers_and_scrub_name_command_companions() {
1545        const ALLOWED: [&str; 4] = [
1546            "utils/secret_env.rs",
1547            "utils/secret_env/command.rs",
1548            "utils/settings.rs",
1549            "claude/ai/claude_cli.rs",
1550        ];
1551        let mut strays = Vec::new();
1552        for source in production_sources() {
1553            let mentions =
1554                source.code.contains("command_var_name(") || source.code.contains("COMMAND_SUFFIX");
1555            if mentions && !ALLOWED.contains(&source.rel.as_str()) {
1556                strays.push(source.rel); // omni-dev: coverage ignore-line reason="only runs if a stray file names the companion; strays.is_empty() below is this test's whole point"
1557            }
1558        }
1559        assert!(
1560            strays.is_empty(),
1561            "read secrets through secret_var, not by naming their _COMMAND companion \
1562             (ADR-0090): {strays:?}"
1563        );
1564    }
1565
1566    /// Self-check for the guards' source stripping: test modules go, the
1567    /// production code around them stays.
1568    #[test]
1569    fn strip_test_modules_removes_only_test_blocks() {
1570        let text = "fn a() {}\n#[cfg(test)]\nfn helper() {}\n#[cfg(test)]\n\
1571                    #[allow(x)]\nmod tests {\n fn t() { if x { } }\n}\nfn b() {}\n";
1572        let stripped = strip_test_modules("t.rs", text);
1573        assert!(stripped.contains("fn a()"));
1574        assert!(stripped.contains("fn helper()"));
1575        assert!(stripped.contains("fn b()"));
1576        assert!(!stripped.contains("fn t()"));
1577    }
1578
1579    /// Self-check: braces inside strings, chars and comments inside a test
1580    /// module don't end it early or keep it open, so code after it is seen.
1581    #[test]
1582    fn strip_test_modules_ignores_braces_in_literals_and_comments() {
1583        let text = "#[cfg(test)]\nmod tests {\n let a = '{'; let b = \"}}\\\"{\";\n \
1584                    let c = r#\"{\"#; // }\n /* { */ fn f<'a>(x: &'a str) {}\n}\nfn after() {}\n";
1585        let stripped = strip_test_modules("t.rs", text);
1586        assert!(stripped.contains("fn after()"), "{stripped}");
1587        assert!(!stripped.contains("fn f<"), "{stripped}");
1588    }
1589
1590    /// Self-check: an unclosed `{` (a malformed test module) has no matching
1591    /// brace, rather than looping forever or panicking itself — the panic on
1592    /// `None` belongs to `strip_test_modules`'s caller, not this scanner.
1593    #[test]
1594    fn matching_brace_returns_none_when_unclosed() {
1595        assert_eq!(matching_brace("{ fn f() { }"), None);
1596    }
1597
1598    /// Self-check: nested parens are depth-counted rather than stopping at
1599    /// the first `)`, and text with no closing paren at all is returned
1600    /// unchanged (the caller's regex match is always balanced Rust source in
1601    /// practice, but the helper must still degrade rather than panic).
1602    #[test]
1603    fn balanced_args_counts_nested_parens_and_degrades_when_unclosed() {
1604        assert_eq!(balanced_args("f(x), y) rest"), "f(x), y");
1605        assert_eq!(balanced_args("no closing paren"), "no closing paren");
1606    }
1607
1608    // ── resolve_secret_pair / write_secret_file (#2008) ─────────────────
1609
1610    #[test]
1611    fn resolve_secret_pair_uses_the_given_labels_in_a_conflict() {
1612        let err = resolve_secret_pair(
1613            "drive.accounts.work.client_secret",
1614            Some("v"),
1615            "drive.accounts.work.client_secret_file",
1616            Some("/x"),
1617        )
1618        .unwrap_err();
1619        assert_eq!(
1620            err.to_string(),
1621            "both drive.accounts.work.client_secret and \
1622             drive.accounts.work.client_secret_file are set; set only one of them"
1623        );
1624    }
1625
1626    #[test]
1627    fn resolve_secret_pair_treats_empty_members_as_unset() {
1628        assert!(resolve_secret_pair("a", Some(""), "a_file", Some(""))
1629            .unwrap()
1630            .is_none());
1631        let resolved = resolve_secret_pair("a", Some("v"), "a_file", Some("")).unwrap();
1632        assert_eq!(resolved.unwrap().expose_secret(), "v");
1633    }
1634
1635    #[test]
1636    fn write_secret_file_creates_an_owner_only_file_the_reader_accepts() {
1637        let dir = tempfile::tempdir().unwrap();
1638        let path = dir.path().join("token");
1639        let written =
1640            write_secret_file("k_file", &path, &Secret::new(SECRET_BYTES.to_string())).unwrap();
1641        assert!(written);
1642        assert_eq!(
1643            read_secret_file("k_file", &path).unwrap().expose_secret(),
1644            SECRET_BYTES
1645        );
1646        #[cfg(unix)]
1647        {
1648            use std::os::unix::fs::PermissionsExt;
1649            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1650            assert_eq!(mode & 0o777, 0o600);
1651        }
1652        // No temp file is left behind.
1653        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
1654    }
1655
1656    #[test]
1657    fn write_secret_file_replaces_a_different_value() {
1658        let (_dir, path) = secret_file(b"old\n", 0o600);
1659        assert!(write_secret_file("k_file", &path, &Secret::new("new".to_string())).unwrap());
1660        assert_eq!(
1661            read_secret_file("k_file", &path).unwrap().expose_secret(),
1662            "new"
1663        );
1664    }
1665
1666    #[cfg(unix)]
1667    #[test]
1668    fn write_secret_file_leaves_an_unchanged_file_alone() {
1669        use std::os::unix::fs::MetadataExt;
1670        let (_dir, path) = secret_file(b"same\n", 0o600);
1671        let inode = std::fs::metadata(&path).unwrap().ino();
1672        assert!(!write_secret_file("k_file", &path, &Secret::new("same".to_string())).unwrap());
1673        assert_eq!(std::fs::metadata(&path).unwrap().ino(), inode);
1674    }
1675
1676    #[cfg(unix)]
1677    #[test]
1678    fn write_secret_file_writes_through_a_symlink_to_its_target() {
1679        let (dir, target) = secret_file(b"old\n", 0o600);
1680        let link = dir.path().join("link");
1681        std::os::unix::fs::symlink(&target, &link).unwrap();
1682        assert!(write_secret_file("k_file", &link, &Secret::new("new".to_string())).unwrap());
1683        assert!(std::fs::symlink_metadata(&link)
1684            .unwrap()
1685            .file_type()
1686            .is_symlink());
1687        assert_eq!(
1688            read_secret_file("k_file", &target).unwrap().expose_secret(),
1689            "new"
1690        );
1691    }
1692
1693    #[test]
1694    fn write_secret_file_rejects_a_relative_path() {
1695        let err = write_secret_file(
1696            "k_file",
1697            Path::new("rel/token"),
1698            &Secret::new("v".to_string()),
1699        )
1700        .unwrap_err();
1701        assert!(matches!(err, SecretEnvError::RelativePath { .. }), "{err}");
1702    }
1703
1704    #[test]
1705    fn write_secret_file_reports_a_missing_directory_without_the_secret() {
1706        let dir = tempfile::tempdir().unwrap();
1707        let path = dir.path().join("missing").join("token");
1708        let err =
1709            write_secret_file("k_file", &path, &Secret::new(SECRET_BYTES.to_string())).unwrap_err();
1710        assert!(matches!(err, SecretEnvError::Unwritable { .. }), "{err}");
1711        let message = err.to_string();
1712        assert!(message.contains("k_file"), "{message}");
1713        assert!(!message.contains(SECRET_BYTES), "{message}");
1714    }
1715
1716    #[test]
1717    fn write_secret_file_replaces_an_empty_placeholder() {
1718        let (_dir, path) = secret_file(b"", 0o600);
1719        assert!(write_secret_file("k_file", &path, &Secret::new("v".to_string())).unwrap());
1720        assert_eq!(
1721            read_secret_file("k_file", &path).unwrap().expose_secret(),
1722            "v"
1723        );
1724    }
1725
1726    #[cfg(unix)]
1727    #[test]
1728    fn write_secret_file_refuses_to_replace_a_file_the_reader_rejects() {
1729        let (_dir, path) = secret_file(b"old\n", 0o644);
1730        let err = write_secret_file("k_file", &path, &Secret::new("new".to_string())).unwrap_err();
1731        assert!(
1732            matches!(err, SecretEnvError::LoosePermissions { .. }),
1733            "{err}"
1734        );
1735        assert_eq!(std::fs::read(&path).unwrap(), b"old\n");
1736    }
1737
1738    #[test]
1739    fn write_secret_file_refuses_to_replace_a_json_file() {
1740        let json = b"{\"installed\": {\"client_secret\": \"s\"}}\n";
1741        let (_dir, path) = secret_file(json, 0o600);
1742        let err = write_secret_file("k_file", &path, &Secret::new("s".to_string())).unwrap_err();
1743        assert!(matches!(err, SecretEnvError::HoldsJson { .. }), "{err}");
1744        assert!(!err.to_string().contains("installed"), "{err}");
1745        assert_eq!(std::fs::read(&path).unwrap(), json);
1746    }
1747
1748    #[cfg(unix)]
1749    #[test]
1750    fn write_secret_file_refuses_a_dangling_symlink() {
1751        let dir = tempfile::tempdir().unwrap();
1752        let link = dir.path().join("link");
1753        std::os::unix::fs::symlink(dir.path().join("absent"), &link).unwrap();
1754        let err = write_secret_file("k_file", &link, &Secret::new("v".to_string())).unwrap_err();
1755        assert!(matches!(err, SecretEnvError::Unreadable { .. }), "{err}");
1756        assert!(std::fs::symlink_metadata(&link)
1757            .unwrap()
1758            .file_type()
1759            .is_symlink());
1760        assert!(!dir.path().join("absent").exists());
1761    }
1762
1763    #[test]
1764    fn plan_secret_file_write_reports_whether_it_replaces_a_file() {
1765        let (dir, path) = secret_file(b"old\n", 0o600);
1766        let secret = Secret::new("new".to_string());
1767        let replace = plan_secret_file_write("k_file", &path, &secret)
1768            .unwrap()
1769            .unwrap();
1770        assert!(replace.replaces_existing());
1771        let create = plan_secret_file_write("k_file", &dir.path().join("fresh"), &secret)
1772            .unwrap()
1773            .unwrap();
1774        assert!(!create.replaces_existing());
1775        // Planning writes nothing.
1776        assert_eq!(std::fs::read(&path).unwrap(), b"old\n");
1777        assert!(!dir.path().join("fresh").exists());
1778    }
1779
1780    /// A `symlink_metadata` failure other than "not found" (here, a path
1781    /// walking through a regular file as if it were a directory) is
1782    /// reported as [`SecretEnvError::Unwritable`] rather than treated as
1783    /// "the file doesn't exist yet".
1784    #[test]
1785    fn plan_secret_file_write_reports_a_non_not_found_stat_error() {
1786        let dir = tempfile::tempdir().unwrap();
1787        let not_a_dir = dir.path().join("not-a-dir");
1788        std::fs::write(&not_a_dir, b"x").unwrap();
1789        let path = not_a_dir.join("secret");
1790        let err =
1791            plan_secret_file_write("k_file", &path, &Secret::new("v".to_string())).unwrap_err();
1792        assert!(matches!(err, SecretEnvError::Unwritable { .. }), "{err}");
1793    }
1794
1795    /// A [`SecretFileWrite::write`] I/O failure (here, a parent directory
1796    /// with no write permission, so the temp file can't be created) is
1797    /// reported as [`SecretEnvError::Unwritable`] rather than partially
1798    /// applied.
1799    #[test]
1800    fn write_secret_file_reports_an_io_error_from_an_unwritable_directory() {
1801        let dir = tempfile::tempdir().unwrap();
1802        let path = dir.path().join("secret");
1803        set_mode(dir.path(), 0o500);
1804        let err = write_secret_file("k_file", &path, &Secret::new("v".to_string())).unwrap_err();
1805        set_mode(dir.path(), 0o700);
1806        assert!(matches!(err, SecretEnvError::Unwritable { .. }), "{err}");
1807        assert!(!path.exists());
1808    }
1809
1810    /// Self-check: the guards see the real read sites (so an empty scan
1811    /// can't pass vacuously).
1812    #[test]
1813    fn guards_scan_the_real_read_sites() {
1814        let sources = production_sources();
1815        let all: Vec<String> = sources
1816            .iter()
1817            .flat_map(|s| env_like_literals(&s.code))
1818            .collect();
1819        for name in ["DATADOG_API_KEY", "OMNI_BRIDGE_TOKEN", "CLAUDE_API_KEY"] {
1820            assert!(all.iter().any(|n| n == name), "{name} not found");
1821        }
1822    }
1823}