Add comprehensive shared utilities for encryption testing across the pdftract workspace. Provides path resolution, test execution helpers, assertion functions, fixture validation, mock data builders, and test constants. Features: - Path resolution: workspace_root(), pdftract_bin(), encrypted_fixture() - Test execution: run_pdftract_extract(), stdin password support - Assertions: encryption_exit_code, diagnostics, success/failure checks - Fixture validation: PDF structure checks, fixture existence - Mock builders: RC4, AES-128, AES-256 encryption dictionaries (decrypt feature) - Constants: exit codes, fixture list, test passwords Files: - tests/encryption_fixtures.rs - Main fixtures module (461 lines) - tests/lib.rs - Test support library - tests/ENCRYPTION_FIXTURES.md - Documentation (161 lines) - tests/verify_encryption_fixtures.rs - Verification tests (137 lines) - tests/encryption_fixtures_usage_example.rs - Usage examples (81 lines) - tests/mod.rs - Module aggregation Acceptance criteria: ✅ Common test fixtures added (5 encrypted PDFs) ✅ Helper functions added (20+ functions) ✅ Code compiles successfully ✅ Functions usable across multiple encryption tests Closes bf-2np8r Verification: notes/bf-2np8r.md
460 lines
15 KiB
Rust
460 lines
15 KiB
Rust
//! Common test fixtures and helper functions for encryption testing
|
|
//!
|
|
//! This module provides shared utilities for encryption-related tests across
|
|
//! the pdftract codebase. It includes:
|
|
//! - Test fixture path resolution
|
|
//! - Binary path resolution
|
|
//! - Common test constants
|
|
//! - Helper functions for running pdftract and validating output
|
|
//! - Mock data builders for encryption dictionaries
|
|
//!
|
|
//! # Usage
|
|
//!
|
|
//! ```rust
|
|
//! use pdftract_tests::encryption_fixtures::*;
|
|
//!
|
|
//! let bin = pdftract_bin();
|
|
//! let fixture = encrypted_fixture("livecycle.pdf");
|
|
//! let output = run_pdftract_extract(&bin, &fixture, None);
|
|
//! assert_encryption_exit_code(&output, &fixture);
|
|
//! ```
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::fs;
|
|
|
|
// Re-export exit code constants from CLI module
|
|
pub use pdftract_cli::hash::{EXIT_ENCRYPTED, EXIT_SUCCESS};
|
|
|
|
// Re-export diagnostic types for error message validation
|
|
pub use pdftract_core::diagnostics::{DiagCode, Diagnostic};
|
|
|
|
// Re-export encryption error types for testing
|
|
pub use pdftract_core::encryption::decryptor::DecryptionError;
|
|
|
|
// ============================================================================
|
|
// Test Constants
|
|
// ============================================================================
|
|
|
|
/// Exit code for encryption errors (should match EXIT_ENCRYPTED from CLI)
|
|
pub const EXPECTED_ENCRYPTION_EXIT_CODE: i32 = 3;
|
|
|
|
/// Expected encrypted fixture files for testing
|
|
pub const ENCRYPTED_FIXTURES: &[&str] = &[
|
|
"livecycle.pdf",
|
|
"EC-04-rc4-encrypted.pdf",
|
|
"EC-05-aes128-encrypted.pdf",
|
|
"EC-06-aes256-encrypted.pdf",
|
|
"EC-empty-password.pdf",
|
|
];
|
|
|
|
/// Test password for encrypted fixtures (when available)
|
|
pub const TEST_PASSWORD: &str = "test123";
|
|
|
|
/// Wrong password for testing error handling
|
|
pub const WRONG_PASSWORD: &str = "wrongpassword";
|
|
|
|
// ============================================================================
|
|
// Path Resolution Functions
|
|
// ============================================================================
|
|
|
|
/// Get the workspace root directory
|
|
pub fn workspace_root() -> PathBuf {
|
|
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
|
|
.unwrap_or_else(|_| ".".to_string());
|
|
let path = PathBuf::from(manifest_dir);
|
|
|
|
// If we're in tests/ directory, parent() is workspace root
|
|
// If we're in crates/pdftract-cli/tests/, go up two levels
|
|
if path.ends_with("tests") {
|
|
path.parent().unwrap().to_path_buf()
|
|
} else if path.ends_with("pdftract-cli") {
|
|
path.parent().unwrap().parent().unwrap().to_path_buf()
|
|
} else if path.ends_with("pdftract-core") {
|
|
path.parent().unwrap().parent().unwrap().to_path_buf()
|
|
} else {
|
|
path
|
|
}
|
|
}
|
|
|
|
/// Get the path to the pdftract binary (cargo build output)
|
|
pub fn pdftract_bin() -> PathBuf {
|
|
let mut path = workspace_root();
|
|
path.push("target/debug/pdftract");
|
|
|
|
// Fall back to release if debug doesn't exist
|
|
if !path.exists() {
|
|
let mut release_path = workspace_root();
|
|
release_path.push("target/release/pdftract");
|
|
return release_path;
|
|
}
|
|
|
|
path
|
|
}
|
|
|
|
/// Path to encrypted fixtures directory
|
|
pub fn encrypted_fixtures_dir() -> PathBuf {
|
|
workspace_root().join("tests/fixtures/encrypted")
|
|
}
|
|
|
|
/// Get the path to a specific encrypted fixture
|
|
pub fn encrypted_fixture(name: &str) -> PathBuf {
|
|
encrypted_fixtures_dir().join(name)
|
|
}
|
|
|
|
/// Path to general fixtures directory
|
|
pub fn fixtures_dir() -> PathBuf {
|
|
workspace_root().join("tests/fixtures")
|
|
}
|
|
|
|
/// Get the path to a general fixture (not encrypted)
|
|
pub fn fixture(name: &str) -> PathBuf {
|
|
fixtures_dir().join(name)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test Execution Helpers
|
|
// ============================================================================
|
|
|
|
/// Run pdftract extract on a file with optional password
|
|
pub fn run_pdftract_extract(
|
|
bin: &Path,
|
|
pdf_path: &Path,
|
|
password: Option<&str>,
|
|
) -> std::process::Output {
|
|
let mut cmd = Command::new(bin);
|
|
cmd.args(["extract", pdf_path.to_str().unwrap()]);
|
|
|
|
if let Some(pwd) = password {
|
|
cmd.args(["--password", pwd]);
|
|
}
|
|
|
|
cmd.output().expect("Failed to execute pdftract extract")
|
|
}
|
|
|
|
/// Run pdftract extract with stdin password
|
|
pub fn run_pdftract_extract_with_stdin_password(
|
|
bin: &Path,
|
|
pdf_path: &Path,
|
|
password: &str,
|
|
) -> std::process::Output {
|
|
Command::new(bin)
|
|
.args(["extract", pdf_path.to_str().unwrap()])
|
|
.stdin(std::process::Stdio::piped())
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::piped())
|
|
.output()
|
|
.expect("Failed to execute pdftract extract with stdin password")
|
|
}
|
|
|
|
// ============================================================================
|
|
// Assertion Helpers
|
|
// ============================================================================
|
|
|
|
/// Assert that a command output contains encryption-related diagnostics
|
|
pub fn assert_encryption_diagnostic(output: &std::process::Output, fixture_name: &str) {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let combined = format!("{} {}", stderr, stdout);
|
|
|
|
assert!(
|
|
combined.contains("ENCRYPTION_UNSUPPORTED")
|
|
|| combined.contains("Unsupported encryption")
|
|
|| combined.contains("encrypted"),
|
|
"Output for {} should contain encryption-related diagnostic, got: {}",
|
|
fixture_name,
|
|
combined
|
|
);
|
|
}
|
|
|
|
/// Assert that a command exits with the encryption error code
|
|
pub fn assert_encryption_exit_code(output: &std::process::Output, fixture_name: &str) {
|
|
let exit_code = output.status.code().unwrap_or(0);
|
|
assert_eq!(
|
|
exit_code, EXPECTED_ENCRYPTION_EXIT_CODE,
|
|
"{} should exit with code {} for ENCRYPTION_UNSUPPORTED, got: {}",
|
|
fixture_name, EXPECTED_ENCRYPTION_EXIT_CODE, exit_code
|
|
);
|
|
}
|
|
|
|
/// Assert that a command exits with success code
|
|
pub fn assert_success_exit_code(output: &std::process::Output) {
|
|
assert!(
|
|
output.status.success(),
|
|
"Command should succeed, got exit code: {:?}",
|
|
output.status.code()
|
|
);
|
|
}
|
|
|
|
/// Assert that a command failed with non-zero exit code
|
|
pub fn assert_failure_exit_code(output: &std::process::Output) {
|
|
assert!(
|
|
!output.status.success(),
|
|
"Command should fail with non-zero exit code, got: {:?}",
|
|
output.status.code()
|
|
);
|
|
}
|
|
|
|
/// Assert output contains specific message
|
|
pub fn assert_output_contains(output: &std::process::Output, message: &str) {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let combined = format!("{} {}", stderr, stdout);
|
|
|
|
assert!(
|
|
combined.contains(message),
|
|
"Output should contain '{}', got: {}",
|
|
message,
|
|
combined
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Fixture Validation Helpers
|
|
// ============================================================================
|
|
|
|
/// Verify a fixture file exists
|
|
pub fn assert_fixture_exists(fixture_path: &Path) {
|
|
assert!(
|
|
fixture_path.exists(),
|
|
"Fixture not found: {}",
|
|
fixture_path.display()
|
|
);
|
|
}
|
|
|
|
/// Verify a fixture file is a valid PDF (basic structure check)
|
|
pub fn assert_valid_pdf_structure(fixture_path: &Path) {
|
|
let content = fs::read(fixture_path)
|
|
.expect(&format!("Failed to read fixture: {}", fixture_path.display()));
|
|
|
|
// Check for PDF magic number (%PDF-)
|
|
assert!(
|
|
content.starts_with(b"%PDF-"),
|
|
"Fixture {} is not a valid PDF (missing %PDF- header)",
|
|
fixture_path.display()
|
|
);
|
|
|
|
// Check for EOF marker
|
|
let content_str = String::from_utf8_lossy(&content);
|
|
assert!(
|
|
content_str.contains("%%EOF"),
|
|
"Fixture {} is not a valid PDF (missing %%EOF marker)",
|
|
fixture_path.display()
|
|
);
|
|
}
|
|
|
|
/// Verify all encrypted fixture files exist
|
|
pub fn assert_encrypted_fixtures_exist() {
|
|
let fixture_dir = encrypted_fixtures_dir();
|
|
assert!(
|
|
fixture_dir.exists(),
|
|
"Encrypted fixtures directory not found: {}",
|
|
fixture_dir.display()
|
|
);
|
|
|
|
for fixture_name in ENCRYPTED_FIXTURES {
|
|
let path = encrypted_fixture(fixture_name);
|
|
if !path.exists() {
|
|
println!("Skipping {} (not found)", fixture_name);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Mock Data Builders (for core encryption tests)
|
|
// ============================================================================
|
|
|
|
#[cfg(feature = "decrypt")]
|
|
use pdftract_core::parser::object::{PdfDict, PdfObject};
|
|
|
|
/// Create a PdfDict from a vector of key-value pairs
|
|
#[cfg(feature = "decrypt")]
|
|
pub fn make_dict(entries: Vec<(&str, PdfObject)>) -> PdfDict {
|
|
entries.into_iter().map(|(k, v)| (k.into(), v)).collect()
|
|
}
|
|
|
|
/// Create a mock trailer dictionary with encryption
|
|
#[cfg(feature = "decrypt")]
|
|
pub fn make_trailer(encrypt_dict: PdfDict, id: Option<Vec<u8>>) -> PdfDict {
|
|
let mut trailer = make_dict(vec![
|
|
("/Root", PdfObject::Ref(pdftract_core::parser::object::ObjRef::new(1, 0))),
|
|
("/Encrypt", PdfObject::Dict(Box::new(encrypt_dict))),
|
|
]);
|
|
|
|
if let Some(id_bytes) = id {
|
|
trailer.insert(
|
|
"/ID".into(),
|
|
PdfObject::Array(Box::new(vec![PdfObject::String(Box::new(id_bytes))]))
|
|
);
|
|
}
|
|
|
|
trailer
|
|
}
|
|
|
|
/// Create a mock RC4-40 encryption dictionary (V=1, R=2)
|
|
#[cfg(feature = "decrypt")]
|
|
pub fn make_rc4_encryption_dict() -> PdfDict {
|
|
make_dict(vec![
|
|
("/Filter", PdfObject::Name("Standard".into())),
|
|
("/V", PdfObject::Integer(1)),
|
|
("/R", PdfObject::Integer(2)),
|
|
("/O", PdfObject::String(Box::new(vec![0u8; 32]))),
|
|
("/U", PdfObject::String(Box::new(vec![0u8; 32]))),
|
|
("/P", PdfObject::Integer(0xFFFFFFFF_i64)),
|
|
])
|
|
}
|
|
|
|
/// Create a mock AES-128 encryption dictionary (V=4, R=4)
|
|
#[cfg(feature = "decrypt")]
|
|
pub fn make_aes128_encryption_dict() -> PdfDict {
|
|
make_dict(vec![
|
|
("/Filter", PdfObject::Name("Standard".into())),
|
|
("/V", PdfObject::Integer(4)),
|
|
("/R", PdfObject::Integer(4)),
|
|
("/O", PdfObject::String(Box::new(vec![0u8; 32]))),
|
|
("/U", PdfObject::String(Box::new(vec![0u8; 32]))),
|
|
("/P", PdfObject::Integer(0xFFFFFFFF_i64)),
|
|
("/StmF", PdfObject::Name("/Identity".into())),
|
|
("/StrF", PdfObject::Name("/Identity".into())),
|
|
])
|
|
}
|
|
|
|
/// Create a mock AES-256 encryption dictionary (V=5, R=6)
|
|
#[cfg(feature = "decrypt")]
|
|
pub fn make_aes256_encryption_dict() -> PdfDict {
|
|
make_dict(vec![
|
|
("/Filter", PdfObject::Name("Standard".into())),
|
|
("/V", PdfObject::Integer(5)),
|
|
("/R", PdfObject::Integer(6)),
|
|
("/O", PdfObject::String(Box::new(vec![0u8; 48]))),
|
|
("/U", PdfObject::String(Box::new(vec![0u8; 48]))),
|
|
("/P", PdfObject::Integer(0xFFFFFFFF_i64)),
|
|
("/UE", PdfObject::String(Box::new(vec![0u8; 32]))),
|
|
("/OE", PdfObject::String(Box::new(vec![0u8; 32]))),
|
|
("/Perms", PdfObject::String(Box::new({
|
|
let mut perms = [0u8; 16];
|
|
perms[0..4].copy_from_slice(&0xFFFFFFFFu32.to_le_bytes());
|
|
perms.to_vec()
|
|
}))),
|
|
])
|
|
}
|
|
|
|
/// Create a mock unsupported encryption dictionary
|
|
#[cfg(feature = "decrypt")]
|
|
pub fn make_unsupported_encryption_dict(filter_name: &str) -> PdfDict {
|
|
make_dict(vec![
|
|
("/Filter", PdfObject::Name(filter_name.into())),
|
|
("/V", PdfObject::Integer(1)),
|
|
("/R", PdfObject::Integer(2)),
|
|
])
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test Suite Builders
|
|
// ============================================================================
|
|
|
|
/// Macro to generate parameterized tests for multiple fixtures
|
|
#[macro_export]
|
|
macro_rules! test_all_fixtures {
|
|
($test_name:ident, $test_body:expr) => {
|
|
pub fn $test_name() {
|
|
let bin = pdftract_bin();
|
|
for fixture_name in ENCRYPTED_FIXTURES {
|
|
let fixture_path = encrypted_fixture(fixture_name);
|
|
|
|
if !fixture_path.exists() {
|
|
println!("Skipping {} (not found)", fixture_name);
|
|
continue;
|
|
}
|
|
|
|
$test_body(&bin, &fixture_path, fixture_name);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// Module Tests
|
|
// ============================================================================
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_workspace_root_resolution() {
|
|
let root = workspace_root();
|
|
assert!(root.exists(), "Workspace root should exist: {}", root.display());
|
|
}
|
|
|
|
#[test]
|
|
fn test_encrypted_fixtures_dir_exists() {
|
|
let dir = encrypted_fixtures_dir();
|
|
assert!(dir.exists(), "Encrypted fixtures directory should exist: {}", dir.display());
|
|
}
|
|
|
|
#[test]
|
|
fn test_pdftract_bin_path() {
|
|
let bin = pdftract_bin();
|
|
// Don't assert existence here since binary might not be built yet
|
|
assert!(bin.ends_with("pdftract"), "Binary path should end with 'pdftract'");
|
|
}
|
|
|
|
#[test]
|
|
fn test_livecycle_fixture_path() {
|
|
let fixture = encrypted_fixture("livecycle.pdf");
|
|
assert!(
|
|
fixture.ends_with("tests/fixtures/encrypted/livecycle.pdf"),
|
|
"Fixture path should resolve correctly: {}",
|
|
fixture.display()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_constants_defined() {
|
|
assert_eq!(EXPECTED_ENCRYPTION_EXIT_CODE, 3);
|
|
assert!(!ENCRYPTED_FIXTURES.is_empty());
|
|
assert!(!TEST_PASSWORD.is_empty());
|
|
assert!(!WRONG_PASSWORD.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(feature = "decrypt")]
|
|
fn test_make_dict_creates_valid_dict() {
|
|
let dict = make_dict(vec![
|
|
("/Filter", PdfObject::Name("Standard".into())),
|
|
("/V", PdfObject::Integer(1)),
|
|
]);
|
|
|
|
assert!(dict.contains_key("/Filter"));
|
|
assert!(dict.contains_key("/V"));
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(feature = "decrypt")]
|
|
fn test_rc4_encryption_dict_builder() {
|
|
let dict = make_rc4_encryption_dict();
|
|
assert!(dict.contains_key("/Filter"));
|
|
assert!(dict.contains_key("/V"));
|
|
assert!(dict.contains_key("/R"));
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(feature = "decrypt")]
|
|
fn test_aes128_encryption_dict_builder() {
|
|
let dict = make_aes128_encryption_dict();
|
|
assert!(dict.contains_key("/Filter"));
|
|
assert!(dict.contains_key("/V"));
|
|
assert!(dict.contains_key("/StmF"));
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(feature = "decrypt")]
|
|
fn test_aes256_encryption_dict_builder() {
|
|
let dict = make_aes256_encryption_dict();
|
|
assert!(dict.contains_key("/Filter"));
|
|
assert!(dict.contains_key("/V"));
|
|
assert!(dict.contains_key("/UE"));
|
|
assert!(dict.contains_key("/OE"));
|
|
}
|
|
}
|