From 60f60fc0a2c3fd58f6679483cfae1d2478e673dd Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 22:25:30 +0200 Subject: [PATCH] Give a redacted or sanitized copy a new file identifier (#3734) * cos: give a redacted or sanitized full save a new file identifier A full save that the document asked for (Document::require_full_save_with_new_id) now writes a fresh /ID pair, so a redacted or sanitized copy does not claim to be the file it came from (ISO 32000-2 section 14.4). Ordinary saves are unchanged. An encrypted file keeps the first string of /ID, because the file key is derived from it, and gets a new second string. An encrypted file whose /ID is not a pair of strings is left as it was, as before. SaveOptions::id_entropy lets a caller or a test supply entropy; with it the new identifier depends only on that entropy and the written bytes. Without it the identifier mixes the standard library's random hash keys and a process-wide counter, the same facility the engine uses for new encryption keys. Checked: the new tests in crates/cos/tests/file_identifier.rs fail on the clean base (compile errors for the missing API), fail on assertions when only the API is in place, and fail again when each pinned line is reverted (the regeneration call, the first-string keep, the flag, the entropy input, and the ordinary-save guard). cargo test -p pdfcraft-cos passes, and clippy with warnings denied is clean. A probe that wrote 16 ordinary full and incremental saves gave byte-identical files with and without this change. * redact: give a redacted or sanitized copy a new file identifier Applying redactions, sanitizing and removing hidden information now ask the cos layer for a new file identifier (Document::require_full_save_with_new_id), so the saved copy no longer carries the /ID of its source. Before this change the previous revision was dropped but the identifier still linked the copy to the original. The request sits in remove_hidden itself, because its per-category callers (the remove-hidden edit and the optimizer's discard option) call it directly, not through sanitize(). sanitize() asks only on its nothing-hidden path, where remove_hidden returned before asking: the rewrite is a new file either way. Checked: the new tests a_redacted_or_sanitized_copy_gets_a_new_identifier and a_remove_hidden_copy_gets_a_new_identifier fail before this change (the request is missing after apply, and after remove_hidden) and fail again when the apply call, the nothing-hidden branch or the remove_hidden request is reverted. cargo test -p pdfcraft-redact passes with the tests included (152 passed, 1 ignored); cargo clippy with warnings denied and cargo fmt --check are clean. * docs(cos): say when a full save regenerates the file identifier The writing section of the crate README described full saves without saying what happens to /ID. It now says that an ordinary save keeps the identifier, that a document which asks for a new one (redaction and sanitizing) gets both strings regenerated, that SaveOptions::id_entropy supplies entropy for reproducible output, and that an encrypted file keeps its first string because the key depends on it. Checked: the text matches the code added in e1f761cc (regenerate_id, new_id_bytes, require_full_save_with_new_id) and the tests in crates/cos/tests/file_identifier.rs. No code changed in this commit. * cos: hash the whole rewritten file into the new identifier The identifier of a requested rewrite hashed only the first 64 KiB of the written file. With caller-supplied id_entropy the identifier is deterministic, so two rewrites of documents with the same length that differ only after byte 65,536 received the same /ID, and a later edit of a large document could keep its previous /ID. Every written byte is now hashed. The output is read once: one loop over 4 KiB chunks feeds both halves of the 16-byte identifier, so the extra cost is one linear pass over the output. Measured with a probe in the workspace dev profile (opt-level 1): hashing 64 MiB took 0.024 s, about 24 ms per 64 MiB of output. A release build was not measured. Ordinary saves never reach this code, and their output is unchanged. Checked: crates/cos/tests/file_identifier.rs gains the_identifier_depends_on_the_whole_rewritten_file. It fails on the branch head before this change ("content past 64 KiB must change the identifier", both identifiers equal) and passes after it; reverting only the chunk loop to the 64 KiB prefix makes it fail again. The six existing tests in that file pass, and so do the full cos and redact test suites. * cos: keep the key valid when an encrypted rewrite has no usable /ID A requested rewrite of an encrypted file whose /ID was absent, had one entry, or had a first entry that is not a string wrote no identifier at all, although the document had asked for a new one. The reader takes the empty string as the key input for these shapes (Document::first_id), so the rewrite now writes the empty string (<>) as the first entry and a new second entry. The key stays valid. A file whose first entry is a string keeps that string, as before. Ordinary saves and unencrypted rewrites are unchanged. Checked: an_encrypted_rewrite_without_a_usable_identifier_still_gets_a_new_one in crates/cos/tests/file_identifier.rs covers the three shapes for RC4-128, AES-128 and AES-256. After the rewrite each file opens with its password, the first string is empty and the second is new. On the branch head the test fails with "the rewrite writes a pair" (left 0, right 2). Reverting only the guard to the head's early return makes it fail again. Isolated, the one-entry shape fails with left 1, right 2, and the non-string shape with left Some(0), right Some(16). The other seven tests in the file pass. * cos: derive the identifier from caller entropy with a fixed hash With SaveOptions::id_entropy the identifier was built with std's DefaultHasher, whose algorithm the standard library does not specify, and with write_usize (4 bytes on wasm32, 8 on 64-bit targets) and native-endian integers. The same entropy and the same file could therefore give different identifiers on different platforms or toolchains, although the documentation promised the bytes depend only on the entropy and the written file. The identifier is now FNV-1a (64 bit, the published constants) over fixed-width big-endian fields and the whole written file: the salt, the lane, id_seed and the length, then the bytes. With caller entropy the salt is the FNV-1a hash of that entropy. Without entropy it is still the standard library's random hash of a process counter, so only the salt differs between the two paths. Every written byte is hashed, in one pass that feeds both lanes. Cost: this pass is slower than the SipHash it replaces. A temporary probe in the workspace dev profile (opt-level 1) hashed 64 MiB through both lanes in 0.129 s (about 500 MiB/s), against 0.024 s before. A release build was not measured. The cost is paid once per requested rewrite; ordinary saves do not reach this code. Checked: the golden test fixed_entropy_rewrite in crates/cos/tests/file_identifier.rs pins the 16 bytes for a fixed entropy and the standard fixture. On the previous hash it fails (left [64, 145, ...], right [50, 125, ...]). An independent re-implementation of the same formula in Python, run over the bytes the writer hashed (everything before startxref), gives the same 16 bytes. The unit test fnv1a_is_the_published_64_bit_algorithm checks the constants against the published vectors. FNV is not collision-resistant against chosen content; that matters only if someone could choose a file's identifier, and the identifier only has to be unique. * Repair review branch integration * cos: take fresh identifier entropy from the browser on wasm Without SaveOptions::id_entropy the salt of a rewritten file's identifier was RandomState and a process-wide counter. On wasm32-unknown-unknown those hash keys are fixed at compile time, so two fresh web apps that followed the same allocation and call sequence reproduced the same salt and the same identifier for identical output, and a redacted copy stayed linkable to its source. The default salt now comes from the browser first: crypto.getRandomValues (Web Crypto, through js_sys, sixteen bytes folded with the same FNV-1a the identifier uses), or Math.random where that API is missing, and only when neither is reachable the previous RandomState salt. js-sys and wasm-bindgen join pdfcraft-cos as wasm32-only dependencies; both are already in the dependency tree of the web shell, and native builds compile neither. Caller entropy is untouched: with id_entropy the identifier is still a pure function of that entropy, the seed and the written bytes. Checked: separate_fresh_processes_get_different_identifiers re-runs this test binary as two fresh child processes over the same fixture and fails when the default salt is made constant (so does the in-process divergence test); stand_in_for_a_fresh_process is its no-op child half. Both pass with the real salt. On WebAssembly no browser test harness exists, so the browser path is covered by the wasm32 cargo check and by construction, not by a test. cargo check --target wasm32-unknown-unknown -p pdfcraft-cos passes, cargo test -p pdfcraft-cos and -p pdfcraft-redact pass, and clippy with warnings denied is clean on both. * Extract full-save trailer assembly Assisted-by: OpenAI Codex --------- Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> Co-authored-by: FredApps Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- Cargo.lock | 2 + crates/cos/Cargo.toml | 7 + crates/cos/README.md | 8 + crates/cos/src/document.rs | 3 + crates/cos/src/document/open.rs | 15 ++ crates/cos/src/writer.rs | 229 ++++++++++++++++++++--- crates/cos/tests/file_identifier.rs | 279 ++++++++++++++++++++++++++++ crates/redact/src/lib.rs | 5 +- crates/redact/src/sanitize.rs | 11 +- crates/redact/src/tests.rs | 81 ++++++++ 10 files changed, 614 insertions(+), 26 deletions(-) create mode 100644 crates/cos/tests/file_identifier.rs diff --git a/Cargo.lock b/Cargo.lock index 1bf064f5d2..e68f422a52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4333,6 +4333,7 @@ version = "0.18.1" dependencies = [ "chrono", "hayro-syntax", + "js-sys", "pdfcraft-crypt", "pdfcraft-filters", "pdfcraft-oracle", @@ -4340,6 +4341,7 @@ dependencies = [ "proptest", "serde_json", "thiserror 2.0.21", + "wasm-bindgen", ] [[package]] diff --git a/crates/cos/Cargo.toml b/crates/cos/Cargo.toml index a2fb3f4c4e..0bf2e894d2 100644 --- a/crates/cos/Cargo.toml +++ b/crates/cos/Cargo.toml @@ -15,6 +15,13 @@ thiserror = { workspace = true } # PDF date strings (`pdf_date`) go through chrono (no hand-rolled calendar code). chrono = { workspace = true } +# On the web, a rewrite's fresh file identifier takes its entropy from the browser +# (`crypto.getRandomValues`). Both crates are already in the dependency tree of the web shell +# (`js-sys` 0.3 / `wasm-bindgen` 0.2); native builds compile neither. +[target.'cfg(target_arch = "wasm32")'.dependencies] +js-sys = "0.3" +wasm-bindgen = "0.2" + [dev-dependencies] pdfcraft-oracle = { workspace = true } pdfcraft-testkit = { workspace = true } diff --git a/crates/cos/README.md b/crates/cos/README.md index 398b161625..774f2c4367 100644 --- a/crates/cos/README.md +++ b/crates/cos/README.md @@ -89,6 +89,14 @@ into compressed object streams with a cross-reference stream (PDF 1.5+); turning classic table any reader accepts. Both forms map accepted xref/header aliases to one output object, retaining shared identity such as page-tree parent links; unrelated generations stay null. +A full save keeps the file's `/ID` as it is, unless the document asked for a new identifier with +`Document::require_full_save_with_new_id` (redaction and sanitizing do). Then both strings are +regenerated, and `SaveOptions::id_entropy` can supply entropy for reproducible output (tests pin +output this way). Without it the identifier mixes in fresh entropy from the host — the browser's +`crypto.getRandomValues` on the web, the operating system's random hash seed on the desktop — so a +fresh copy of the program cannot reproduce it. An encrypted file keeps the first string, because its +key is derived from it. + Dictionaries keep their key order and streams keep their *encoded* bytes, so rewritten objects stay recognisable in a diff and byte-stable when re-serialized. diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index e52aba2ce3..e5ce531f67 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -71,6 +71,9 @@ pub struct Document { encryption_changed: bool, /// Set by edits that must not leave earlier revisions in the file (redaction). full_save: bool, + /// The next full save also gives the file a new identifier (`/ID`), see + /// [`Document::require_full_save_with_new_id`]. + new_id: bool, /// The handler and `/Encrypt` object number that saves use, when encryption changed. out_security: Option>, out_encrypt_num: Option, diff --git a/crates/cos/src/document/open.rs b/crates/cos/src/document/open.rs index 7a4337c8da..3bf6677e99 100644 --- a/crates/cos/src/document/open.rs +++ b/crates/cos/src/document/open.rs @@ -35,6 +35,7 @@ impl Document { unfound: Arc::default(), encryption_changed: false, full_save: false, + new_id: false, out_security: None, out_encrypt_num: None, }; @@ -121,6 +122,7 @@ impl Document { unfound: Arc::default(), encryption_changed: false, full_save: false, + new_id: false, out_security: None, out_encrypt_num: None, }; @@ -290,12 +292,25 @@ impl Document { self.full_save = true; } + /// Like [`Document::require_full_save`], and the rewritten file also gets a new file identifier + /// (`/ID`), so that a redacted or sanitized copy never claims to be its source (ISO 32000-2 + /// §14.4). An encrypted file keeps the first string of its identifier, which its key depends on. + pub fn require_full_save_with_new_id(&mut self) { + self.full_save = true; + self.new_id = true; + } + /// The next save must rewrite the whole file (see [`Document::require_full_save`]). /// Repair-aware edits retaining two accepted generations cannot use one incremental slot. pub fn full_save_required(&self) -> bool { self.full_save || self.overlay.values().any(|slot| matches!(slot, Slot::RepairedSet(..))) } + /// The next full save gives the file a new identifier (see [`Document::require_full_save_with_new_id`]). + pub fn new_id_required(&self) -> bool { + self.new_id + } + /// Security was set or removed since the document was opened (the next save applies it). pub fn encryption_changed(&self) -> bool { self.encryption_changed diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 6c4c71580a..d5be18d96c 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -167,11 +167,17 @@ pub struct SaveOptions { /// before adding it, with [`Document::free_unlinked_additions`]. Full saves always leave /// these out. pub drop_unlinked: bool, + /// Entropy for the file identifier of a rewrite that asks for a new one (see + /// [`Document::require_full_save_with_new_id`]). Given, the identifier depends only on this and + /// on the written bytes, so the same save gives the same file; tests use it to pin output. + /// Without it, fresh entropy from the host (the operating system on native targets, the + /// browser on WebAssembly) is mixed in. + pub id_entropy: Option<[u8; 16]>, } impl Default for SaveOptions { fn default() -> Self { - Self { mod_date: None, id_seed: 0x5052_494E_5443_5241, object_streams: true, drop_unlinked: false } + Self { mod_date: None, id_seed: 0x5052_494E_5443_5241, object_streams: true, drop_unlinked: false, id_entropy: None } } } @@ -383,26 +389,7 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro } } batch.flush(&reader.document, &mut next, &mut rows, &mut out)?; - let mut trailer = Dict::new(); - for key in [&b"Root"[..], b"Info", b"Encrypt"] { - if let Some(r) = trailer_in.reference(key).and_then(|r| map.get(&r)) { - trailer.set(key.to_vec(), Object::Ref(ObjRef::new(*r, 0))); - } - } - if let Some(encrypt @ Object::Dict(_)) = trailer_in.get(b"Encrypt") { - trailer.set(b"Encrypt".to_vec(), renumber(encrypt, &map)); - } - if let Some(id) = trailer_in.get(b"ID") { - trailer.set(b"ID".to_vec(), direct_id(&reader.document, id)); - } - for (key, value) in trailer_in.iter() { - if !trailer_key_rewritten(key) { - trailer.set(key.clone(), renumber(value, &map)); - } - } - if reader.document.output_security().0.is_none() { - ensure_id(&mut trailer, opts, reader.document.bytes()); - } + let mut trailer = full_save_trailer(&reader.document, &trailer_in, &map, opts, &out); if opts.object_streams { write_xref_stream(&mut out, 0, &mut trailer, rows, next); } else { @@ -421,6 +408,34 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro Ok(out) } +/// Assemble the full rewrite's trailer and apply its identifier policy. +fn full_save_trailer(doc: &Document, trailer_in: &Dict, map: &HashMap, opts: &SaveOptions, out: &[u8]) -> Dict { + let mut trailer = Dict::new(); + for key in [&b"Root"[..], b"Info", b"Encrypt"] { + if let Some(r) = trailer_in.reference(key).and_then(|r| map.get(&r)) { + trailer.set(key.to_vec(), Object::Ref(ObjRef::new(*r, 0))); + } + } + if let Some(encrypt @ Object::Dict(_)) = trailer_in.get(b"Encrypt") { + trailer.set(b"Encrypt".to_vec(), renumber(encrypt, map)); + } + if let Some(id) = trailer_in.get(b"ID") { + trailer.set(b"ID".to_vec(), direct_id(doc, id)); + } + for (key, value) in trailer_in.iter() { + if !trailer_key_rewritten(key) { + trailer.set(key.clone(), renumber(value, map)); + } + } + if doc.output_security().0.is_none() { + ensure_id(&mut trailer, opts, doc.bytes()); + } + if doc.new_id_required() { + regenerate_id(&mut trailer, opts, out, doc.output_security().0.is_some()); + } + trailer +} + /// Object `r` read again for writing. The traversal read it once and only kept its number, so /// a second read that fails, finds nothing or finds a different kind of object must fail the /// save rather than write `null` in its place. @@ -669,6 +684,136 @@ fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) { trailer.set(b"ID".to_vec(), Object::Array(vec![s.clone(), s])); } +/// Give a rewritten file a new identifier (§14.4), so that a redacted or sanitized copy does not +/// claim to be its source. An encrypted file keeps the first string, which its key is derived from, +/// and gets a new second one. When there is no such string (no `/ID`, one entry, or a first entry +/// that is not a string) the reader takes the empty string as the key input (`Document::first_id`), +/// so the empty string is written first and the key stays valid. +fn regenerate_id(trailer: &mut Dict, opts: &SaveOptions, written: &[u8], encrypted: bool) { + let fresh = Object::String(PdfString { bytes: new_id_bytes(opts, written), hex: true }); + let pair = if encrypted { + let first = match trailer.get(b"ID") { + Some(Object::Array(a)) => match a.first() { + Some(Object::String(s)) => Object::String(s.clone()), + _ => Object::String(PdfString { bytes: Vec::new(), hex: true }), + }, + _ => Object::String(PdfString { bytes: Vec::new(), hex: true }), + }; + vec![first, fresh] + } else { + vec![fresh.clone(), fresh] + }; + trailer.set(b"ID".to_vec(), Object::Array(pair)); +} + +/// FNV-1a over 64 bits, with its published offset basis and prime. The identifier is built from it +/// rather than from `std`'s `DefaultHasher`, whose algorithm the standard library does not specify. +const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; +const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + +fn fnv1a(state: u64, bytes: &[u8]) -> u64 { + bytes.iter().fold(state, |h, b| (h ^ u64::from(*b)).wrapping_mul(FNV_PRIME)) +} + +/// Sixteen bytes for a file identifier, one 8-byte half per lane. The bytes are a pure function of +/// `SaveOptions::id_entropy`, `id_seed` and the written file: the hash is FNV-1a (above) and every +/// integer is written as a fixed-width big-endian value, so the same input gives the same identifier +/// on every platform and toolchain. Without entropy only the salt changes (see `id_salt`). FNV is +/// not collision-resistant against chosen content; the identifier only has to be unique, not secret. +/// Every written byte is hashed, once, in a single traversal of the output. +fn new_id_bytes(opts: &SaveOptions, written: &[u8]) -> Vec { + let salt = id_salt(opts); + let length = u64::try_from(written.len()).unwrap_or(u64::MAX); + let mut halves = [0u8, 1].map(|lane| { + let mut h = fnv1a(FNV_OFFSET, &salt.to_be_bytes()); + h = fnv1a(h, &[lane]); + h = fnv1a(h, &opts.id_seed.to_be_bytes()); + fnv1a(h, &length.to_be_bytes()) + }); + for chunk in written.chunks(4096) { + for h in &mut halves { + *h = fnv1a(*h, chunk); + } + } + halves.iter().flat_map(|h| h.to_be_bytes()).collect() +} + +/// The salt of an identifier. With caller entropy it is a fixed hash of that entropy (deterministic, +/// for tests and callers who pin output). Without it the salt is fresh: on WebAssembly from the +/// browser ([`browser_salt`], so two fresh web apps that write the same bytes cannot share an +/// identifier), and elsewhere from the standard library's random hash keys — seeded by the operating +/// system, so separate processes differ — mixed with a process-wide counter, which separates +/// successive calls in one process. +fn id_salt(opts: &SaveOptions) -> u64 { + use std::hash::BuildHasher; + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + match &opts.id_entropy { + Some(entropy) => fnv1a(FNV_OFFSET, entropy), + None => { + #[cfg(target_arch = "wasm32")] + if let Some(salt) = browser_salt() { + return salt; + } + std::collections::hash_map::RandomState::new().hash_one(COUNTER.fetch_add(1, Ordering::Relaxed)) + } + } +} + +/// One byte from one `Math.random` draw: the draw (in [0, 1)) scaled to [0, 256), integer part +/// taken. The quotient is in [0, 256), so a saturating cast never wraps; a NaN or infinite draw +/// (which a conforming engine never returns) folds in as 0. No input can panic here. +#[cfg(any(target_arch = "wasm32", test))] +fn draw_byte(random: f64) -> u8 { + (random * 256.0).floor().rem_euclid(256.0) as u8 +} + +/// The `Math.random` fallback's mixing: one salt byte per draw (see [`draw_byte`]), folded through +/// FNV-1a exactly like the Web Crypto bytes, so all sixteen bytes of the fold carry entropy from +/// the draws. Pure and platform-independent, so the native test target can exercise it; on wasm +/// each draw comes from `Math.random`. +#[cfg(any(target_arch = "wasm32", test))] +fn salt_from_draws(draws: &[f64; 16]) -> u64 { + let mut bytes = [0u8; 16]; + for (byte, draw) in bytes.iter_mut().zip(draws) { + *byte = draw_byte(*draw); + } + fnv1a(FNV_OFFSET, &bytes) +} + +/// Fresh salt entropy from the browser, on WebAssembly. `crypto.getRandomValues` (Web Crypto, the +/// browser's secure generator, available in windows and workers alike) gives sixteen bytes, folded +/// into one salt. When that API is missing or refuses the call, `Math.random` is mixed instead +/// ([`salt_from_draws`]: sixteen draws, so the salt's full width carries entropy) — browsers seed +/// it afresh for every document, so a fresh instance still cannot reproduce another's salt. Only +/// where neither is reachable does the caller fall back to its `RandomState` salt, whose keys are +/// fixed at compile time on `wasm32-unknown-unknown` — the state before browser entropy was wired +/// in. +#[cfg(target_arch = "wasm32")] +fn browser_salt() -> Option { + use wasm_bindgen::JsCast; + let crypto = js_sys::Reflect::get(&js_sys::global(), &wasm_bindgen::JsValue::from_str("crypto")) + .ok() + .filter(|crypto| !crypto.is_undefined() && !crypto.is_null()); + if let Some(crypto) = crypto { + let fill = js_sys::Reflect::get(&crypto, &wasm_bindgen::JsValue::from_str("getRandomValues")) + .ok() + .and_then(|fill| fill.dyn_into::().ok()); + if let Some(fill) = fill { + let bytes = js_sys::Uint8Array::new_with_length(16); + if fill.call1(&crypto, &bytes).is_ok() { + return Some(fnv1a(FNV_OFFSET, &bytes.to_vec())); + } + } + } + // Web Crypto is missing or refused the call: degrade to `Math.random` rather than giving up. + let mut draws = [0.0; 16]; + for draw in &mut draws { + *draw = js_sys::Math::random(); + } + Some(salt_from_draws(&draws)) +} + /// A PDF date string for "now" given seconds since the Unix epoch (UTC). pub fn pdf_date(unix_secs: i64) -> String { let (days, secs) = (unix_secs.div_euclid(86_400), unix_secs.rem_euclid(86_400)); @@ -711,6 +856,48 @@ mod tests { use proptest::prelude::*; + #[test] + fn fnv1a_is_the_published_64_bit_algorithm() { + // Published FNV-1a (64 bit) test vectors, so the identifier can be reproduced outside this crate. + assert_eq!(super::fnv1a(super::FNV_OFFSET, b""), 0xcbf2_9ce4_8422_2325); + assert_eq!(super::fnv1a(super::FNV_OFFSET, b"a"), 0xaf63_dc4c_8601_ec8c); + assert_eq!(super::fnv1a(super::FNV_OFFSET, b"foobar"), 0x8594_4171_f739_67e8); + } + + #[test] + fn math_random_draws_fill_the_whole_salt_width() { + // Draw i = (i + 1/2)/256 scales to the integer i, so the fold's byte i proves draw i landed + // there: all sixteen bytes carry their own draw's entropy (the fallback once shifted four + // draws into one word, leaving the other four bytes of the fold constant). + let draws: [f64; 16] = std::array::from_fn(|i| (i as f64 + 0.5) / 256.0); + let bytes: Vec = (0..16).collect(); + assert_eq!(super::salt_from_draws(&draws), super::fnv1a(super::FNV_OFFSET, &bytes)); + } + + #[test] + fn every_math_random_draw_reaches_the_salt() { + let base = [0.5; 16]; + let baseline = super::salt_from_draws(&base); + for i in 0..16 { + let mut draws = base; + draws[i] = 0.75; + assert_ne!(super::salt_from_draws(&draws), baseline, "draw {i} did not reach the salt"); + } + } + + #[test] + fn a_math_random_draw_outside_its_range_still_mixes_without_panicking() { + // A conforming `Math.random` stays in [0, 1), but the mixing must not panic outside it. + assert_eq!(super::draw_byte(0.0), 0); + assert_eq!(super::draw_byte(0.5), 128); + assert_eq!(super::draw_byte(255.5 / 256.0), 255); + assert_eq!(super::draw_byte(-0.5 / 256.0), 255); + assert_eq!(super::draw_byte(-1.0), 0); + assert_eq!(super::draw_byte(2.0), 0); + assert_eq!(super::draw_byte(f64::NAN), 0); + assert_eq!(super::draw_byte(f64::INFINITY), 0); + } + use super::*; use crate::Lexer; diff --git a/crates/cos/tests/file_identifier.rs b/crates/cos/tests/file_identifier.rs new file mode 100644 index 0000000000..4612df54f0 --- /dev/null +++ b/crates/cos/tests/file_identifier.rs @@ -0,0 +1,279 @@ +//! The file identifier (`/ID`, ISO 32000-2 §14.4) of a full save. An ordinary save keeps the +//! identifier the file has and writes the same bytes twice. A rewrite the document asked for (after +//! redaction or sanitizing) gives the file a new pair. An encrypted file keeps its first string, +//! because its file key is derived from it. + +use std::sync::Arc; + +use pdfcraft_cos::{Algorithm, Document, NewEncryption, Object, PdfString, SaveOptions, write_full, write_incremental}; +use pdfcraft_testkit::Pdf; + +const ID_HEX: &str = "/ID [<00112233445566778899AABBCCDDEEFF> <00112233445566778899AABBCCDDEEFF>]"; +const ID_BYTES: [u8; 16] = [0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]; + +/// One page whose content holds a secret, with `entry` added to the trailer (for example an `/ID`). +fn source_with(entry: Option<&str>) -> Vec { + let pdf = Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>") + .stream("", "BT /F1 12 Tf 20 100 Td (TOPSECRET) Tj ET") + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); + match entry { + Some(entry) => pdf.trailer_entry(entry).build(), + None => pdf.build(), + } +} + +/// [`source_with`] with the standard identifier, or none. +fn source(with_id: bool) -> Vec { + source_with(with_id.then_some(ID_HEX)) +} + +/// A one-page file whose 70,000-byte content stream ends in `last`. Two of these built with +/// different last bytes have the same length and differ only past the first 64 KiB. +fn large_source(last: u8) -> Vec { + let mut content = vec![b' '; 70_000]; + content.push(last); + Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>") + .stream("", content) + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + .build() +} + +fn reopen(bytes: Vec) -> Document { + Document::open(Arc::new(bytes)).unwrap() +} + +/// The strings of the trailer's `/ID` (empty when there is none). +fn id_strings(doc: &Document) -> Vec> { + match doc.trailer().get(b"ID") { + Some(Object::Array(a)) => a.iter().map(|s| s.as_string().map(|s| s.bytes.clone()).unwrap_or_default()).collect(), + _ => Vec::new(), + } +} + +#[test] +fn an_ordinary_save_keeps_the_identifier_and_writes_the_same_bytes() { + let doc = reopen(source(true)); + let opts = SaveOptions::default(); + let saved = write_full(&doc, &opts).unwrap(); + assert_eq!(saved, write_full(&doc, &opts).unwrap(), "unchanged inputs produce identical bytes"); + assert_eq!(id_strings(&reopen(saved)), vec![ID_BYTES.to_vec(), ID_BYTES.to_vec()]); + let appended = reopen(write_incremental(&doc, &opts).unwrap()); + assert_eq!(id_strings(&appended), vec![ID_BYTES.to_vec(), ID_BYTES.to_vec()]); +} + +#[test] +fn an_ordinary_save_of_a_file_without_an_identifier_creates_the_same_one_every_time() { + let doc = reopen(source(false)); + let opts = SaveOptions::default(); + let saved = write_full(&doc, &opts).unwrap(); + assert_eq!(saved, write_full(&doc, &opts).unwrap(), "unchanged inputs produce identical bytes"); + let ids = id_strings(&reopen(saved)); + assert_eq!(ids.len(), 2); + assert!(ids.iter().all(|s| s.len() == 16), "{ids:?}"); +} + +#[test] +fn a_rewrite_the_document_asked_for_gets_a_new_identifier() { + let mut doc = reopen(source(true)); + doc.require_full_save_with_new_id(); + assert!(doc.full_save_required() && doc.new_id_required()); + let opts = SaveOptions::default(); + for saved in [write_incremental(&doc, &opts).unwrap(), write_full(&doc, &opts).unwrap()] { + let ids = id_strings(&reopen(saved)); + assert_eq!(ids.len(), 2); + for id in &ids { + assert_eq!(id.len(), 16); + assert_ne!(id.as_slice(), ID_BYTES.as_slice(), "the copy must not claim the source's identifier"); + } + } + // Every save is a new identity, not the same one again. + let first = id_strings(&reopen(write_full(&doc, &opts).unwrap())); + let second = id_strings(&reopen(write_full(&doc, &opts).unwrap())); + assert_ne!(first, second); +} + +#[test] +fn a_rewrite_without_an_identifier_still_gets_one() { + let mut doc = reopen(source(false)); + doc.require_full_save_with_new_id(); + let ids = id_strings(&reopen(write_full(&doc, &SaveOptions::default()).unwrap())); + assert_eq!(ids.len(), 2); + assert!(ids.iter().all(|s| s.len() == 16), "{ids:?}"); +} + +#[test] +fn caller_entropy_makes_the_new_identifier_reproducible() { + let mut doc = reopen(source(true)); + doc.require_full_save_with_new_id(); + let with = |entropy: u8| SaveOptions { id_entropy: Some([entropy; 16]), ..SaveOptions::default() }; + let first = write_full(&doc, &with(1)).unwrap(); + assert_eq!(first, write_full(&doc, &with(1)).unwrap(), "the same entropy gives the same bytes"); + let other = write_full(&doc, &with(2)).unwrap(); + assert_ne!(id_strings(&reopen(first)), id_strings(&reopen(other)), "different entropy gives a different identifier"); +} + +#[test] +fn an_encrypted_rewrite_keeps_the_first_identifier_string_its_key_depends_on() { + for algorithm in [Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256] { + let mut doc = reopen(source(true)); + doc.set_encryption(&NewEncryption { + algorithm, + user_password: "user", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [5; 32], + }) + .unwrap(); + let protected = write_full(&doc, &SaveOptions::default()).unwrap(); + let before = Document::open_with_password(Arc::new(protected), Some("user")).unwrap(); + + let mut doc = before.clone(); + doc.require_full_save_with_new_id(); + let out = write_incremental(&doc, &SaveOptions::default()).unwrap(); + // The output opens with the same password only if its first identifier string is unchanged. + let back = Document::open_with_password(Arc::new(out), Some("user")).unwrap(); + let (old, new) = (id_strings(&before), id_strings(&back)); + assert_eq!(new.len(), 2, "{algorithm:?}"); + assert_eq!(new.first(), old.first(), "{algorithm:?}: the key depends on the first string"); + assert_ne!(new.get(1), old.get(1), "{algorithm:?}: the second string is new"); + } +} + +/// The `/ID` shapes whose key the reader takes as the empty string: none at all, one entry, and a +/// first entry that is not a string. +fn unusable_ids() -> Vec<(&'static str, Option)> { + let empty = Object::String(PdfString::literal(Vec::new())); + vec![ + ("absent", None), + ("one entry", Some(Object::Array(vec![empty.clone()]))), + ("first entry not a string", Some(Object::Array(vec![Object::Int(7), empty]))), + ] +} + +/// An encrypted file whose key is derived from the empty string, with its trailer `/ID` replaced by `id`. +fn protected_with_id(algorithm: Algorithm, id: Option) -> Vec { + let mut doc = reopen(source_with(Some("/ID [<> <>]"))); + doc.set_encryption(&NewEncryption { + algorithm, + user_password: "user", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [5; 32], + }) + .unwrap(); + match id { + Some(id) => doc.trailer_mut().set(b"ID".to_vec(), id), + None => { + doc.trailer_mut().remove(b"ID"); + } + } + write_full(&doc, &SaveOptions::default()).unwrap() +} + +#[test] +fn an_encrypted_rewrite_without_a_usable_identifier_still_gets_a_new_one() { + // The key is derived from the first string of /ID, and a missing /ID, a one-entry /ID and a + // first entry that is not a string all count as the empty string. The rewrite keeps that + // (empty) first string, so the file still opens with its password, and writes a new second one. + for algorithm in [Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256] { + for (shape, id) in unusable_ids() { + let before = Document::open_with_password(Arc::new(protected_with_id(algorithm, id)), Some("user")).unwrap(); + let mut doc = before.clone(); + doc.require_full_save_with_new_id(); + let out = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let back = Document::open_with_password(Arc::new(out), Some("user")).unwrap(); + let ids = id_strings(&back); + let previous = id_strings(&before); + assert_eq!(ids.len(), 2, "{algorithm:?}, {shape}: the rewrite writes a pair"); + assert!(ids.first().is_some_and(|s| s.is_empty()), "{algorithm:?}, {shape}: the first string is the one the key uses"); + assert_eq!(ids.get(1).map(Vec::len), Some(16), "{algorithm:?}, {shape}"); + assert_ne!(ids.get(1), previous.get(1), "{algorithm:?}, {shape}: the second string is new"); + } + } +} + +/// The identifier of [`fixed_entropy_rewrite`]. It was computed once with the FNV-1a formula in the +/// writer; it must not change with the platform or the toolchain. +const GOLDEN_FIXED_ENTROPY_ID: [u8; 16] = [0x32, 0x7d, 0x91, 0x46, 0xb8, 0x6d, 0xb3, 0xe1, 0x97, 0xc3, 0x5e, 0xdc, 0x58, 0x48, 0x3f, 0xba]; + +/// The standard fixture rewritten for a new identifier, with `id_entropy` of 0x42. +fn fixed_entropy_rewrite() -> Vec { + let mut doc = reopen(source(true)); + doc.require_full_save_with_new_id(); + write_full(&doc, &SaveOptions { id_entropy: Some([0x42; 16]), ..SaveOptions::default() }).unwrap() +} + +#[test] +fn a_fixed_entropy_gives_the_same_identifier_on_every_platform() { + // The identifier is FNV-1a over fixed-width big-endian fields and the written file, so the same + // entropy and bytes give the same 16 bytes on every platform and toolchain. The value also pins + // this writer's output for the fixture. + let golden = GOLDEN_FIXED_ENTROPY_ID.to_vec(); + assert_eq!(id_strings(&reopen(fixed_entropy_rewrite())), vec![golden.clone(), golden]); +} + +#[test] +fn the_identifier_depends_on_the_whole_rewritten_file() { + // Same caller entropy and same length; the two rewrites differ only in one byte of a stream + // that lies past the first 64 KiB of the output. Their identifiers must still differ. + let opts = SaveOptions { id_entropy: Some([9; 16]), ..SaveOptions::default() }; + let rewrite = |last: u8| { + let mut doc = reopen(large_source(last)); + doc.require_full_save_with_new_id(); + write_full(&doc, &opts).unwrap() + }; + let (a, b) = (rewrite(b'A'), rewrite(b'B')); + assert_eq!(a.len(), b.len(), "the two rewrites have the same length"); + let first_difference = a.iter().zip(&b).position(|(x, y)| x != y).unwrap_or(usize::MAX); + assert!(first_difference > 64 * 1024, "the rewrites first differ at byte {first_difference}, inside the first 64 KiB"); + assert_ne!(id_strings(&reopen(a)), id_strings(&reopen(b)), "content past 64 KiB must change the identifier"); +} + +/// Where [`stand_in_for_a_fresh_process`] writes its rewrite. +const ID_PROBE: &str = "PDFCRAFT_TEST_ID_PROBE"; + +/// The child half of [`separate_fresh_processes_get_different_identifiers`]: it runs alone, in its +/// own process, and writes a default-path rewrite of the standard fixture to the file the parent +/// named. Without the variable it is a plain no-op. +#[test] +fn stand_in_for_a_fresh_process() { + let Some(path) = std::env::var_os(ID_PROBE) else { return }; + let mut doc = reopen(source(true)); + doc.require_full_save_with_new_id(); + std::fs::write(path, write_full(&doc, &SaveOptions::default()).unwrap()).unwrap(); +} + +#[test] +fn separate_fresh_processes_get_different_identifiers() { + // The default identifier is meant to differ between fresh instances of the whole save pipeline, + // not only between two calls in one process (which the counter already separates). On the native + // test target a fresh instance is a fresh process, whose hash keys the operating system seeds; + // the fix under test runs on WebAssembly, where no browser test harness exists — there the + // browser's `crypto.getRandomValues` stands in for this seeding, and this test cannot see it. + let exe = std::env::current_exe().unwrap(); + let probe = |name: &str| { + let path = std::env::temp_dir().join(format!("pdfcraft-id-probe-{}-{name}.pdf", std::process::id())); + let out = std::process::Command::new(&exe) + .args(["--exact", "stand_in_for_a_fresh_process", "--nocapture", "--"]) + .env(ID_PROBE, &path) + .output() + .expect("run this test's own binary as a child"); + assert!(out.status.success(), "{name}: the child failed: {}", String::from_utf8_lossy(&out.stderr)); + let saved = std::fs::read(&path).unwrap_or_else(|e| panic!("{name}: the child wrote its file: {e}")); + let ids = id_strings(&reopen(saved)); + assert_eq!(ids.len(), 2, "{name}"); + assert!(ids.iter().all(|s| s.len() == 16), "{name}: {ids:?}"); + let _ = std::fs::remove_file(&path); + ids + }; + let (first, second) = (probe("a"), probe("b")); + assert_ne!(first, second, "two fresh processes must not share an identifier"); +} diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 41ac47357e..b6a8885c03 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -554,8 +554,9 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co if residue > 0 { return Err(RedactError::Residue(residue)); } - // The previous revision still holds the removed content: the next save must rewrite. - doc.require_full_save(); + // The previous revision still holds the removed content: the next save must rewrite, and the + // rewritten file is a different file from its source, so it gets a new identifier. + doc.require_full_save_with_new_id(); Ok(report) } diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 6fd4354300..f8088e0bba 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -674,16 +674,21 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result