Ctrl K
ComunidadGuías…

src/password.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//! Password handling: secret types, lazy password sources, and helpers.
//!
//! Passwords are never held as plain `String` values. The [`secrecy`] crate
//! provides [`SecretString`] which auto-zeroizes on drop and prevents
//! accidental exposure via `Debug` / `Display`.
//!
//! [`PasswordSource`] captures *where* a password comes from (CLI flag, file,
//! command, credential store, interactive prompt) and evaluates lazily — the
//! password is only fetched at auth time and released immediately after.

use std::path::{Path, PathBuf};
use std::sync::Arc;
// Permissive-mode warnings are unix-only (Windows has no POSIX mode bits).
// The dedup cache (HashSet / Mutex / OnceLock) is scoped to that path.
#[cfg(unix)]
use std::collections::HashSet;
#[cfg(unix)]
use std::sync::{Mutex, OnceLock};

use anyhow::Context;
pub use secrecy::{ExposeSecret, SecretString};

use crate::credential::CredentialStore;

/// Describes where to obtain a password, evaluated lazily on each auth attempt.
///
/// Between auth cycles (e.g., watch mode re-auth), the closure holds only the
/// source descriptor — no password remains in memory.
#[derive(Debug)]
pub enum PasswordSource {
    /// Password already in memory (from `--password` flag, env var, or TOML).
    Direct(Arc<SecretString>),
    /// Shell command to execute on each auth attempt.
    Command(String),
    /// File path to read on each auth attempt.
    File(PathBuf),
    /// OS keyring or encrypted file credential store.
    Store(CredentialStore),
    /// Interactive terminal prompt via `rpassword`.
    Interactive,
}

/// Shared handle to a password-resolving closure.
///
/// Behind `Arc` so the async call site can refcount-bump a handle
/// into `tokio::task::spawn_blocking` without borrowing across
/// `.await`. The closure itself stays synchronous — it's the
/// blocking pool that keeps slow `--password-command` invocations
/// from pinning an async runtime worker.
pub type PasswordProvider =
    std::sync::Arc<dyn Fn() -> Option<SecretString> + Send + Sync + 'static>;

/// Run a [`PasswordProvider`] on the blocking pool.
///
/// The provider's `resolve()` path may include a 30-second subprocess
/// wait (`--password-command`) or a file read; running it on an async
/// worker would block the runtime. Dispatch to `spawn_blocking` instead
/// so the worker stays free for concurrent download/state-DB work.
pub async fn invoke_password_provider(provider: &PasswordProvider) -> Option<SecretString> {
    let provider = std::sync::Arc::clone(provider);
    tokio::task::spawn_blocking(move || provider())
        .await
        .unwrap_or_else(|e| {
            tracing::error!(error = %e, "password provider panicked in spawn_blocking");
            None
        })
}

/// Decision returned by [`decide_save_password_action`] to the sync loop:
/// either perform the save, or skip it with a user-visible warning
/// explaining why `--save-password` was a no-op for this source.
#[derive(Debug, PartialEq, Eq)]
pub enum SavePasswordAction {
    /// Password is CLI/env-sourced; write it to the credential store.
    Save,
    /// Source already persists the secret elsewhere. `reason` is the warning
    /// text the sync loop should emit at `tracing::warn!` level.
    SkipWithWarning(&'static str),
}

/// Decide how the sync loop should handle `--save-password` given the
/// resolved password source. Pure function for testability — the sync loop
/// wraps this with the actual `store.store()` call and log emission.
#[must_use]
pub fn decide_save_password_action(source: &PasswordSource) -> SavePasswordAction {
    match source {
        PasswordSource::Direct(_) => SavePasswordAction::Save,
        PasswordSource::Interactive => SavePasswordAction::SkipWithWarning(
            "--save-password with an interactive password prompt is not supported. \
             Use `kei password set` to save a prompted password to the credential \
             store, then re-run without --save-password.",
        ),
        PasswordSource::File(_) => SavePasswordAction::SkipWithWarning(
            "--save-password skipped: the password is already persistent in a file \
             (`--password-file` / `[auth] password_file`). Remove --save-password, \
             or use `kei password set` to bootstrap the credential store from the file.",
        ),
        PasswordSource::Command(_) => SavePasswordAction::SkipWithWarning(
            "--save-password skipped: the password comes from an external command \
             (`--password-command` / `[auth] password_command`). Remove --save-password \
             to avoid storing a stale copy of a rotating secret.",
        ),
        PasswordSource::Store(_) => SavePasswordAction::SkipWithWarning(
            "--save-password skipped: the password is already in the credential store. \
             Remove --save-password from your invocation.",
        ),
    }
}

impl PasswordSource {
    /// Evaluate the source, returning the password.
    ///
    /// Called once per auth attempt — the result is not cached between attempts.
    /// For [`Command`](Self::Command) and [`File`](Self::File) sources, this
    /// re-executes the command or re-reads the file each time, supporting
    /// password rotation and external secret managers.
    #[allow(
        dead_code,
        reason = "kept as the default-input wrapper; CLI providers use the startup input snapshot"
    )]
    pub fn resolve(&self) -> anyhow::Result<Option<SecretString>> {
        self.resolve_in_mode(crate::InputMode::detect())
    }

    /// Evaluate the source using the input mode captured at process startup.
    pub(crate) fn resolve_in_mode(
        &self,
        input_mode: crate::InputMode,
    ) -> anyhow::Result<Option<SecretString>> {
        match self {
            Self::Direct(s) => Ok(Some(SecretString::from(s.expose_secret().to_owned()))),
            Self::Command(cmd) => run_password_command(cmd).map(Some),
            Self::File(path) => read_password_file(path).map(Some),
            Self::Store(store) => store.retrieve(),
            Self::Interactive => {
                if !input_mode.can_prompt() {
                    anyhow::bail!(
                        "No iCloud password is configured, and kei cannot prompt because stdin is not a terminal. \
                         Set a password with one of:\n  \
                         - ICLOUD_PASSWORD environment variable\n  \
                         - kei password set (OS keyring or encrypted file)\n  \
                         - --password-command or [auth] password_command (external secret manager)\n  \
                         - --password-file or [auth] password_file (Docker secret, etc.)"
                    );
                }
                Ok(prompt_password())
            }
        }
    }
}

/// Build a [`PasswordSource`] from resolved configuration, following the priority chain:
///
/// direct password > password_command > password_file > credential store > interactive
///
/// Direct password can come from `--password` or `ICLOUD_PASSWORD`. The command
/// and file sources can come from CLI flags or their TOML counterparts; CLI
/// wins per-field. `[auth] password` in TOML is rejected upstream in
/// `Config::build()`.
pub fn build_password_source(
    password: Option<&SecretString>,
    password_command: Option<&str>,
    password_file: Option<&Path>,
    credential_store: CredentialStore,
) -> PasswordSource {
    if let Some(pw) = password {
        PasswordSource::Direct(Arc::new(SecretString::from(pw.expose_secret().to_owned())))
    } else if let Some(cmd) = password_command {
        PasswordSource::Command(cmd.to_string())
    } else if let Some(path) = password_file {
        PasswordSource::File(path.to_path_buf())
    } else if credential_store.has_credential() {
        PasswordSource::Store(credential_store)
    } else {
        PasswordSource::Interactive
    }
}

/// Read a password from a file, stripping a single trailing newline.
///
/// Designed for Docker secrets (`/run/secrets/...`) and similar file-based
/// credential stores. The file is re-read on each call to support rotation.
///
/// On Unix, warns once per path if the file is readable by group or other
/// users (see [`check_password_file_mode`]).
pub(crate) fn read_password_file(path: &Path) -> anyhow::Result<SecretString> {
    warn_if_permissive_mode(path);
    let contents = std::fs::read_to_string(path)
        .with_context(|| format!("Could not read password file {}", path.display()))?;
    let trimmed = strip_trailing_newline(&contents);
    anyhow::ensure!(
        !trimmed.is_empty(),
        "Password file is empty: {}",
        path.display()
    );
    Ok(SecretString::from(trimmed.to_string()))
}

/// Inspect a password file's mode and return a warning message if any
/// group/other permission bits are set, or `None` if the file is secure.
///
/// Paths under the conventional container-secret mount points
/// (`/run/secrets/` and `/var/run/secrets/`) are exempted because Docker and
/// Kubernetes publish those files as world-readable by design; isolation is
/// enforced at the mount layer, not the file mode.
///
/// Pure function so the policy can be unit-tested without touching the
/// filesystem or the tracing subscriber.
#[cfg(unix)]
pub(crate) fn check_password_file_mode(path: &Path, mode: u32) -> Option<String> {
    let path_str = path.to_string_lossy();
    if path_str.starts_with("/run/secrets/") || path_str.starts_with("/var/run/secrets/") {
        return None;
    }
    let mode = mode & 0o777;
    if mode & 0o077 == 0 {
        return None;
    }
    Some(format!(
        "password file {} is readable by other users (mode {mode:04o}); \
         run `chmod 600 {}` to restrict it",
        path.display(),
        path.display(),
    ))
}

/// Warn once per path when a password file has permissive group/other
/// permissions. No-op on non-Unix platforms (Windows uses a different ACL
/// model that's out of scope here).
#[cfg(unix)]
fn warn_if_permissive_mode(path: &Path) {
    use std::os::unix::fs::PermissionsExt;

    static WARNED_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
    let Ok(meta) = std::fs::metadata(path) else {
        return;
    };
    let Some(msg) = check_password_file_mode(path, meta.permissions().mode()) else {
        return;
    };

    let warned = WARNED_PATHS.get_or_init(|| Mutex::new(HashSet::new()));
    let Ok(mut guard) = warned.lock() else { return };
    if !guard.insert(path.to_path_buf()) {
        return;
    }
    drop(guard);

    tracing::warn!(
        path = %path.display(),
        message = %msg,
        "Permissive password file mode"
    );
}

#[cfg(not(unix))]
fn warn_if_permissive_mode(_path: &Path) {}

/// Default kill deadline for a password command. Watch-mode re-auth is the
/// scenario this guards: a hung secret manager (network-stalled Vault, sleepy
/// gpg-agent, etc.) would otherwise freeze the whole sync indefinitely.
pub(crate) const PASSWORD_COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);

/// Execute a shell command and capture stdout as a password.
///
/// The command runs via `sh -c` with stdin as `/dev/null` (so it can't hang
/// waiting on input) and stderr inherited (command errors visible to the
/// user). Re-executed on each auth attempt to support dynamic secret managers
/// (1Password, Vault, pass, etc.).
///
/// Not supported on Windows: there's no `sh` on a stock Windows PATH. Use
/// `--password-file` instead, or run kei under WSL.
pub(crate) fn run_password_command(cmd: &str) -> anyhow::Result<SecretString> {
    run_password_command_with_timeout(cmd, PASSWORD_COMMAND_TIMEOUT)
}

/// Shared implementation of [`run_password_command`] with an injectable
/// timeout so tests can exercise the kill path without waiting 30 seconds.
fn run_password_command_with_timeout(
    cmd: &str,
    timeout: std::time::Duration,
) -> anyhow::Result<SecretString> {
    #[cfg(not(unix))]
    {
        let _ = (cmd, timeout);
        anyhow::bail!(
            "`--password-command` / `[auth] password_command` is not supported on Windows: \
             kei runs commands through `sh -c`, which is not on the default Windows PATH. \
             Use `--password-file` / `[auth] password_file`, or run kei under WSL."
        );
    }

    #[cfg(unix)]
    {
        use std::io::Read;

        let mut child = std::process::Command::new("sh")
            .args(["-c", cmd])
            .stdin(std::process::Stdio::null())
            .stdout(std::process::Stdio::piped())
            .stderr(std::process::Stdio::inherit())
            .spawn()
            .with_context(|| format!("Could not run password command: {cmd}"))?;

        // Poll with 5ms → 50ms backoff; clamp each sleep to the remaining
        // deadline so we never overshoot the timeout budget.
        let start = std::time::Instant::now();
        let mut poll = std::time::Duration::from_millis(5);
        let cap = std::time::Duration::from_millis(50);
        let status = loop {
            match child.try_wait() {
                Ok(Some(status)) => break status,
                Ok(None) => {
                    let remaining = timeout.saturating_sub(start.elapsed());
                    if remaining.is_zero() {
                        let _ = child.kill();
                        let _ = child.wait();
                        anyhow::bail!(
                            "Password command timed out after {} seconds: {cmd}",
                            timeout.as_secs()
                        );
                    }
                    std::thread::sleep(poll.min(remaining));
                    poll = (poll * 2).min(cap);
                }
                Err(e) => {
                    return Err(anyhow::Error::from(e)
                        .context(format!("waiting on password command: {cmd}")));
                }
            }
        };

        anyhow::ensure!(
            status.success(),
            "Password command exited with status {status}: {cmd}"
        );

        let mut buf = Vec::new();
        if let Some(mut stdout) = child.stdout.take() {
            stdout
                .read_to_end(&mut buf)
                .with_context(|| format!("Could not read password command output: {cmd}"))?;
        }
        let stdout =
            String::from_utf8(buf).context("Password command output is not valid UTF-8")?;
        let trimmed = strip_trailing_newline(&stdout);
        anyhow::ensure!(
            !trimmed.is_empty(),
            "Password command did not print a password: {cmd}"
        );
        Ok(SecretString::from(trimmed.to_string()))
    }
}

/// Prompt for a password on stdin using `rpassword` (masked input).
///
/// Returns `None` if stdin is not a terminal or the prompt fails.
pub fn prompt_password() -> Option<SecretString> {
    tokio::task::block_in_place(|| {
        rpassword::prompt_password("iCloud Password: ")
            .ok()
            .map(SecretString::from)
    })
}

/// Strip a single trailing newline (LF or CRLF) from a string.
fn strip_trailing_newline(s: &str) -> &str {
    s.strip_suffix("\r\n")
        .or_else(|| s.strip_suffix('\n'))
        .unwrap_or(s)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn write_test_file(dir: &std::path::Path, name: &str, contents: &str) -> PathBuf {
        let path = dir.join(name);
        std::fs::write(&path, contents).unwrap();
        path
    }

    // ── strip_trailing_newline ──────────────────────────────────────

    #[test]
    fn strip_trailing_newline_lf() {
        assert_eq!(strip_trailing_newline("password\n"), "password");
    }

    #[test]
    fn strip_trailing_newline_crlf() {
        assert_eq!(strip_trailing_newline("password\r\n"), "password");
    }

    #[test]
    fn strip_trailing_newline_none() {
        assert_eq!(strip_trailing_newline("password"), "password");
    }

    #[test]
    fn strip_trailing_newline_only_one() {
        assert_eq!(strip_trailing_newline("password\n\n"), "password\n");
    }

    // ── read_password_file ──────────────────────────────────────────

    #[test]
    fn read_password_file_normal() {
        let dir = tempfile::tempdir().unwrap();
        let path = write_test_file(dir.path(), "pw.txt", "my_secret\n");
        assert_eq!(
            read_password_file(&path).unwrap().expose_secret(),
            "my_secret"
        );
    }

    #[test]
    fn read_password_file_no_newline() {
        let dir = tempfile::tempdir().unwrap();
        let path = write_test_file(dir.path(), "pw.txt", "my_secret");
        assert_eq!(
            read_password_file(&path).unwrap().expose_secret(),
            "my_secret"
        );
    }

    #[test]
    fn read_password_file_crlf() {
        let dir = tempfile::tempdir().unwrap();
        let path = write_test_file(dir.path(), "pw.txt", "my_secret\r\n");
        assert_eq!(
            read_password_file(&path).unwrap().expose_secret(),
            "my_secret"
        );
    }

    #[test]
    fn read_password_file_empty() {
        let dir = tempfile::tempdir().unwrap();
        let path = write_test_file(dir.path(), "pw.txt", "");
        let err = read_password_file(&path).unwrap_err();
        assert!(err.to_string().contains("empty"), "{err}");
    }

    #[test]
    fn read_password_file_only_newline() {
        let dir = tempfile::tempdir().unwrap();
        let path = write_test_file(dir.path(), "pw.txt", "\n");
        let err = read_password_file(&path).unwrap_err();
        assert!(err.to_string().contains("empty"), "{err}");
    }

    #[test]
    fn read_password_file_missing() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("nonexistent.txt");
        let err = read_password_file(&path).unwrap_err();
        assert!(err.to_string().contains("Could not read"), "{err}");
    }

    // ── run_password_command ────────────────────────────────────────
    //
    // All happy-path tests here shell out via `sh -c`, which is unix-only.
    // Windows coverage is `run_password_command_errors_on_non_unix` below.

    #[cfg(unix)]
    #[test]
    fn run_password_command_echo() {
        assert_eq!(
            run_password_command("echo hunter2")
                .unwrap()
                .expose_secret(),
            "hunter2"
        );
    }

    #[cfg(unix)]
    #[test]
    fn run_password_command_failure() {
        let err = run_password_command("false").unwrap_err();
        assert!(err.to_string().contains("exited with status"), "{err}");
    }

    #[cfg(unix)]
    #[test]
    fn run_password_command_empty() {
        let err = run_password_command("printf ''").unwrap_err();
        assert!(
            err.to_string().contains("did not print a password"),
            "{err}"
        );
    }

    #[cfg(unix)]
    #[test]
    fn run_password_command_rejects_non_utf8_stdout() {
        let err = run_password_command("printf '\\377\\376'").unwrap_err();
        assert!(
            err.to_string().contains("not valid UTF-8"),
            "expected invalid UTF-8 error; got: {err}"
        );
    }

    #[cfg(unix)]
    #[test]
    fn run_password_command_strips_newline() {
        assert_eq!(
            run_password_command("echo secret_value")
                .unwrap()
                .expose_secret(),
            "secret_value"
        );
    }

    #[cfg(unix)]
    #[test]
    fn run_password_command_times_out_and_kills_hung_process() {
        // `sleep 30` with a 100ms timeout must return within a fraction of a
        // second; anything close to 30s means the kill path is broken.
        let start = std::time::Instant::now();
        let err =
            run_password_command_with_timeout("sleep 30", std::time::Duration::from_millis(100))
                .unwrap_err();
        let elapsed = start.elapsed();
        assert!(
            err.to_string().contains("timed out"),
            "expected timeout error; got: {err}"
        );
        assert!(
            elapsed < std::time::Duration::from_secs(2),
            "timeout path took {elapsed:?}; kill didn't work"
        );
    }

    #[cfg(unix)]
    #[test]
    fn run_password_command_completes_before_timeout() {
        // A command that finishes well under the timeout returns its output,
        // not a timeout error. Guards the happy path from a future regression
        // where the poll loop accidentally short-circuits.
        let pw = run_password_command_with_timeout("echo fast", std::time::Duration::from_secs(5))
            .unwrap();
        assert_eq!(pw.expose_secret(), "fast");
    }

    #[cfg(not(unix))]
    #[test]
    fn run_password_command_errors_on_non_unix() {
        let err = run_password_command("echo anything").unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("not supported on Windows"), "{msg}");
        assert!(msg.contains("--password-file"), "{msg}");
    }

    // ── PasswordSource::resolve ─────────────────────────────────────

    #[test]
    fn password_source_direct_resolve() {
        let source = PasswordSource::Direct(Arc::new(SecretString::from("direct_pw")));
        assert_eq!(
            source.resolve().unwrap().unwrap().expose_secret(),
            "direct_pw"
        );
    }

    // Same reasoning as run_password_command_echo: `sh -c` is unix-only.
    #[cfg(unix)]
    #[test]
    fn password_source_command_resolve() {
        let source = PasswordSource::Command("echo cmd_pw".to_string());
        assert_eq!(source.resolve().unwrap().unwrap().expose_secret(), "cmd_pw");
    }

    #[test]
    fn password_source_file_resolve() {
        let dir = tempfile::tempdir().unwrap();
        let path = write_test_file(dir.path(), "pw_source.txt", "file_pw\n");
        let source = PasswordSource::File(path);
        assert_eq!(
            source.resolve().unwrap().unwrap().expose_secret(),
            "file_pw"
        );
    }

    // ── decide_save_password_action ────────────────────────────────

    #[test]
    fn decide_save_direct_saves() {
        let source = PasswordSource::Direct(Arc::new(SecretString::from("x")));
        assert_eq!(
            decide_save_password_action(&source),
            SavePasswordAction::Save
        );
    }

    #[test]
    fn decide_save_file_skips_with_bootstrap_hint() {
        let source = PasswordSource::File(PathBuf::from("/x"));
        match decide_save_password_action(&source) {
            SavePasswordAction::SkipWithWarning(msg) => {
                assert!(msg.contains("--password-file"), "{msg}");
                assert!(msg.contains("kei password set"), "{msg}");
            }
            other => panic!("expected SkipWithWarning, got {other:?}"),
        }
    }

    #[test]
    fn decide_save_command_skips_with_rotation_hint() {
        let source = PasswordSource::Command("echo x".to_string());
        match decide_save_password_action(&source) {
            SavePasswordAction::SkipWithWarning(msg) => {
                assert!(msg.contains("--password-command"), "{msg}");
                assert!(msg.contains("rotating"), "{msg}");
            }
            other => panic!("expected SkipWithWarning, got {other:?}"),
        }
    }

    #[test]
    fn decide_save_store_skips_as_redundant() {
        let dir = tempfile::tempdir().unwrap();
        let source =
            PasswordSource::Store(CredentialStore::new("user@example.com", dir.path(), "com"));
        match decide_save_password_action(&source) {
            SavePasswordAction::SkipWithWarning(msg) => {
                assert!(msg.contains("already in the credential store"), "{msg}");
            }
            other => panic!("expected SkipWithWarning, got {other:?}"),
        }
    }

    #[test]
    fn decide_save_interactive_points_at_password_set() {
        let source = PasswordSource::Interactive;
        match decide_save_password_action(&source) {
            SavePasswordAction::SkipWithWarning(msg) => {
                assert!(msg.contains("kei password set"), "{msg}");
                assert!(msg.contains("interactive"), "{msg}");
            }
            other => panic!("expected SkipWithWarning, got {other:?}"),
        }
    }

    #[test]
    fn interactive_source_rejects_no_input_mode_before_prompting() {
        let error = PasswordSource::Interactive
            .resolve_in_mode(crate::InputMode::NoInput)
            .expect_err("headless password lookup must not prompt");

        assert!(error.to_string().contains("stdin is not a terminal"));
    }

    // ── check_password_file_mode (unix-only) ────────────────────────
    //
    // These tests exercise the pure policy function without touching the
    // filesystem or the tracing subscriber.

    #[cfg(unix)]
    #[test]
    fn check_mode_owner_read_only_ok() {
        let path = Path::new("/home/user/icloud_pw");
        assert!(check_password_file_mode(path, 0o600).is_none());
        assert!(check_password_file_mode(path, 0o400).is_none());
    }

    #[cfg(unix)]
    #[test]
    fn check_mode_group_readable_warns() {
        let path = Path::new("/home/user/icloud_pw");
        let msg = check_password_file_mode(path, 0o640).unwrap();
        assert!(msg.contains("0640"), "mode should appear in message: {msg}");
        assert!(msg.contains("chmod 600"), "fix hint missing: {msg}");
    }

    #[cfg(unix)]
    #[test]
    fn check_mode_world_readable_warns() {
        let path = Path::new("/home/user/icloud_pw");
        let msg = check_password_file_mode(path, 0o644).unwrap();
        assert!(msg.contains("0644"));
        assert!(msg.contains(path.to_str().unwrap()));
    }

    #[cfg(unix)]
    #[test]
    fn check_mode_group_exec_only_warns() {
        // Any bit in the lower six — including exec — means someone other
        // than the owner has access to the file metadata chain.
        let path = Path::new("/home/user/icloud_pw");
        assert!(check_password_file_mode(path, 0o610).is_some());
        assert!(check_password_file_mode(path, 0o601).is_some());
    }

    #[cfg(unix)]
    #[test]
    fn check_mode_masks_setuid_setgid_sticky() {
        // A sticky/setuid bit shouldn't mask a permissive low-bit mode.
        let path = Path::new("/home/user/icloud_pw");
        assert!(check_password_file_mode(path, 0o4644).is_some());
        assert!(check_password_file_mode(path, 0o2600).is_none());
    }

    #[cfg(unix)]
    #[test]
    fn check_mode_docker_secrets_exempted() {
        // Docker secrets land in /run/secrets/ with mode 0o444 by default;
        // the isolation is at the mount layer, not the file mode.
        let path = Path::new("/run/secrets/icloud_password");
        assert!(check_password_file_mode(path, 0o444).is_none());
        assert!(check_password_file_mode(path, 0o644).is_none());
    }

    #[cfg(unix)]
    #[test]
    fn check_mode_k8s_secrets_exempted() {
        let path = Path::new("/var/run/secrets/icloud/password");
        assert!(check_password_file_mode(path, 0o644).is_none());
    }

    #[cfg(unix)]
    #[test]
    fn check_mode_secrets_prefix_must_match_path_segment() {
        // A user directory that merely starts with "/run/secrets" but isn't
        // the canonical container mount should still be checked.
        let path = Path::new("/run/secretsharing/pw");
        assert!(check_password_file_mode(path, 0o644).is_some());
    }

    // ── read_password_file with a permissive file ──────────────────
    //
    // Integration sanity check: the warn path must not break the read.

    #[cfg(unix)]
    #[test]
    fn read_password_file_still_works_on_permissive_mode() {
        use std::os::unix::fs::PermissionsExt;
        let dir = tempfile::tempdir().unwrap();
        let path = write_test_file(dir.path(), "permissive_pw.txt", "leaky\n");
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
        assert_eq!(read_password_file(&path).unwrap().expose_secret(), "leaky");
    }

    #[cfg(unix)]
    #[test]
    fn password_file_permission_warning_not_cached_before_violation() {
        use std::os::unix::fs::PermissionsExt;

        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("later_permissive_pw.txt");

        warn_if_permissive_mode(&path);

        std::fs::write(&path, "secret\n").unwrap();
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();

        let (capture, _guard) = crate::test_helpers::TracingCapture::install();
        warn_if_permissive_mode(&path);

        assert!(
            capture.contains_event(|event| {
                event.level == tracing::Level::WARN
                    && event.field("message").is_some_and(|message| {
                        message.contains("readable by other users")
                            || message == "Permissive password file mode"
                    })
                    && event.field("path") == Some(path.to_string_lossy().as_ref())
            }),
            "missing warning for path that became permissive after initial non-violation: {:?}",
            capture.events()
        );
    }

    // ── invoke_password_provider ────────────────────────────────────
    //
    // A provider whose resolve() does blocking I/O (subprocess wait,
    // file read) must not run on the async worker. The canary here
    // is that the async caller stays responsive while the sync
    // closure is in flight, which spawn_blocking guarantees.

    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
    async fn invoke_password_provider_yields_runtime_for_blocking_closure() {
        use std::sync::Arc as StdArc;
        use std::sync::atomic::{AtomicBool, Ordering};
        use std::time::Duration;

        let interleaved = StdArc::new(AtomicBool::new(false));
        let seen = StdArc::clone(&interleaved);
        let release = StdArc::new((std::sync::Mutex::new(false), std::sync::Condvar::new()));
        let provider_release = StdArc::clone(&release);

        // Sync provider that blocks until the async canary releases it. If
        // invoke_password_provider ran this closure on the single async
        // worker, the canary would never execute and the timeout would fire.
        let provider: PasswordProvider = StdArc::new(move || {
            let (lock, cvar) = &*provider_release;
            let mut released = lock.lock().expect("release mutex");
            while !*released {
                released = cvar.wait(released).expect("release condvar");
            }
            Some(SecretString::from("ok".to_string()))
        });

        let provider_fut = super::invoke_password_provider(&provider);
        let canary_fut = async {
            tokio::task::yield_now().await;
            seen.store(true, Ordering::SeqCst);
            let (lock, cvar) = &*release;
            *lock.lock().expect("release mutex") = true;
            cvar.notify_one();
        };

        let (password, ()) = tokio::time::timeout(Duration::from_secs(1), async {
            tokio::join!(provider_fut, canary_fut)
        })
        .await
        .expect("spawn_blocking provider must not starve the async worker");
        assert_eq!(password.unwrap().expose_secret(), "ok");
        assert!(
            interleaved.load(Ordering::SeqCst),
            "the async canary must have run while the sync provider was sleeping"
        );
    }
}