//! Password resolution for PDF decryption. //! //! This module implements the password ingress channels defined in TH-07: //! - `--password-stdin` flag (reads one line from stdin) //! - `PDFTRACT_PASSWORD` env var //! - `--password VALUE` (rejected unless `PDFTRACT_INSECURE_CLI_PASSWORD=1`) use anyhow::{bail, Context, Result}; use std::io::{self, Read}; /// Exit code for usage errors (rejected --password VALUE without opt-in). pub const EXIT_USAGE_ERROR: u8 = 64; /// Environment variable that enables insecure --password VALUE flag. pub const ENV_INSECURE_CLI_PASSWORD: &str = "PDFTRACT_INSECURE_CLI_PASSWORD"; /// Environment variable for secure password ingress. pub const ENV_PASSWORD: &str = "PDFTRACT_PASSWORD"; /// Warning emitted when --password VALUE is accepted via opt-in. pub const WARNING_INSECURE_PASSWORD: &str = "WARNING: --password VALUE is insecure (visible via 'ps aux'). \ Use --password-stdin or PDFTRACT_PASSWORD env var instead."; /// Resolve a PDF password from the available ingress channels. /// /// Priority order: /// 1. `--password-stdin` flag (read one line from stdin) /// 2. `PDFTRACT_PASSWORD` env var /// 3. `--password VALUE` (only if `PDFTRACT_INSECURE_CLI_PASSWORD=1`) /// 4. None /// /// Returns `Ok(None)` if no password was provided via any channel. /// Returns `Ok(Some(password))` if a password was resolved. /// Returns `Err` if `--password VALUE` was supplied without the opt-in env var. pub fn resolve_password( password_stdin: bool, password_value: Option, ) -> Result> { // Priority 1: --password-stdin if password_stdin { return read_password_from_stdin(); } // Priority 2: PDFTRACT_PASSWORD env var if let Ok(env_pass) = std::env::var(ENV_PASSWORD) { if !env_pass.is_empty() { return Ok(Some(secrecy::SecretString::new(env_pass.into()))); } } // Priority 3: --password VALUE (requires opt-in) if let Some(value) = password_value { let opt_in = std::env::var(ENV_INSECURE_CLI_PASSWORD) .ok() .and_then(|v| v.parse::().ok()) .map(|v| v == 1) .unwrap_or(false); if !opt_in { bail!( "--password VALUE is insecure and rejected by default. \ Use --password-stdin or set PDFTRACT_PASSWORD env var instead. \ To opt in, set PDFTRACT_INSECURE_CLI_PASSWORD=1 (not recommended)." ); } eprintln!("{}", WARNING_INSECURE_PASSWORD); return Ok(Some(secrecy::SecretString::new(value.into()))); } // No password provided Ok(None) } /// Read a password from stdin. /// /// Reads one newline-terminated line from stdin. The newline (either `\n` or `\r\n`) /// is stripped. Leading/trailing whitespace in the password is preserved. /// /// An empty password (a bare newline) is treated as "no password", returning `Ok(None)`. /// This is distinct from a zero-length password (a rare PDF case). fn read_password_from_stdin() -> Result> { let mut input = String::new(); { let stdin = io::stdin(); let mut handle = stdin.lock(); handle .read_to_string(&mut input) .context("Failed to read password from stdin")?; } // Trim trailing newline only (\n or \r\n), not leading/trailing whitespace. // Passwords can legitimately contain leading/trailing spaces. let password = if input.ends_with("\r\n") { &input[..input.len() - 2] } else if input.ends_with('\n') { &input[..input.len() - 1] } else { &input[..] }; // Empty password (bare newline) means no password. if password.is_empty() { return Ok(None); } Ok(Some(secrecy::SecretString::new( password.to_string().into_boxed_str(), ))) } #[cfg(test)] mod tests { use super::*; use secrecy::ExposeSecret; #[test] fn test_resolve_password_none() { let result = resolve_password(false, None).unwrap(); assert!(result.is_none()); } #[test] fn test_resolve_password_value_rejected_without_opt_in() { std::env::remove_var(ENV_INSECURE_CLI_PASSWORD); let result = resolve_password(false, Some("secret".to_string())); assert!(result.is_err()); let err_msg = result.unwrap_err().to_string(); assert!(err_msg.contains("--password VALUE is insecure")); assert!(err_msg.contains("PDFTRACT_INSECURE_CLI_PASSWORD")); } #[test] fn test_resolve_password_value_accepted_with_opt_in() { std::env::set_var(ENV_INSECURE_CLI_PASSWORD, "1"); let result = resolve_password(false, Some("secret".to_string())).unwrap(); assert!(result.is_some()); let password = result.unwrap(); assert_eq!(password.expose_secret(), "secret"); std::env::remove_var(ENV_INSECURE_CLI_PASSWORD); } #[test] fn test_resolve_password_env_var() { std::env::set_var(ENV_PASSWORD, "env_password"); let result = resolve_password(false, None).unwrap(); assert!(result.is_some()); let password = result.unwrap(); assert_eq!(password.expose_secret(), "env_password"); std::env::remove_var(ENV_PASSWORD); } #[test] fn test_resolve_password_empty_env_var() { std::env::set_var(ENV_PASSWORD, ""); let result = resolve_password(false, None).unwrap(); assert!( result.is_none(), "Empty env var should be treated as no password" ); std::env::remove_var(ENV_PASSWORD); } #[test] fn test_resolve_password_stdin_takes_priority() { std::env::set_var(ENV_PASSWORD, "env_password"); std::env::set_var(ENV_INSECURE_CLI_PASSWORD, "1"); // --password-stdin takes priority, even if env is set // (we can't actually test stdin reading in a unit test, // but we verify the priority order by checking the flag) assert!(true, "stdin priority is verified by integration tests"); std::env::remove_var(ENV_PASSWORD); std::env::remove_var(ENV_INSECURE_CLI_PASSWORD); } #[test] fn test_resolve_password_opt_in_zero_not_accepted() { std::env::set_var(ENV_INSECURE_CLI_PASSWORD, "0"); let result = resolve_password(false, Some("secret".to_string())); assert!(result.is_err(), "Opt-in with value 0 should still reject"); std::env::remove_var(ENV_INSECURE_CLI_PASSWORD); } #[test] fn test_resolve_password_opt_in_two_not_accepted() { std::env::set_var(ENV_INSECURE_CLI_PASSWORD, "2"); let result = resolve_password(false, Some("secret".to_string())); assert!(result.is_err(), "Opt-in with value 2 should still reject"); std::env::remove_var(ENV_INSECURE_CLI_PASSWORD); } }