src/password.rs
Rama main · réplica de lectura del repositorio original.
//! 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"
);
}
}