pdftract/crates/pdftract-cli/src/password.rs
jedarden e0b293c3d6 fix(pdftract-2a6rk): fix xref.rs u64 literal overflow in proptest
Fixed compilation error in xref.rs where u64 literal 0x5DEECE66D was used
with u32 state, causing overflow. Changed state to u64 for proper Java
Random algorithm behavior.

The OCG /OCProperties parsing implementation was already complete and
all tests pass. See notes/pdftract-2a6rk.md for verification.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 17:26:27 -04:00

189 lines
6.7 KiB
Rust

//! 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<String>,
) -> Result<Option<secrecy::SecretString>> {
// 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::<u8>().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<Option<secrecy::SecretString>> {
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);
}
}