Files
pdfcraft/crates/cos/tests/corpus.rs
0a8b230ca0 D1: pin and verify the pdf.js corpus (#339)
xtask corpus cloned mozilla/pdf.js at HEAD with no commit pin and no content check, and the corpus tests returned early when the directory was missing. So the corpus sweep measured a moving target, xtask/baselines/pdfjs.json was compared against whatever upstream happened to be that day, and a damaged or tampered checkout produced green results. AGENTS.md §2 asks for fixtures fetched pinned by commit and sha256-verified.

Verification is two layers. The commit fixes which tree we ask for; the manifest digest fixes what landed on disk: one '<sha256>  <path>' line per file, sorted by path, then one sha256 over that text. Git object ids are SHA-1, so the manifest is what makes this a sha256 check. The pin is 5d923c9 with 1455 files, which matches what the GitHub tree API reports for test/pdfs.

The checkout sets core.autocrlf=false and core.eol=lf. This repository has autocrlf=true globally, so without that git would rewrite line endings on checkout and the same commit would hash differently on Windows than on Linux; the pin has to mean the same thing everywhere.

A successful verification writes corpus/pdfjs/.pdfcraft-corpus.json. The corpus tests now skip only when nothing is fetched, and fail loudly when a checkout exists without that stamp. xtask check verifies before sweeping, so the baseline is never compared against an unknown tree.

Checked end to end: the unrecorded-pin guard refuses to fetch; --update-pin fetches and prints the constants; appending one byte to one of the 1455 files is detected and repaired by re-fetching; removing the stamp makes the corpus tests fail with a message saying what to run. Against the verified corpus the tests pass: 964 files ok and 7 password cases.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 21:26:54 -04:00

180 lines
8.9 KiB
Rust

//! Corpus parity: every file hayro-syntax can open must also open with our reader, agree on the
//! page count, and survive an incremental save that touches the catalog (the appended revision
//! must be readable by both readers and leave the original bytes untouched).
//!
//! Runs over `corpus/pdfjs/test/pdfs` when present (`cargo xtask corpus` fetches it); when it is
//! absent these are a no-op, so CI without the corpus stays green. Run with
//! `--ignored --nocapture`.
//!
//! A corpus that is present but **unverified** is a failure, not a skip. `xtask corpus` writes
//! `corpus/pdfjs/.pdfcraft-corpus.json` only after the checkout matches the pinned commit and
//! manifest sha256, so these tests refuse to run against a tree nobody has checked. Without that,
//! a half-finished or tampered checkout would quietly produce green results.
use std::sync::Arc;
use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental};
/// `corpus/pdfjs`, or `None` when nothing has been fetched.
///
/// Panics when the checkout exists without the stamp that `cargo xtask corpus` writes on a
/// successful verification: an unverified corpus must fail loudly rather than skip.
fn checkout() -> Option<std::path::PathBuf> {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/pdfjs");
if !dir.is_dir() {
return None;
}
let stamp = dir.join(".pdfcraft-corpus.json");
assert!(
stamp.is_file(),
"{} exists but {} does not: the corpus has not been verified against the pin. \
Run `cargo xtask corpus` (it fetches and verifies), or delete corpus/pdfjs to skip these tests.",
dir.display(),
stamp.display()
);
Some(dir)
}
/// Leaf pages reachable from the catalog (cycle-safe), like viewers count them.
fn page_count(doc: &Document) -> Option<usize> {
let root = doc.get(doc.root()?);
let mut stack = vec![root.as_dict()?.get(b"Pages")?.clone()];
let (mut seen, mut n) = (std::collections::HashSet::new(), 0);
while let Some(o) = stack.pop() {
if let Object::Ref(r) = o
&& !seen.insert(r)
{
continue;
}
let node = doc.resolve(&o);
let Some(d) = node.as_dict() else { continue };
match d.get(b"Kids").map(|k| doc.resolve(k)) {
Some(k) if d.name(b"Type") != Some(b"Page") => stack.extend(k.as_array().cloned().unwrap_or_default().into_iter().rev()),
_ => n += 1,
}
}
Some(n)
}
#[test]
#[ignore = "needs corpus/; run with --ignored"]
fn corpus_parity() {
let Some(root) = checkout() else {
eprintln!("corpus not fetched; skipping (cargo xtask corpus)");
return;
};
let dir = root.join("test/pdfs");
let entries = std::fs::read_dir(&dir).unwrap_or_else(|e| panic!("verified corpus has no {}: {e}", dir.display()));
let mut files: Vec<_> = entries.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|e| e == "pdf")).collect();
files.sort();
let (mut checked, mut encrypted, mut failures) = (0, 0, Vec::new());
for path in &files {
let bytes = Arc::new(std::fs::read(path).unwrap());
let Ok(reference) = std::panic::catch_unwind(|| hayro_syntax::Pdf::new(bytes.clone())) else { continue };
let Ok(reference) = reference else { continue }; // hayro can't open it either: not a parity case
let name = path.file_name().unwrap().to_string_lossy().into_owned();
if std::env::var("COS_ONLY").is_ok_and(|o| o != name) {
continue;
}
if std::env::var_os("COS_TRACE").is_some() {
eprintln!("{name}");
}
let expected = reference.pages().len();
let result = std::panic::catch_unwind(|| -> Result<(), String> {
let mut doc = match Document::open(bytes.clone()) {
Ok(d) => d,
Err(pdfcraft_cos::CosError::NeedsPassword) => return Err("encrypted".into()),
Err(e) => return Err(format!("open: {e}")),
};
// hayro may repair a broken /Count; compare against the leaf walk count loosely.
if page_count(&doc) != Some(expected) && expected > 0 {
return Err(format!("page count {:?} vs hayro {expected}", page_count(&doc)));
}
let root = doc.root().ok_or("no root")?;
doc.update_dict(root, |d| d.set(b"PdfCraftTest".to_vec(), Object::Bool(true))).map_err(|e| e.to_string())?;
let saved = write_incremental(&doc, &SaveOptions::default()).map_err(|e| format!("save: {e}"))?;
// Reconstructed files are rewritten in full; everything else must keep its bytes.
if !doc.revisions().is_empty() && saved.get(..bytes.len()) != Some(&bytes[..]) {
return Err("prefix modified".into());
}
let again = Document::open(Arc::new(saved.clone())).map_err(|e| format!("reopen: {e}"))?;
let flag = again.get(again.root().ok_or("no root after save")?).as_dict().and_then(|d| d.get(b"PdfCraftTest").cloned());
if flag != Some(Object::Bool(true)) {
return Err("edit lost after reopen".into());
}
let hay = hayro_syntax::Pdf::new(Arc::new(saved)).map_err(|e| format!("hayro reopen: {e:?}"))?;
if hay.pages().len() != expected {
return Err(format!("hayro sees {} pages after save, expected {expected}", hay.pages().len()));
}
let full = write_full(&doc, &SaveOptions::default()).map_err(|e| format!("full: {e}"))?;
let hay = hayro_syntax::Pdf::new(Arc::new(full)).map_err(|e| format!("hayro full: {e:?}"))?;
if hay.pages().len() != expected {
return Err(format!("hayro sees {} pages after full save, expected {expected}", hay.pages().len()));
}
Ok(())
});
match result {
Ok(Ok(())) => checked += 1,
Ok(Err(e)) if e == "encrypted" => encrypted += 1,
Ok(Err(e)) => failures.push(format!("{name}: {e}")),
Err(_) => failures.push(format!("{name}: PANIC")),
}
}
eprintln!("cos corpus parity: {checked} ok, {encrypted} encrypted (skipped), {} failures", failures.len());
for f in &failures {
eprintln!(" {f}");
}
assert!(failures.iter().all(|f| !f.ends_with("PANIC")), "no panics");
}
/// Password-protected corpus files (passwords from pdf.js's test manifest): the right password
/// opens them and the edit/save round trip still works; no or a wrong password is reported.
#[test]
#[ignore = "needs corpus/; run with --ignored"]
fn corpus_passwords() {
let Some(root) = checkout() else {
eprintln!("corpus not fetched; skipping (cargo xtask corpus)");
return;
};
let dir = root.join("test");
let manifest =
std::fs::read_to_string(dir.join("test_manifest.json")).unwrap_or_else(|e| panic!("verified corpus has no test_manifest.json: {e}"));
let entries: Vec<serde_json::Value> = serde_json::from_str(&manifest).expect("manifest is JSON");
let cases: Vec<(String, String)> =
entries.iter().filter_map(|e| Some((e.get("file")?.as_str()?.to_string(), e.get("password")?.as_str()?.to_string()))).collect();
assert!(cases.len() >= 5, "manifest parsed ({} cases)", cases.len());
let mut failures = Vec::new();
for (file, pw) in &cases {
let Ok(bytes) = std::fs::read(dir.join(file)) else { continue };
let bytes = Arc::new(bytes);
if !matches!(Document::open(bytes.clone()), Err(pdfcraft_cos::CosError::NeedsPassword)) {
failures.push(format!("{file}: opened without a password"));
}
if !matches!(Document::open_with_password(bytes.clone(), Some("definitely wrong")), Err(pdfcraft_cos::CosError::WrongPassword)) {
failures.push(format!("{file}: wrong password not rejected"));
}
match Document::open_with_password(bytes.clone(), Some(pw)) {
Ok(mut doc) => {
let pages = page_count(&doc);
if pages.unwrap_or(0) == 0 {
failures.push(format!("{file}: no pages after decryption"));
continue;
}
let root = doc.root().unwrap();
doc.update_dict(root, |d| d.set(b"PdfCraftTest".to_vec(), Object::Bool(true))).unwrap();
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
match Document::open_with_password(Arc::new(saved.clone()), Some(pw)) {
Ok(again) if page_count(&again) == pages => {}
other => failures.push(format!("{file}: reopen after save failed ({:?})", other.err())),
}
}
Err(e) => failures.push(format!("{file}: {e}")),
}
}
eprintln!("password corpus: {} cases, {} failures", cases.len(), failures.len());
for f in &failures {
eprintln!(" {f}");
}
assert!(failures.is_empty());
}