mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-06 18:52:36 +00:00
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 <FredApps@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
This commit is contained in:
10 files changed
+614
-26
No files matched your search
Generated
+2
@@ -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]]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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<Arc<pdfcraft_crypt::SecurityHandler>>,
|
||||
out_encrypt_num: Option<u32>,
|
||||
|
||||
@@ -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
|
||||
|
||||
+208
-21
@@ -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<Vec<u8>, 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<Vec<u8>, 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<ObjRef, u32>, 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<u8> {
|
||||
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<u64> {
|
||||
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::<js_sys::Function>().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<u8> = (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;
|
||||
|
||||
|
||||
@@ -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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8>) -> 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<Vec<u8>> {
|
||||
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<Object>)> {
|
||||
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<Object>) -> Vec<u8> {
|
||||
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<u8> {
|
||||
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");
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -674,16 +674,21 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
|
||||
if done.is_empty() {
|
||||
return Err(RedactError::NothingToApply);
|
||||
}
|
||||
doc.require_full_save();
|
||||
// The rewrite drops the revisions that held the removed content, so it is a different file from
|
||||
// its source: it gets a new identifier, whoever asked for the removal (the remove-hidden edit
|
||||
// and the optimizer's discard option call this function directly, not through [`sanitize`]).
|
||||
doc.require_full_save_with_new_id();
|
||||
Ok(done)
|
||||
}
|
||||
|
||||
/// Sanitize Document: remove every category of hidden information.
|
||||
pub fn sanitize(doc: &mut Document) -> Result<Vec<(Hidden, usize)>, RedactError> {
|
||||
match remove_hidden(doc, &HIDDEN) {
|
||||
// Nothing hidden is fine for Sanitize: the full rewrite still drops old revisions.
|
||||
// Nothing hidden is fine for Sanitize: the full rewrite still drops old revisions. The
|
||||
// rewrite is a new file either way, so it asks for a new identifier itself (when something
|
||||
// was removed, `remove_hidden` has already asked).
|
||||
Err(RedactError::NothingToApply) => {
|
||||
doc.require_full_save();
|
||||
doc.require_full_save_with_new_id();
|
||||
Ok(Vec::new())
|
||||
}
|
||||
r => r,
|
||||
|
||||
@@ -2272,6 +2272,87 @@ fn inline_outline_children_count_towards_the_total_budget() {
|
||||
assert_eq!(scan(&doc).iter().find(|c| c.0 == Hidden::Bookmarks), Some(&(Hidden::Bookmarks, 100_000)));
|
||||
}
|
||||
|
||||
/// A redacted copy and a sanitized copy are different files from their source: neither may keep
|
||||
/// the source's file identifier (ISO 32000-2 §14.4). A save without the rewrite keeps it.
|
||||
#[test]
|
||||
fn a_redacted_or_sanitized_copy_gets_a_new_identifier() {
|
||||
fn with_id(mut doc: Document) -> Document {
|
||||
let id = Object::Array(vec![
|
||||
Object::String(pdfcraft_cos::PdfString::literal(vec![0x11; 16])),
|
||||
Object::String(pdfcraft_cos::PdfString::literal(vec![0x22; 16])),
|
||||
]);
|
||||
doc.trailer_mut().set(b"ID".to_vec(), id);
|
||||
doc
|
||||
}
|
||||
fn saved_ids(doc: &Document) -> Vec<Vec<u8>> {
|
||||
let saved = Document::open(Arc::new(write_full(doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
match saved.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(),
|
||||
}
|
||||
}
|
||||
let source = vec![vec![0x11; 16], vec![0x22; 16]];
|
||||
let assert_new = |ids: &[Vec<u8>], what: &str| {
|
||||
assert_eq!(ids.len(), 2, "{what}");
|
||||
for (new, old) in ids.iter().zip(&source) {
|
||||
assert_eq!(new.len(), 16, "{what}");
|
||||
assert_ne!(new, old, "{what}: the copy keeps the source's identifier");
|
||||
}
|
||||
};
|
||||
|
||||
// Redaction: the same save keeps the identifier until the redaction asks for a new one.
|
||||
let mut doc = with_id(one_page(b"BT /F1 10 Tf 10 100 Td (Secret) Tj ET", "", vec![]));
|
||||
mark(&mut doc, 0, &[[0.0, 95.0, 300.0, 110.0]], "");
|
||||
assert_eq!(saved_ids(&doc), source, "before the redaction");
|
||||
apply(&mut doc, None).unwrap();
|
||||
assert!(doc.new_id_required());
|
||||
assert_new(&saved_ids(&doc), "redaction");
|
||||
|
||||
// Sanitizing with hidden information to remove.
|
||||
let mut doc = with_id(hidden_fixture());
|
||||
crate::sanitize::sanitize(&mut doc).unwrap();
|
||||
assert!(doc.new_id_required());
|
||||
assert_new(&saved_ids(&doc), "sanitizing");
|
||||
|
||||
// Sanitizing a file with nothing hidden still rewrites it, so it gets a new identifier too.
|
||||
let mut doc = with_id(one_page(b"BT ET", "", vec![]));
|
||||
assert!(crate::sanitize::sanitize(&mut doc).unwrap().is_empty());
|
||||
assert_new(&saved_ids(&doc), "sanitizing without hidden information");
|
||||
}
|
||||
|
||||
/// A copy that lost hidden information through `remove_hidden` itself is a different file from its
|
||||
/// source, so it does not keep the source's identifier either: the remove-hidden edit and the
|
||||
/// optimizer's discard option call `remove_hidden` directly, not through `sanitize`.
|
||||
#[test]
|
||||
fn a_remove_hidden_copy_gets_a_new_identifier() {
|
||||
use crate::sanitize::{Hidden, remove_hidden};
|
||||
let with_id = |mut doc: Document| {
|
||||
doc.trailer_mut().set(
|
||||
b"ID".to_vec(),
|
||||
Object::Array(vec![
|
||||
Object::String(pdfcraft_cos::PdfString::literal(vec![0x11; 16])),
|
||||
Object::String(pdfcraft_cos::PdfString::literal(vec![0x22; 16])),
|
||||
]),
|
||||
);
|
||||
doc
|
||||
};
|
||||
let saved_ids = |doc: &Document| {
|
||||
let saved = Document::open(Arc::new(write_full(doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
match saved.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<_>>(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
|
||||
let mut doc = with_id(hidden_fixture());
|
||||
assert_eq!(remove_hidden(&mut doc, &[Hidden::Metadata]).unwrap().len(), 1, "the fixture has metadata to remove");
|
||||
assert!(doc.new_id_required());
|
||||
let ids = saved_ids(&doc);
|
||||
assert_eq!(ids.len(), 2);
|
||||
assert_ne!(ids[0], vec![0x11; 16], "the copy keeps the source's first identifier string");
|
||||
assert_ne!(ids[1], vec![0x22; 16], "the copy keeps the source's second identifier string");
|
||||
}
|
||||
|
||||
/// Issue #2447: in a tagged PDF the structure tree points at the link and the comment it tags
|
||||
/// (`/OBJR`). Removing them must drop those references too, or the full rewrite keeps the
|
||||
/// annotations, the URL and the comment text included, while the page no longer lists them.
|
||||
|
||||
Reference in new issue
Block a user