mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 10:39:01 -06:00
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>
180 lines
8.9 KiB
Rust
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());
|
|
}
|