use std::hash::{BuildHasher, RandomState};
use pdfrum_object::{Array, Dict, Object, PdfString, names};
const ID_LEN: usize = 16;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum IdSource {
#[default]
Random,
Fixed([u8; ID_LEN]),
}
impl IdSource {
fn bytes(self, nonce: u64) -> [u8; ID_LEN] {
match self {
Self::Fixed(seed) => mix(&seed, nonce),
Self::Random => {
let a = RandomState::new().hash_one(nonce);
let b = RandomState::new().hash_one(nonce.wrapping_add(0x9E37_79B9));
let mut out = [0u8; ID_LEN];
for (slot, byte) in out
.iter_mut()
.zip(a.to_le_bytes().into_iter().chain(b.to_le_bytes()))
{
*slot = byte;
}
out
}
}
}
#[must_use]
pub fn tag_byte(self, index: u64) -> u8 {
match self {
Self::Fixed(seed) => mix(&seed, TAG_NONCE ^ index).first().copied().unwrap_or(0),
Self::Random => {
#[expect(
clippy::cast_possible_truncation,
reason = "any byte of the hash is as good as any other"
)]
{
RandomState::new().hash_one(index) as u8
}
}
}
}
}
const TAG_NONCE: u64 = 0x5375_6273_6574_0000;
fn mix(seed: &[u8; ID_LEN], nonce: u64) -> [u8; ID_LEN] {
let mut state = nonce ^ 0x243F_6A88_85A3_08D3;
for byte in seed {
state = state
.wrapping_mul(0x5851_F42D_4C95_7F2D)
.wrapping_add(u64::from(*byte).wrapping_add(1));
}
let mut out = [0u8; ID_LEN];
for slot in &mut out {
state = state
.wrapping_mul(0x5851_F42D_4C95_7F2D)
.wrapping_add(0x1405_7B7E_F767_814F);
*slot = u8::try_from(state >> 56).unwrap_or(0);
}
out
}
#[derive(Debug, Clone, PartialEq)]
pub struct FileId {
pub array: Array,
pub rekeyed: bool,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct IdContext<'a> {
pub(crate) old: Option<&'a Array>,
pub(crate) encrypt: Option<&'a Dict>,
pub(crate) incremental: bool,
}
pub(crate) fn build(ctx: IdContext<'_>, source: IdSource) -> FileId {
let fresh = |nonce: u64| Object::Str(PdfString::hex(source.bytes(nonce)));
let Some(old) = ctx.old else {
let first = fresh(0);
return FileId {
array: Array::of([first.clone(), first]),
rekeyed: needs_rekey(ctx.encrypt),
};
};
let first = old
.raw_at(0)
.filter(|o| o.as_string().is_some())
.cloned()
.unwrap_or_else(|| fresh(0));
let second = old.raw_at(1).filter(|o| o.as_string().is_some());
if ctx.incremental
&& ctx.encrypt.is_some()
&& let Some(second) = second
{
return FileId {
array: Array::of([first, second.clone()]),
rekeyed: false,
};
}
FileId {
array: Array::of([first, fresh(1)]),
rekeyed: false,
}
}
fn needs_rekey(encrypt: Option<&Dict>) -> bool {
let Some(dict) = encrypt else {
return false;
};
let revision = dict.direct_int(names::R).unwrap_or(0);
(revision == 2 || revision == 3) && dict.name(names::FILTER) == Some(names::STANDARD)
}
#[cfg(test)]
mod tests {
use super::{FileId, IdContext, IdSource, build};
use pdfrum_object::{Array, Dict, Object, PdfString, names};
fn hex(s: &str) -> Object {
Object::Str(PdfString::hex(s.as_bytes()))
}
fn ctx<'a>(
old: Option<&'a Array>,
encrypt: Option<&'a Dict>,
incremental: bool,
) -> IdContext<'a> {
IdContext {
old,
encrypt,
incremental,
}
}
fn seed() -> IdSource {
IdSource::Fixed([7u8; 16])
}
fn elements(id: &FileId) -> (Vec<u8>, Vec<u8>) {
let get = |i: usize| {
id.array
.string_at(i)
.map(|s| s.bytes.to_vec())
.unwrap_or_default()
};
(get(0), get(1))
}
#[test]
fn the_first_element_is_preserved_when_the_file_had_one() {
let old = Array::of([hex("keepme"), hex("changeme")]);
let id = build(ctx(Some(&old), None, false), seed());
let (first, second) = elements(&id);
assert_eq!(first, b"keepme");
assert_ne!(second, b"changeme", "the second element is regenerated");
assert_eq!(second.len(), 16);
assert!(!id.rekeyed);
}
#[test]
fn a_document_with_no_id_gets_two_identical_elements() {
let id = build(ctx(None, None, false), seed());
let (first, second) = elements(&id);
assert_eq!(first, second);
assert_eq!(first.len(), 16);
assert!(!id.rekeyed);
}
#[test]
fn an_incremental_encrypted_save_keeps_the_second_element() {
let old = Array::of([hex("keepme"), hex("alsokeep")]);
let encrypt = Dict::from_pairs([(names::R.clone(), Object::Int(4))]);
let id = build(ctx(Some(&old), Some(&encrypt), true), seed());
let (first, second) = elements(&id);
assert_eq!(first, b"keepme");
assert_eq!(second, b"alsokeep");
}
#[test]
fn a_full_encrypted_save_still_regenerates_the_second_element() {
let old = Array::of([hex("keepme"), hex("changeme")]);
let encrypt = Dict::from_pairs([(names::R.clone(), Object::Int(4))]);
let id = build(ctx(Some(&old), Some(&encrypt), false), seed());
assert_ne!(elements(&id).1, b"changeme");
}
#[test]
fn no_id_plus_revision_three_forces_a_rekey() {
for revision in [2i64, 3] {
let encrypt = Dict::from_pairs([
(names::R.clone(), Object::Int(revision)),
(names::FILTER.clone(), Object::Name(names::STANDARD.clone())),
]);
let id = build(ctx(None, Some(&encrypt), false), seed());
assert!(id.rekeyed, "revision {revision} derives its key from /ID");
}
}
#[test]
fn revision_four_and_up_survive_a_fresh_id() {
for revision in [4i64, 5, 6] {
let encrypt = Dict::from_pairs([
(names::R.clone(), Object::Int(revision)),
(names::FILTER.clone(), Object::Name(names::STANDARD.clone())),
]);
assert!(!build(ctx(None, Some(&encrypt), false), seed()).rekeyed);
}
}
#[test]
fn a_non_standard_handler_is_never_rekeyed() {
let encrypt = Dict::from_pairs([
(names::R.clone(), Object::Int(2)),
(
names::FILTER.clone(),
Object::Name(pdfrum_object::Name::from("Custom")),
),
]);
assert!(!build(ctx(None, Some(&encrypt), false), seed()).rekeyed);
}
#[test]
fn a_fixed_source_produces_the_same_id_every_time() {
let a = build(ctx(None, None, false), seed());
let b = build(ctx(None, None, false), seed());
assert_eq!(a, b);
}
#[test]
fn different_seeds_produce_different_ids() {
let a = build(ctx(None, None, false), IdSource::Fixed([1u8; 16]));
let b = build(ctx(None, None, false), IdSource::Fixed([2u8; 16]));
assert_ne!(a, b);
}
#[test]
fn the_two_elements_of_one_array_differ() {
let old = Array::of([hex("keepme")]);
let id = build(ctx(Some(&old), None, false), seed());
let (first, second) = elements(&id);
assert_ne!(first, second);
}
#[test]
fn a_random_source_differs_between_saves() {
let a = build(ctx(None, None, false), IdSource::Random);
let b = build(ctx(None, None, false), IdSource::Random);
assert_ne!(a, b);
}
#[test]
fn elements_are_sixteen_bytes_spelled_as_hex() {
let id = build(ctx(None, None, false), seed());
for i in 0..2 {
let s = id.array.string_at(i).expect("a string");
assert!(s.hex, "the trailer spells /ID in hex");
assert_eq!(s.bytes.len(), 16);
}
}
#[test]
fn a_junk_first_element_is_replaced() {
let old = Array::of([Object::Int(5), hex("second")]);
let id = build(ctx(Some(&old), None, false), seed());
assert_eq!(elements(&id).0.len(), 16);
}
#[test]
fn tag_bytes_are_stable_under_a_fixed_seed() {
let s = seed();
let first: Vec<u8> = (0..6).map(|i| s.tag_byte(i)).collect();
let again: Vec<u8> = (0..6).map(|i| s.tag_byte(i)).collect();
assert_eq!(first, again);
}
#[test]
fn every_seed_byte_reaches_every_output_byte() {
let base = [0u8; 16];
let tag_of = |s: IdSource| -> Vec<u8> { (0..6).map(|i| s.tag_byte(i)).collect() };
let reference = tag_of(IdSource::Fixed(base));
for position in 0..16 {
let mut altered = base;
if let Some(slot) = altered.get_mut(position) {
*slot = 0xFF;
}
assert_ne!(
tag_of(IdSource::Fixed(altered)),
reference,
"changing seed byte {position} must change the tag"
);
}
}
#[test]
fn every_seed_byte_reaches_the_id() {
let base = [0u8; 16];
let reference = build(ctx(None, None, false), IdSource::Fixed(base));
for position in 0..16 {
let mut altered = base;
if let Some(slot) = altered.get_mut(position) {
*slot = 0xFF;
}
assert_ne!(
build(ctx(None, None, false), IdSource::Fixed(altered)),
reference,
"changing seed byte {position} must change /ID"
);
}
}
}