Skip to main content

omni_dev/utils/
secret_env.rs

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