//! Encryption acceptance tests. //! //! The merge criteria from `REVIEWS/adr/0005-pdf-encryption.md`. //! //! The fixtures are produced by `tests/corpus/generate.py`, which implements //! the standard security handler's algorithms independently from the //! specification. A fixture that decrypts therefore shows the reader agrees //! with the spec, not merely with itself. //! //! The bug being guarded against is specific: before this existed, an //! encrypted document parsed "successfully", reported a page, and rendered //! blank because the content stream was ciphertext that interpreted to zero //! operators. Every test below asserts real plaintext, not merely `Ok`. use std::path::PathBuf; use nigig_pdf_document::PdfDocument; use nigig_pdf_graphics::content::parse_content_stream; use nigig_pdf_graphics::recording::{RecordingDevice, RenderCommand}; /// The marker every encrypted fixture draws once decrypted. const MARKER: &str = "DecryptedOK"; fn corpus(relative: &str) -> Vec { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../tests/corpus") .join(relative); std::fs::read(&path).unwrap_or_else(|e| panic!("missing {}: {e}", path.display())) } /// Open a fixture and return its first page's content stream. fn decrypted_content(fixture: &str) -> String { let data = corpus(fixture); let mut doc = PdfDocument::parse(&data) .unwrap_or_else(|e| panic!("{fixture} should open with the empty password: {e}")); assert!(doc.is_encrypted(), "{fixture} should report as encrypted"); let page = doc .page(0) .unwrap_or_else(|e| panic!("{fixture} page 0: {e}")); String::from_utf8_lossy(&page.content_data).into_owned() } #[test] fn an_rc4_40_bit_document_decrypts() { let content = decrypted_content("encrypted/rc4_40.pdf"); assert!( content.contains(MARKER), "content did not decrypt, got {content:?}" ); } #[test] fn an_rc4_128_bit_document_decrypts() { let content = decrypted_content("encrypted/rc4_128.pdf"); assert!( content.contains(MARKER), "content did not decrypt, got {content:?}" ); } #[test] fn an_aes_128_document_decrypts() { let content = decrypted_content("encrypted/aes128.pdf"); assert!( content.contains(MARKER), "content did not decrypt, got {content:?}" ); } /// The AES-256 (revision 6) path. /// /// The encryption commit implemented `derive_key_r6` and the AES-256 stream /// path but never ran either against a real file: only the helper functions /// had unit tests. This closes that gap in ADR 0005's own merge criteria. #[test] fn an_aes_256_document_decrypts() { let content = decrypted_content("encrypted/aes256.pdf"); assert!( content.contains(MARKER), "the revision 6 key derivation did not produce the file key, got {content:?}" ); } /// The exact regression: decrypted content must interpret to real operators. /// /// Asserting `Ok` from the parser is not enough — that is what the old /// behaviour did while producing a blank page. #[test] fn decrypted_content_interprets_to_real_render_commands() { for fixture in [ "encrypted/rc4_40.pdf", "encrypted/rc4_128.pdf", "encrypted/aes128.pdf", "encrypted/aes256.pdf", ] { let data = corpus(fixture); let mut doc = PdfDocument::parse(&data).expect("opens"); let page = doc.page(0).expect("page 0"); let ops = parse_content_stream(&page.content_data).expect("content parses"); let commands = RecordingDevice::from_ops(&ops); assert!( !commands.is_empty(), "{fixture} produced no render commands: ciphertext interprets to nothing" ); assert!( commands .iter() .any(|c| matches!(c, RenderCommand::ShowTextWithMetrics { .. })), "{fixture} should draw text" ); } } /// ADR 0005 rule 2: an unsupported handler is refused, never half-opened. #[test] fn a_public_key_handler_is_refused_by_name() { let data = corpus("encrypted/unsupported_handler.pdf"); let err = PdfDocument::parse(&data) .err() .expect("a handler this code cannot implement must be refused"); let message = err.to_string(); assert!( message.contains("Adobe.PubSec"), "the error should name the handler, got {message:?}" ); } #[test] fn a_wrong_password_is_distinguishable_from_a_corrupt_file() { // The fixture uses the empty user password, so any other password fails // authentication. A caller needs to tell "prompt again" from "damaged". let data = corpus("encrypted/rc4_128.pdf"); let err = PdfDocument::parse_with_password(&data, b"definitely-wrong") .err() .expect("a wrong password must be refused"); assert!( err.to_string().contains("password"), "the error should mention the password, got {err}" ); } #[test] fn the_correct_empty_password_opens_the_document_explicitly() { let data = corpus("encrypted/rc4_128.pdf"); let mut doc = PdfDocument::parse_with_password(&data, b"").expect("empty password works"); let page = doc.page(0).expect("page 0"); assert!(String::from_utf8_lossy(&page.content_data).contains(MARKER)); } #[test] fn permissions_are_reported() { let data = corpus("encrypted/rc4_128.pdf"); let doc = PdfDocument::parse(&data).expect("opens"); let permissions = doc.permissions().expect("an encrypted document has /P"); // The fixtures use /P -1, meaning everything is permitted. assert!(permissions.can_print()); assert!(permissions.can_copy()); assert!(permissions.can_modify()); } #[test] fn an_unencrypted_document_reports_no_encryption() { // The flag must mean something, so it has to be false for plain files. let data = corpus("basic/text.pdf"); let doc = PdfDocument::parse(&data).expect("opens"); assert!(!doc.is_encrypted()); assert!(doc.permissions().is_none()); } #[test] fn strings_inside_an_encrypted_document_decrypt() { // Strings use a separate cipher path from streams; a handler that // decrypts only streams leaves every title and field value as garbage. let data = corpus("encrypted/rc4_128.pdf"); let mut doc = PdfDocument::parse(&data).expect("opens"); let page = doc.page(0).expect("page 0"); // The content stream's own literal string is the observable case here. let content = String::from_utf8_lossy(&page.content_data); assert!( content.contains(&format!("({MARKER})")), "the literal string should be readable, got {content:?}" ); } /// ADR 0003 and 0005: saving an encrypted document stays refused. #[test] fn saving_an_encrypted_document_is_still_refused() { use nigig_pdf_cos::incremental::SaveError; use nigig_pdf_document::form::DocumentFormEditor; use nigig_pdf_document::save::save_form_edits; let data = corpus("encrypted/rc4_128.pdf"); let mut doc = PdfDocument::parse(&data).expect("opens"); let trailer = doc.trailer().clone(); let max = doc.max_object_number(); // Even with no form to edit, the refusal must come from the encryption // check rather than from there being nothing to write. let form = doc.acroform().ok().flatten().unwrap_or_default(); let (_, report) = save_form_edits(&data, &trailer, &form, max).expect("no edits, no write"); assert!(report.unchanged); // With an edit queued it must refuse outright. let mut form = form; if let Some(field) = form.fields().first().map(|f| f.obj_ref) { let _ = DocumentFormEditor::new(&mut form).set_text(field, "x"); assert_eq!( save_form_edits(&data, &trailer, &form, max), Err(SaveError::Encrypted) ); } } /// A `/U` shorter than revision 6 requires must be reported, not indexed /// past the end. #[test] fn a_truncated_u_entry_is_reported() { let data = corpus("encrypted/truncated_u.pdf"); let err = PdfDocument::parse(&data) .err() .expect("a malformed /U must be refused"); assert!( err.to_string().contains("/U"), "the error should name the entry, got {err}" ); } #[test] fn a_missing_required_entry_is_reported() { let data = corpus("encrypted/missing_o.pdf"); let err = PdfDocument::parse(&data) .err() .expect("an /Encrypt without /O must be refused"); assert!( err.to_string().contains("/O"), "the error should name the entry, got {err}" ); } /// A `/Length` far outside the legal range must not panic or index out of /// bounds; the key is clamped and authentication then fails cleanly. #[test] fn an_absurd_key_length_is_handled_without_panicking() { let data = corpus("encrypted/absurd_length.pdf"); let err = PdfDocument::parse(&data) .err() .expect("the fixture's /O and /U do not authenticate"); // Whatever the message, the point is that it returned rather than // panicking on a 999999-bit key length. assert!(!err.to_string().is_empty()); } #[test] fn every_encrypted_fixture_walks_without_panicking() { let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../tests/corpus/encrypted"); for entry in std::fs::read_dir(&dir).expect("encrypted corpus").flatten() { let path = entry.path(); if path.extension().is_none_or(|e| e != "pdf") { continue; } let Ok(data) = std::fs::read(&path) else { continue; }; // Some fixtures are deliberately refused; none may panic. if let Ok(mut doc) = PdfDocument::parse(&data) { for index in 0..doc.page_count().min(4) { let _ = doc.page(index); let _ = doc.page_annotations(index); } let _ = doc.acroform(); } let _ = PdfDocument::parse_with_password(&data, b"wrong"); } } /// Truncating an encrypted file must not panic the crypto path. #[test] fn truncated_encrypted_documents_never_panic() { let data = corpus("encrypted/aes128.pdf"); for cut in [1, 8, 64, 200, data.len() / 2, data.len() - 1] { let _ = PdfDocument::parse(&data[..cut.min(data.len())]); } }