1use std::hash::{BuildHasher, RandomState};
46
47use pdfrum_object::{Array, Dict, Object, PdfString, names};
48
49const ID_LEN: usize = 16;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54pub enum IdSource {
55 #[default]
57 Random,
58 Fixed([u8; ID_LEN]),
61}
62
63impl IdSource {
64 fn bytes(self, nonce: u64) -> [u8; ID_LEN] {
68 match self {
69 Self::Fixed(seed) => mix(&seed, nonce),
70 Self::Random => {
73 let a = RandomState::new().hash_one(nonce);
74 let b = RandomState::new().hash_one(nonce.wrapping_add(0x9E37_79B9));
75 let mut out = [0u8; ID_LEN];
76 for (slot, byte) in out
77 .iter_mut()
78 .zip(a.to_le_bytes().into_iter().chain(b.to_le_bytes()))
79 {
80 *slot = byte;
81 }
82 out
83 }
84 }
85 }
86
87 #[must_use]
91 pub fn tag_byte(self, index: u64) -> u8 {
92 match self {
93 Self::Fixed(seed) => mix(&seed, TAG_NONCE ^ index).first().copied().unwrap_or(0),
94 Self::Random => {
95 #[expect(
96 clippy::cast_possible_truncation,
97 reason = "any byte of the hash is as good as any other"
98 )]
99 {
100 RandomState::new().hash_one(index) as u8
101 }
102 }
103 }
104 }
105}
106
107const TAG_NONCE: u64 = 0x5375_6273_6574_0000;
110
111fn mix(seed: &[u8; ID_LEN], nonce: u64) -> [u8; ID_LEN] {
121 let mut state = nonce ^ 0x243F_6A88_85A3_08D3;
123 for byte in seed {
124 state = state
125 .wrapping_mul(0x5851_F42D_4C95_7F2D)
126 .wrapping_add(u64::from(*byte).wrapping_add(1));
127 }
128
129 let mut out = [0u8; ID_LEN];
132 for slot in &mut out {
133 state = state
134 .wrapping_mul(0x5851_F42D_4C95_7F2D)
135 .wrapping_add(0x1405_7B7E_F767_814F);
136 *slot = u8::try_from(state >> 56).unwrap_or(0);
137 }
138 out
139}
140
141#[derive(Debug, Clone, PartialEq)]
144pub struct FileId {
145 pub array: Array,
147 pub rekeyed: bool,
151}
152
153#[derive(Debug, Clone, Copy)]
155pub(crate) struct IdContext<'a> {
156 pub(crate) old: Option<&'a Array>,
158 pub(crate) encrypt: Option<&'a Dict>,
160 pub(crate) incremental: bool,
162}
163
164pub(crate) fn build(ctx: IdContext<'_>, source: IdSource) -> FileId {
166 let fresh = |nonce: u64| Object::Str(PdfString::hex(source.bytes(nonce)));
167
168 let Some(old) = ctx.old else {
169 let first = fresh(0);
172 return FileId {
173 array: Array::of([first.clone(), first]),
174 rekeyed: needs_rekey(ctx.encrypt),
175 };
176 };
177
178 let first = old
180 .raw_at(0)
181 .filter(|o| o.as_string().is_some())
182 .cloned()
183 .unwrap_or_else(|| fresh(0));
184
185 let second = old.raw_at(1).filter(|o| o.as_string().is_some());
186 if ctx.incremental
189 && ctx.encrypt.is_some()
190 && let Some(second) = second
191 {
192 return FileId {
193 array: Array::of([first, second.clone()]),
194 rekeyed: false,
195 };
196 }
197
198 FileId {
199 array: Array::of([first, fresh(1)]),
200 rekeyed: false,
201 }
202}
203
204fn needs_rekey(encrypt: Option<&Dict>) -> bool {
210 let Some(dict) = encrypt else {
211 return false;
212 };
213 let revision = dict.direct_int(names::R).unwrap_or(0);
214 (revision == 2 || revision == 3) && dict.name(names::FILTER) == Some(names::STANDARD)
215}
216
217#[cfg(test)]
218mod tests {
219 use super::{FileId, IdContext, IdSource, build};
220 use pdfrum_object::{Array, Dict, Object, PdfString, names};
221
222 fn hex(s: &str) -> Object {
223 Object::Str(PdfString::hex(s.as_bytes()))
224 }
225
226 fn ctx<'a>(
227 old: Option<&'a Array>,
228 encrypt: Option<&'a Dict>,
229 incremental: bool,
230 ) -> IdContext<'a> {
231 IdContext {
232 old,
233 encrypt,
234 incremental,
235 }
236 }
237
238 fn seed() -> IdSource {
239 IdSource::Fixed([7u8; 16])
240 }
241
242 fn elements(id: &FileId) -> (Vec<u8>, Vec<u8>) {
243 let get = |i: usize| {
244 id.array
245 .string_at(i)
246 .map(|s| s.bytes.to_vec())
247 .unwrap_or_default()
248 };
249 (get(0), get(1))
250 }
251
252 #[test]
254 fn the_first_element_is_preserved_when_the_file_had_one() {
255 let old = Array::of([hex("keepme"), hex("changeme")]);
256 let id = build(ctx(Some(&old), None, false), seed());
257 let (first, second) = elements(&id);
258 assert_eq!(first, b"keepme");
259 assert_ne!(second, b"changeme", "the second element is regenerated");
260 assert_eq!(second.len(), 16);
261 assert!(!id.rekeyed);
262 }
263
264 #[test]
265 fn a_document_with_no_id_gets_two_identical_elements() {
266 let id = build(ctx(None, None, false), seed());
267 let (first, second) = elements(&id);
268 assert_eq!(first, second);
269 assert_eq!(first.len(), 16);
270 assert!(!id.rekeyed);
271 }
272
273 #[test]
276 fn an_incremental_encrypted_save_keeps_the_second_element() {
277 let old = Array::of([hex("keepme"), hex("alsokeep")]);
278 let encrypt = Dict::from_pairs([(names::R.clone(), Object::Int(4))]);
279 let id = build(ctx(Some(&old), Some(&encrypt), true), seed());
280 let (first, second) = elements(&id);
281 assert_eq!(first, b"keepme");
282 assert_eq!(second, b"alsokeep");
283 }
284
285 #[test]
286 fn a_full_encrypted_save_still_regenerates_the_second_element() {
287 let old = Array::of([hex("keepme"), hex("changeme")]);
288 let encrypt = Dict::from_pairs([(names::R.clone(), Object::Int(4))]);
289 let id = build(ctx(Some(&old), Some(&encrypt), false), seed());
290 assert_ne!(elements(&id).1, b"changeme");
291 }
292
293 #[test]
295 fn no_id_plus_revision_three_forces_a_rekey() {
296 for revision in [2i64, 3] {
297 let encrypt = Dict::from_pairs([
298 (names::R.clone(), Object::Int(revision)),
299 (names::FILTER.clone(), Object::Name(names::STANDARD.clone())),
300 ]);
301 let id = build(ctx(None, Some(&encrypt), false), seed());
302 assert!(id.rekeyed, "revision {revision} derives its key from /ID");
303 }
304 }
305
306 #[test]
307 fn revision_four_and_up_survive_a_fresh_id() {
308 for revision in [4i64, 5, 6] {
309 let encrypt = Dict::from_pairs([
310 (names::R.clone(), Object::Int(revision)),
311 (names::FILTER.clone(), Object::Name(names::STANDARD.clone())),
312 ]);
313 assert!(!build(ctx(None, Some(&encrypt), false), seed()).rekeyed);
314 }
315 }
316
317 #[test]
319 fn a_non_standard_handler_is_never_rekeyed() {
320 let encrypt = Dict::from_pairs([
321 (names::R.clone(), Object::Int(2)),
322 (
323 names::FILTER.clone(),
324 Object::Name(pdfrum_object::Name::from("Custom")),
325 ),
326 ]);
327 assert!(!build(ctx(None, Some(&encrypt), false), seed()).rekeyed);
328 }
329
330 #[test]
332 fn a_fixed_source_produces_the_same_id_every_time() {
333 let a = build(ctx(None, None, false), seed());
334 let b = build(ctx(None, None, false), seed());
335 assert_eq!(a, b);
336 }
337
338 #[test]
339 fn different_seeds_produce_different_ids() {
340 let a = build(ctx(None, None, false), IdSource::Fixed([1u8; 16]));
341 let b = build(ctx(None, None, false), IdSource::Fixed([2u8; 16]));
342 assert_ne!(a, b);
343 }
344
345 #[test]
348 fn the_two_elements_of_one_array_differ() {
349 let old = Array::of([hex("keepme")]);
350 let id = build(ctx(Some(&old), None, false), seed());
351 let (first, second) = elements(&id);
352 assert_ne!(first, second);
353 }
354
355 #[test]
357 fn a_random_source_differs_between_saves() {
358 let a = build(ctx(None, None, false), IdSource::Random);
359 let b = build(ctx(None, None, false), IdSource::Random);
360 assert_ne!(a, b);
361 }
362
363 #[test]
366 fn elements_are_sixteen_bytes_spelled_as_hex() {
367 let id = build(ctx(None, None, false), seed());
368 for i in 0..2 {
369 let s = id.array.string_at(i).expect("a string");
370 assert!(s.hex, "the trailer spells /ID in hex");
371 assert_eq!(s.bytes.len(), 16);
372 }
373 }
374
375 #[test]
377 fn a_junk_first_element_is_replaced() {
378 let old = Array::of([Object::Int(5), hex("second")]);
379 let id = build(ctx(Some(&old), None, false), seed());
380 assert_eq!(elements(&id).0.len(), 16);
381 }
382
383 #[test]
384 fn tag_bytes_are_stable_under_a_fixed_seed() {
385 let s = seed();
386 let first: Vec<u8> = (0..6).map(|i| s.tag_byte(i)).collect();
387 let again: Vec<u8> = (0..6).map(|i| s.tag_byte(i)).collect();
388 assert_eq!(first, again);
389 }
390
391 #[test]
396 fn every_seed_byte_reaches_every_output_byte() {
397 let base = [0u8; 16];
398 let tag_of = |s: IdSource| -> Vec<u8> { (0..6).map(|i| s.tag_byte(i)).collect() };
399 let reference = tag_of(IdSource::Fixed(base));
400
401 for position in 0..16 {
402 let mut altered = base;
403 if let Some(slot) = altered.get_mut(position) {
404 *slot = 0xFF;
405 }
406 assert_ne!(
407 tag_of(IdSource::Fixed(altered)),
408 reference,
409 "changing seed byte {position} must change the tag"
410 );
411 }
412 }
413
414 #[test]
416 fn every_seed_byte_reaches_the_id() {
417 let base = [0u8; 16];
418 let reference = build(ctx(None, None, false), IdSource::Fixed(base));
419 for position in 0..16 {
420 let mut altered = base;
421 if let Some(slot) = altered.get_mut(position) {
422 *slot = 0xFF;
423 }
424 assert_ne!(
425 build(ctx(None, None, false), IdSource::Fixed(altered)),
426 reference,
427 "changing seed byte {position} must change /ID"
428 );
429 }
430 }
431}