#![expect(
clippy::expect_used,
reason = "helpers shared by the tests below; a panic here is a failure"
)]
use std::path::{Path, PathBuf};
use std::sync::Arc;
use pdfrum_edit::{EditDoc, IdSource, SaveMode, SaveOptions, save};
use pdfrum_object::{ObjRef, Object, Resolve, names};
use pdfrum_parser::{Document, LoadOptions, load};
const FIXTURES: [(&str, &[u8]); 7] = [
("encrypted_hello_world_r2.pdf", b"h\xf4tel"),
("encrypted_hello_world_r3.pdf", b"h\xf4tel"),
("encrypted_hello_world_r5.pdf", b"h\xf4tel"),
("encrypted_hello_world_r6.pdf", b"h\xf4tel"),
("encrypted.pdf", b"1234"),
("encrypted.pdf", b"5678"),
("bug_644.pdf", b"a"),
];
fn oracle_checkout() -> PathBuf {
let checkout = std::env::var_os("PDFRUM_ORACLE_CHECKOUT").map_or_else(
|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../pdfium-c++"),
PathBuf::from,
);
if !checkout.is_dir() {
static SAID: std::sync::Once = std::sync::Once::new();
SAID.call_once(|| {
eprintln!(
"skipping the oracle-corpus cases: no checkout at {} \
(set PDFRUM_ORACLE_CHECKOUT)",
checkout.display()
);
});
}
checkout
}
fn resources() -> Option<PathBuf> {
let path = oracle_checkout().join("testing/resources");
path.is_dir().then_some(path)
}
fn open(name: &str, password: &[u8]) -> Option<Document> {
let bytes: Arc<[u8]> = Arc::from(std::fs::read(resources()?.join(name)).ok()?);
let opts = LoadOptions {
password: Some(password.to_vec()),
..LoadOptions::default()
};
Some(load(bytes, &opts).unwrap_or_else(|e| unreachable!("{name} failed to open: {e}")))
}
fn fixed(mode: SaveMode) -> SaveOptions {
SaveOptions {
mode,
id_source: IdSource::Fixed([0x5A; 16]),
..SaveOptions::default()
}
}
fn saved(doc: &Document, opts: &SaveOptions) -> Vec<u8> {
let edit = EditDoc::new(doc);
let mut out = Vec::new();
save(&edit, opts, &mut out).expect("the save succeeds");
out
}
fn reload(bytes: &[u8], password: Option<&[u8]>) -> Option<Document> {
let opts = LoadOptions {
password: password.map(<[u8]>::to_vec),
..LoadOptions::default()
};
load(Arc::from(bytes), &opts).ok()
}
fn strings(obj: &Object, into: &mut Vec<Vec<u8>>) {
match obj {
Object::Str(s) => into.push(s.bytes.to_vec()),
Object::Array(a) => a.iter().for_each(|v| strings(v, into)),
Object::Dict(d) => d.iter().for_each(|(_, v)| strings(v, into)),
Object::Stream(s) => s.dict.iter().for_each(|(_, v)| strings(v, into)),
_ => {}
}
}
fn contents(doc: &Document) -> Vec<(u32, Vec<Vec<u8>>, Vec<u8>)> {
(1..=doc.xref().last_object_number())
.filter_map(|num| {
let obj = doc.fetch(ObjRef::new(num, 0)).ok()?;
if obj.is_null() {
return None;
}
let mut found = Vec::new();
strings(&obj, &mut found);
let data = match &*obj {
Object::Stream(s) => pdfrum_parser::decoded_stream(
s,
doc,
&pdfrum_common::Limits::default(),
&mut pdfrum_common::Diagnostics::default(),
),
_ => Vec::new(),
};
Some((num, found, data))
})
.collect()
}
#[test]
fn every_revision_saves_encrypted_and_reopens_with_its_password() {
for (name, password) in FIXTURES {
let Some(doc) = open(name, password) else {
return;
};
assert!(doc.is_encrypted(), "{name} was not encrypted to begin with");
let out = saved(&doc, &fixed(SaveMode::Full));
let reopened = reload(&out, Some(password))
.unwrap_or_else(|| unreachable!("{name} saved unreadably under its own password"));
assert!(
reopened.is_encrypted(),
"{name} saved decrypted despite remove_security being off"
);
assert_eq!(
reopened.page_count(),
doc.page_count(),
"{name} lost pages in the save"
);
}
}
#[test]
fn a_saved_encrypted_document_refuses_the_wrong_password() {
for (name, password) in FIXTURES {
let Some(doc) = open(name, password) else {
return;
};
let out = saved(&doc, &fixed(SaveMode::Full));
assert!(
reload(&out, Some(b"definitely-not-the-password")).is_none(),
"{name} opened under a password it should not have"
);
assert!(
reload(&out, None).is_none(),
"{name} opened with no password at all"
);
}
}
#[test]
fn a_saved_encrypted_document_holds_the_same_content() {
for (name, password) in FIXTURES {
let Some(doc) = open(name, password) else {
return;
};
let out = saved(&doc, &fixed(SaveMode::Full));
let reopened = reload(&out, Some(password)).expect("reopens");
let before = contents(&doc);
let after = contents(&reopened);
assert!(!before.is_empty(), "{name} held nothing to compare");
for (num, wanted, data) in &before {
let Some((_, got, got_data)) = after.iter().find(|(n, _, _)| n == num) else {
continue;
};
assert_eq!(got, wanted, "{name} object {num}: strings differ");
assert_eq!(got_data, data, "{name} object {num}: stream bytes differ");
}
}
}
#[test]
fn the_encryption_dictionary_survives_as_plaintext() {
for (name, password) in FIXTURES {
let Some(doc) = open(name, password) else {
return;
};
let Some((original, _)) = doc.encrypt_dict() else {
unreachable!("{name} declares no /Encrypt");
};
let wanted: Vec<Vec<u8>> = original
.iter()
.filter_map(|(_, v)| v.as_string().map(|s| s.bytes.to_vec()))
.collect();
assert!(!wanted.is_empty(), "{name}'s /Encrypt holds no strings");
let out = saved(&doc, &fixed(SaveMode::Full));
let reopened = reload(&out, Some(password)).expect("reopens");
let (written, _) = reopened
.encrypt_dict()
.unwrap_or_else(|| unreachable!("{name} saved without an /Encrypt"));
let got: Vec<Vec<u8>> = written
.iter()
.filter_map(|(_, v)| v.as_string().map(|s| s.bytes.to_vec()))
.collect();
assert_eq!(got, wanted, "{name}'s /Encrypt was enciphered");
}
}
#[test]
fn permissions_survive_the_save() {
for (name, password) in FIXTURES {
let Some(doc) = open(name, password) else {
return;
};
let out = saved(&doc, &fixed(SaveMode::Full));
let reopened = reload(&out, Some(password)).expect("reopens");
assert_eq!(
reopened.permissions(),
doc.permissions(),
"{name} changed its permissions"
);
assert_eq!(
reopened.owner_permissions(),
doc.owner_permissions(),
"{name} changed its owner permissions"
);
let p = |d: &Document| d.encrypt_dict().and_then(|(e, _)| e.direct_int(names::P));
assert_eq!(p(&reopened), p(&doc), "{name} rewrote /P");
}
}
#[test]
fn an_incremental_save_of_an_encrypted_document_appends() {
for (name, password) in FIXTURES {
let Some(doc) = open(name, password) else {
return;
};
if doc.xref_was_rebuilt() {
continue;
}
let out = saved(&doc, &fixed(SaveMode::Incremental));
let body = doc.bytes();
assert_eq!(
out.get(..body.len()),
Some(body),
"{name}: the incremental save rewrote the original bytes"
);
let text = String::from_utf8_lossy(&out);
let before = String::from_utf8_lossy(body);
assert!(
text.matches("startxref").count() > before.matches("startxref").count(),
"{name}: no appended cross-reference"
);
assert!(
reload(&out, Some(password)).is_some(),
"{name}: the appended file no longer opens"
);
}
}
#[test]
fn remove_security_still_writes_a_decrypted_document() {
for (name, password) in FIXTURES {
let Some(doc) = open(name, password) else {
return;
};
let opts = SaveOptions {
remove_security: true,
..fixed(SaveMode::Full)
};
let out = saved(&doc, &opts);
let reopened = reload(&out, None)
.unwrap_or_else(|| unreachable!("{name} did not open without a password"));
assert!(!reopened.is_encrypted(), "{name} stayed encrypted");
assert!(reopened.encrypt_dict().is_none(), "{name} kept /Encrypt");
assert_eq!(reopened.page_count(), doc.page_count(), "{name} lost pages");
}
}
#[test]
fn two_saves_share_an_id_and_the_plaintext() {
for (name, password) in FIXTURES {
let Some(doc) = open(name, password) else {
return;
};
let first = saved(&doc, &fixed(SaveMode::Full));
let second = saved(&doc, &fixed(SaveMode::Full));
let a = reload(&first, Some(password))
.unwrap_or_else(|| unreachable!("{name}: first save unreadably"));
let b = reload(&second, Some(password))
.unwrap_or_else(|| unreachable!("{name}: second save unreadably"));
let id = |d: &Document| d.trailer().raw(names::ID).cloned();
assert_eq!(
id(&a),
id(&b),
"{name}: /ID changed between two seeded saves"
);
assert_eq!(
contents(&a),
contents(&b),
"{name}: two saves deciphered to different objects"
);
}
}
#[test]
fn the_saved_body_is_not_the_plaintext() {
for (name, password) in FIXTURES {
let Some(doc) = open(name, password) else {
return;
};
let Some(plaintext) = (1..=doc.xref().last_object_number()).find_map(|num| {
match &*doc.fetch(ObjRef::new(num, 0)).ok()? {
Object::Stream(s) if s.data.as_bytes().len() >= 32 => {
Some(s.data.as_bytes().to_vec())
}
_ => None,
}
}) else {
continue;
};
let out = saved(&doc, &fixed(SaveMode::Full));
assert!(
!out.windows(plaintext.len()).any(|w| w == plaintext),
"{name}: a stream's plaintext appears verbatim in the saved file"
);
}
}
#[test]
fn a_mutated_encrypted_document_saves_re_encrypted() {
for (name, password) in FIXTURES {
let Some(doc) = open(name, password) else {
return;
};
let Ok(page_dict) = doc.page(0) else {
continue;
};
let Some(page_ref) = page_dict.reference else {
continue;
};
let resources = page_dict
.inherited(names::RESOURCES, &doc)
.and_then(|object| object.resolve(&doc).ok()?.as_dict().cloned())
.unwrap_or_default();
let mut ctx = pdfrum_page::BuildContext::new();
let mut diags = pdfrum_common::Diagnostics::default();
let limits = pdfrum_common::Limits::default();
let bytes = page_contents(&doc, &page_dict.dict, &limits, &mut diags);
let ops = pdfrum_page::parse_content(&bytes, &limits, &mut diags);
let mut page = pdfrum_page::build_page_streams(
&ops,
&pdfrum_page::StreamBounds::default(),
&page_dict.dict,
|key| page_dict.inherited(key, &doc),
&pdfrum_page::Resources::for_page(Some(resources.clone())),
&doc,
&mut ctx,
&limits,
&mut diags,
);
if page.objects().is_empty() {
continue;
}
for index in 0..page.objects().len() {
page.object_mut(index);
}
let mut edit = EditDoc::new(&doc);
let rewrite =
pdfrum_edit::regenerate(&page, &resources, &doc).expect("every object is dirty");
let shared = pdfrum_edit::shared_objects(&edit);
pdfrum_edit::apply_rewrite(&mut edit, page_ref, &page_dict.dict, &rewrite, &shared);
let mut out = Vec::new();
save(&edit, &fixed(SaveMode::Full), &mut out).expect("the save succeeds");
assert!(
reload(&out, None).is_none(),
"{name}: a mutated save opened with no password"
);
let reopened = reload(&out, Some(password)).expect("reopens with its password");
assert_eq!(reopened.page_count(), doc.page_count());
let prologue = b"0 0 0 RG 0 0 0 rg 1 w 0 J 0 j";
assert!(
!out.windows(prologue.len()).any(|w| w == prologue),
"{name}: a regenerated stream was written in the clear"
);
}
}
fn page_contents(
doc: &Document,
page: &pdfrum_object::Dict,
limits: &pdfrum_common::Limits,
diags: &mut pdfrum_common::Diagnostics,
) -> Vec<u8> {
let Some(contents) = page.get(names::CONTENTS, doc) else {
return Vec::new();
};
let mut out = Vec::new();
let mut push = |object: &Object, out: &mut Vec<u8>| {
if let Some(stream) = object.as_stream() {
out.extend_from_slice(
&pdfrum_filters::decode_chain(stream, 0, doc, limits, diags).data,
);
out.push(b' ');
}
};
let Some(direct) = contents.as_direct() else {
return out;
};
match direct {
Object::Stream(_) => push(direct, &mut out),
Object::Array(array) => {
for element in array.iter() {
if let Ok(resolved) = element.resolve(doc) {
push(resolved.get(), &mut out);
}
}
}
_ => {}
}
out
}