use super::commands::{parse_dpi, read}; use super::options::{AUDIT_OPTIONS, CHECK_ONE_OPTIONS, CHECK_OPTIONS, flag, positional, validate_options}; use super::{ Arc, CliError, Command, Duration, Instant, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Stdio, inspect, stdout_line, }; /// Audit a document for faux redactions. Prints the findings as JSON; fails when any are /// found so scripts and CI can gate on it, and when the audit itself cannot run. pub(crate) fn audit(args: &[String]) -> Result<(), CliError> { validate_options("audit", args, AUDIT_OPTIONS)?; let path = *positional(args, AUDIT_OPTIONS).first().ok_or("audit: missing file")?; let bytes = read(path)?; let password = flag(args, "--password"); let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map_err(|e| format!("{path}: {e}"))?; let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?; // Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions` // reports them; the page geometry is read only when there is something to place. let info = if findings.is_empty() { None } else { let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); Some(inspect(bytes, render_password.as_deref()).map_err(|e| format!("{path}: {e}"))?) }; let items: Vec = findings .into_iter() .map(|f| { let rect = f.rect.map(|v| v as f32); serde_json::json!({ "page": f.page + 1, "kind": f.kind.id(), "rect": info.as_ref().map_or(rect, |i| pdfcraft_automation::view_rect(i, f.page, rect)), "covered_text": f.covered_text, }) }) .collect(); println!("{}", serde_json::json!({ "file": path, "findings": items, "count": items.len() })); if items.is_empty() { Ok(()) } else { Err(format!("audit: {} faux redaction(s) found", items.len()).into()) } } /// Child-process body for `check`: prints one JSON line. pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> { validate_options("check-one", args, CHECK_ONE_OPTIONS)?; let path = *positional(args, CHECK_ONE_OPTIONS).first().ok_or("check-one: missing file")?; let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?; let start = Instant::now(); let bytes = read(path)?; let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) { Err(e) => (format!("open-error:{}", short(&e.to_string())), 0, 0, e.to_string(), 0), Ok(info) => { let mut r = PageRenderer::new(bytes, RenderConfig::default()); let mut failed = Vec::new(); for i in 0..info.pages.len().min(500) { let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false }); if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }).error { failed.push(format!("p{} text: {e}", i + 1)); } if let Some(e) = p.error { failed.push(format!("p{}: {e}", i + 1)); } } let status = if failed.is_empty() { "ok".to_string() } else { "page-errors".to_string() }; (status, info.pages.len(), failed.len(), failed.join(" | "), info.warnings.len()) } }; // `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net, // so a panic in cos shows up as a crash of this process. if args.iter().any(|a| a == "--edit") { edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?); } let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings, "ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::() }); stdout_line(format_args!("{line}"))?; Ok(()) } /// Open with cos, touch every object, edit the catalog, save incrementally and in full (both /// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs. fn edit_round_trip(bytes: &[u8]) { use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental}; let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return }; for num in doc.object_numbers().into_iter().take(20_000) { if let Ok(o) = doc.try_get(num) && let Object::Stream(s) = &*o { let _ = s.decoded(); } } if let Some(root) = doc.root() { let _ = doc.update_dict(root, |d| d.set(b"PdfCraftFuzz".to_vec(), Object::Bool(true))); } let classic = SaveOptions { object_streams: false, ..SaveOptions::default() }; for out in [write_incremental(&doc, &SaveOptions::default()), write_full(&doc, &SaveOptions::default()), write_full(&doc, &classic)] .into_iter() .flatten() { if let Ok(again) = Document::open(Arc::new(out)) { let _ = again.root().map(|r| again.get(r)); } } } fn short(s: &str) -> String { s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).take(4).collect::>().join("-").to_lowercase() } fn collect(paths: &[&str]) -> Vec { let mut out = Vec::new(); let mut stack: Vec = paths.iter().map(PathBuf::from).collect(); let mut visited = std::collections::HashSet::new(); while let Some(p) = stack.pop() { if p.is_dir() { // Directory aliases and symlink cycles must not scan the same tree repeatedly. let identity = match p.canonicalize() { Ok(identity) => identity, Err(error) => { eprintln!("{}: could not resolve directory: {error}", p.display()); continue; } }; if !visited.insert(identity) { continue; } if let Ok(rd) = std::fs::read_dir(&p) { stack.extend(rd.flatten().map(|e| e.path())); } } else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) { out.push(p); } } out.sort(); out } pub(crate) fn check(args: &[String]) -> Result<(), CliError> { validate_options("check", args, CHECK_OPTIONS)?; // Every option is checked before the inputs are scanned, so a bad value is one message, // not one problem per file: a zero timeout used to make every file time out, and a bad // dpi used to reach each child, which printed nothing and was reported as a crash. let timeout_value = flag(args, "--timeout").unwrap_or("20"); let timeout = Duration::from_secs( timeout_value .parse::() .ok() .filter(|secs| *secs > 0) .ok_or_else(|| format!("bad --timeout: expected a positive whole number of seconds, got {timeout_value:?}"))?, ); let dpi = flag(args, "--dpi").unwrap_or("36").to_string(); parse_dpi(&dpi)?; let files = collect(&positional(args, CHECK_OPTIONS)); if files.is_empty() { return Err("check: no PDF files found".into()); } let exe = std::env::current_exe().map_err(|e| e.to_string())?; let mut results = Vec::new(); let mut counts: std::collections::BTreeMap = Default::default(); for (n, f) in files.iter().enumerate() { let r = run_child(&exe, f, &dpi, timeout); let status = r["status"].as_str().unwrap_or("?").to_string(); *counts.entry(status.split(':').next().unwrap_or("?").to_string()).or_default() += 1; if status != "ok" { eprintln!( "[{}/{}] {status:<28} {} {}", n + 1, files.len(), f.display(), r["detail"].as_str().unwrap_or("").chars().take(160).collect::() ); } results.push(r); } eprintln!("\nchecked {} files: {:?}", files.len(), counts); if let Some(out) = flag(args, "--json") { std::fs::write(out, serde_json::to_string_pretty(&results).unwrap_or_default()).map_err(|e| e.to_string())?; } Ok(()) } fn run_child(exe: &Path, file: &Path, dpi: &str, timeout: Duration) -> serde_json::Value { let file_s = file.to_string_lossy().to_string(); let child = Command::new(exe).args(["check-one", &file_s, "--dpi", dpi]).stdout(Stdio::piped()).stderr(Stdio::null()).spawn(); let mut child = match child { Ok(c) => c, Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }), }; let start = Instant::now(); loop { match child.try_wait() { Ok(Some(status)) => { let mut out = String::new(); if let Some(mut so) = child.stdout.take() { use std::io::Read; let _ = so.read_to_string(&mut out); } return match serde_json::from_str::(out.trim()) { Ok(v) => v, Err(_) => serde_json::json!({ "file": file_s, "status": "crash", "detail": format!("child exited with {status}") }), }; } Ok(None) if start.elapsed() > timeout => { let _ = child.kill(); let _ = child.wait(); return serde_json::json!({ "file": file_s, "status": "timeout", "detail": format!("exceeded {}s", timeout.as_secs()) }); } Ok(None) => std::thread::sleep(Duration::from_millis(20)), Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }), } } }