use std::collections::{BTreeMap, HashSet};
use std::path::Path;
use lopdf::{
xref::{Xref, XrefEntry, XrefType},
Dictionary, Document, Object, ObjectId, Reader,
};
use memchr::memmem;
use crate::{
filter::decode_stream_content,
lazy::PdfSource,
xrefboot::{is_delimiter, is_whitespace, parse_version, Lexer},
PdfOpsError, Result,
};
const MAX_OBJSTM_ENTRIES: usize = u16::MAX as usize + 1;
pub(crate) fn is_offset_damage(err: &PdfOpsError) -> bool {
matches!(
err,
PdfOpsError::Pdf(lopdf::Error::ObjectIdMismatch | lopdf::Error::IndirectObject { .. })
)
}
pub(crate) fn with_repair_retry<'a, T>(
buffer: &'a [u8],
path: &Path,
password: Option<&str>,
operation: impl Fn(&PdfSource<'a>) -> Result<T>,
) -> Result<T> {
let source = PdfSource::open(buffer, path, password)?;
match operation(&source) {
Err(err) if is_offset_damage(&err) && !source.repaired() => {
match PdfSource::open_repaired(buffer, path) {
Some(repaired) => operation(&repaired),
None => Err(err),
}
}
result => result,
}
}
pub(crate) fn reconstruct_document(buffer: &[u8]) -> Option<Document> {
let headers = scan_object_headers(buffer);
if headers.is_empty() {
return None;
}
let mut catalogs: Vec<(u32, ObjectId)> = Vec::new();
let mut object_streams: Vec<ObjStmCandidate> = Vec::new();
let mut xref_trailers: Vec<(u32, Dictionary)> = Vec::new();
for (&id, header) in &headers {
let Some((dict, dict_end)) = parse_header_dict(buffer, header.offset) else {
continue;
};
match dict.get(b"Type").ok().and_then(|t| t.as_name().ok()) {
Some(b"Catalog") => catalogs.push((header.offset, (id, header.generation))),
Some(b"ObjStm") => object_streams.push(ObjStmCandidate {
container: id,
dict,
dict_end,
}),
Some(b"XRef") => xref_trailers.push((header.offset, dict)),
_ => {}
}
}
let trailers = recover_trailers(buffer, &xref_trailers);
if trailers.saw_encrypt {
return None;
}
if trailers.best.is_none() && contains_encrypt_reference(buffer) {
return None;
}
let trailer_source = trailers.best;
let mut root_candidates: Vec<ObjectId> = Vec::new();
if let Some(dict) = &trailer_source {
if let Ok(root) = dict.get(b"Root").and_then(Object::as_reference) {
root_candidates.push(match headers.get(&root.0) {
Some(header) if header.generation != root.1 => (root.0, header.generation),
_ => root,
});
}
}
if let Some(&(_, id)) = catalogs.iter().max_by_key(|(offset, _)| *offset) {
if !root_candidates.contains(&id) {
root_candidates.push(id);
}
}
let scan_members_for_catalog = root_candidates.is_empty();
let mut entries: BTreeMap<u32, XrefEntry> = headers
.iter()
.map(|(&id, header)| {
(
id,
XrefEntry::Normal {
offset: header.offset,
generation: header.generation,
},
)
})
.collect();
let mut member_catalogs: Vec<ObjectId> = Vec::new();
for candidate in &object_streams {
let _ = expand_object_stream(
buffer,
&headers,
candidate,
&mut entries,
scan_members_for_catalog.then_some(&mut member_catalogs),
);
}
root_candidates.extend(member_catalogs);
if root_candidates.is_empty() {
return None;
}
let version = recover_version(buffer);
let max_id = *entries.keys().next_back()?;
let mut xref = Xref::new(max_id.checked_add(1)?, XrefType::CrossReferenceTable);
xref.entries = entries;
let mut document = Document::new();
document.version = version;
document.max_id = max_id;
document.reference_table = xref;
for root in root_candidates {
let mut trailer = Dictionary::new();
trailer.set("Size", i64::from(max_id) + 1);
trailer.set("Root", Object::Reference(root));
if let Some(source) = &trailer_source {
for key in [b"Info".as_slice(), b"ID".as_slice()] {
if let Ok(value) = source.get(key) {
trailer.set(key, value.clone());
}
}
}
document.trailer = trailer;
let (returned, is_catalog) = root_resolves_to_catalog(buffer, document, root);
document = returned;
if is_catalog {
return Some(document);
}
}
None
}
struct ScannedHeader {
offset: u32,
generation: u16,
}
fn scan_object_headers(buffer: &[u8]) -> BTreeMap<u32, ScannedHeader> {
let mut headers = BTreeMap::new();
for pos in memmem::find_iter(buffer, b"obj") {
if let Some((id, header)) = parse_header_at(buffer, pos) {
headers.insert(id, header);
}
}
headers
}
fn parse_header_at(buffer: &[u8], keyword_pos: usize) -> Option<(u32, ScannedHeader)> {
if let Some(&byte) = buffer.get(keyword_pos + 3) {
if !is_whitespace(byte) && !is_delimiter(byte) {
return None;
}
}
let gen_end = skip_whitespace_backwards(buffer, keyword_pos)?;
let (gen_start, generation) = parse_digits_backwards(buffer, gen_end)?;
let generation = u16::try_from(generation).ok()?;
let id_end = skip_whitespace_backwards(buffer, gen_start)?;
let (id_start, id) = parse_digits_backwards(buffer, id_end)?;
let id = u32::try_from(id).ok().filter(|id| *id > 0)?;
if id_start > 0 {
let byte = buffer[id_start - 1];
if !is_whitespace(byte) && !is_delimiter(byte) {
return None;
}
}
let mut lexer = Lexer {
buffer,
pos: keyword_pos + 3,
};
lexer.skip_whitespace();
if !matches!(
lexer.peek(),
Some(
b'<' | b'[' | b'/' | b'(' | b'+' | b'-' | b'.' | b'0'
..=b'9' | b't' | b'f' | b'n' | b'e'
)
) {
return None;
}
let offset = u32::try_from(id_start).ok()?;
Some((id, ScannedHeader { offset, generation }))
}
fn skip_whitespace_backwards(buffer: &[u8], end: usize) -> Option<usize> {
let mut pos = end;
while pos > 0 && is_whitespace(buffer[pos - 1]) {
pos -= 1;
}
(pos < end).then_some(pos)
}
fn parse_digits_backwards(buffer: &[u8], end: usize) -> Option<(usize, u64)> {
let mut start = end;
while start > 0 && buffer[start - 1].is_ascii_digit() {
start -= 1;
}
if start == end || end - start > 10 {
return None;
}
let mut value: u64 = 0;
for &byte in &buffer[start..end] {
value = value * 10 + u64::from(byte - b'0');
}
Some((start, value))
}
fn parse_header_dict(buffer: &[u8], offset: u32) -> Option<(Dictionary, usize)> {
let mut lexer = Lexer {
buffer,
pos: offset as usize,
};
lexer.skip_whitespace();
lexer.parse_unsigned::<u32>()?;
lexer.skip_whitespace();
lexer.parse_unsigned::<u16>()?;
lexer.skip_whitespace();
if !lexer.try_keyword(b"obj") {
return None;
}
lexer.skip_whitespace();
if lexer.peek() != Some(b'<') {
return None;
}
match lexer.parse_object(0)? {
Object::Dictionary(dict) => Some((dict, lexer.pos)),
_ => None,
}
}
struct RecoveredTrailers {
best: Option<Dictionary>,
saw_encrypt: bool,
}
fn recover_trailers(buffer: &[u8], xref_trailers: &[(u32, Dictionary)]) -> RecoveredTrailers {
let mut best: Option<Dictionary> = None;
let mut saw_encrypt = false;
for pos in memmem::find_iter(buffer, b"trailer") {
if pos > 0 {
let prev = buffer[pos - 1];
if !is_whitespace(prev) && !is_delimiter(prev) {
continue;
}
}
let mut lexer = Lexer { buffer, pos };
if !lexer.try_keyword(b"trailer") {
continue;
}
let Some(Object::Dictionary(dict)) = lexer.parse_object(0) else {
continue;
};
saw_encrypt |= dict.has(b"Encrypt");
if dict
.get(b"Root")
.map(|root| root.as_reference().is_ok())
.unwrap_or(false)
{
best = Some(dict);
}
}
saw_encrypt |= xref_trailers.iter().any(|(_, dict)| dict.has(b"Encrypt"));
let best = best.or_else(|| {
xref_trailers
.iter()
.filter(|(_, dict)| {
dict.get(b"Root")
.map(|root| root.as_reference().is_ok())
.unwrap_or(false)
})
.max_by_key(|(offset, _)| *offset)
.map(|(_, dict)| dict.clone())
});
RecoveredTrailers { best, saw_encrypt }
}
fn contains_encrypt_reference(buffer: &[u8]) -> bool {
memmem::find_iter(buffer, b"/Encrypt").any(|pos| {
let mut lexer = Lexer {
buffer,
pos: pos + b"/Encrypt".len(),
};
match lexer.peek() {
Some(byte) if is_whitespace(byte) || is_delimiter(byte) => {}
_ => return false,
}
(|| {
lexer.skip_whitespace();
lexer.parse_unsigned::<u32>()?;
lexer.skip_whitespace();
lexer.parse_unsigned::<u16>()?;
lexer.skip_whitespace();
lexer.try_keyword(b"R").then_some(())
})()
.is_some()
})
}
struct ObjStmCandidate {
container: u32,
dict: Dictionary,
dict_end: usize,
}
fn expand_object_stream(
buffer: &[u8],
headers: &BTreeMap<u32, ScannedHeader>,
candidate: &ObjStmCandidate,
entries: &mut BTreeMap<u32, XrefEntry>,
mut member_catalogs: Option<&mut Vec<ObjectId>>,
) -> Option<()> {
let count = usize::try_from(candidate.dict.get(b"N").ok()?.as_i64().ok()?).ok()?;
let content = stream_content(buffer, candidate, headers)?;
let stream = lopdf::Stream::new(candidate.dict.clone(), content.to_vec());
let decoded = decode_stream_content(&stream).unwrap_or_else(|_| content.to_vec());
let first = candidate
.dict
.get(b"First")
.ok()
.and_then(|first| first.as_i64().ok())
.and_then(|first| usize::try_from(first).ok());
let mut lexer = Lexer {
buffer: &decoded,
pos: 0,
};
for index in 0..count.min(MAX_OBJSTM_ENTRIES) {
lexer.skip_whitespace();
let id = lexer.parse_unsigned::<u32>()?;
lexer.skip_whitespace();
let member_offset = lexer.parse_unsigned::<u64>()?;
if id == 0 {
continue;
}
entries.entry(id).or_insert(XrefEntry::Compressed {
container: candidate.container,
index: index as u16,
});
if let (Some(catalogs), Some(first)) = (member_catalogs.as_deref_mut(), first) {
let start = usize::try_from(member_offset)
.ok()
.and_then(|offset| first.checked_add(offset));
if let Some(start) = start.filter(|start| *start < decoded.len()) {
let mut member = Lexer {
buffer: &decoded,
pos: start,
};
if let Some(Object::Dictionary(dict)) = member.parse_object(0) {
if dict.has_type(b"Catalog") {
catalogs.push((id, 0));
}
}
}
}
}
Some(())
}
fn stream_content<'a>(
buffer: &'a [u8],
candidate: &ObjStmCandidate,
headers: &BTreeMap<u32, ScannedHeader>,
) -> Option<&'a [u8]> {
let mut lexer = Lexer {
buffer,
pos: candidate.dict_end,
};
lexer.skip_whitespace();
if !lexer.try_keyword(b"stream") {
return None;
}
lexer.consume_stream_eol()?;
let start = lexer.pos;
let length = resolve_stream_length(&candidate.dict, buffer, headers)?;
let end = start
.checked_add(length)
.filter(|end| *end <= buffer.len())?;
let mut tail = Lexer { buffer, pos: end };
tail.skip_whitespace();
tail.try_keyword(b"endstream").then(|| &buffer[start..end])
}
fn resolve_stream_length(
dict: &Dictionary,
buffer: &[u8],
headers: &BTreeMap<u32, ScannedHeader>,
) -> Option<usize> {
match dict.get(b"Length").ok()? {
Object::Integer(length) => usize::try_from(*length).ok(),
Object::Reference((id, generation)) => {
let header = headers.get(id)?;
if header.generation != *generation {
return None;
}
let mut lexer = Lexer {
buffer,
pos: header.offset as usize,
};
lexer.skip_whitespace();
lexer.parse_unsigned::<u32>()?;
lexer.skip_whitespace();
lexer.parse_unsigned::<u16>()?;
lexer.skip_whitespace();
if !lexer.try_keyword(b"obj") {
return None;
}
match lexer.parse_object(0)? {
Object::Integer(length) => usize::try_from(length).ok(),
_ => None,
}
}
_ => None,
}
}
fn recover_version(buffer: &[u8]) -> String {
let window = &buffer[..buffer.len().min(1024)];
memmem::find(window, b"%PDF-")
.and_then(|pos| parse_version(&buffer[pos..]))
.unwrap_or_else(|| "1.4".to_string())
}
fn root_resolves_to_catalog(buffer: &[u8], document: Document, root: ObjectId) -> (Document, bool) {
let reader = Reader {
buffer,
document,
encryption_state: None,
raw_objects: BTreeMap::new(),
password: None,
strict: false,
};
let is_catalog = reader
.get_object(root, &mut HashSet::new())
.ok()
.and_then(|object| {
object
.as_dict()
.map(|dict| dict.has_type(b"Catalog") || dict.has(b"Pages"))
.ok()
})
.unwrap_or(false);
(reader.document, is_catalog)
}
#[cfg(test)]
mod tests {
use super::*;
fn scanned_ids(buffer: &[u8]) -> Vec<u32> {
scan_object_headers(buffer).into_keys().collect()
}
#[test]
fn header_scan_finds_headers_across_whitespace_styles() {
let headers = scan_object_headers(
b"%PDF-1.4\n1 0 obj\n<< >>\nendobj\r\n2 0 obj<< >>endobj\n3\t0\tobj\n[1 2]\nendobj\n",
);
assert_eq!(headers.keys().copied().collect::<Vec<_>>(), vec![1, 2, 3]);
assert_eq!(headers[&1].offset, 9);
assert_eq!(headers[&1].generation, 0);
}
#[test]
fn header_scan_rejects_lookalikes() {
assert!(scanned_ids(b"/F12 0 obj << >>").is_empty());
assert!(scanned_ids(b"(see 1 0 obj)").is_empty());
assert!(scanned_ids(b" 1 99999999 obj << >>").is_empty());
assert!(scanned_ids(b" 0 0 obj << >>").is_empty());
assert!(scanned_ids(b" 10obj << >>").is_empty());
}
#[test]
fn header_scan_later_duplicate_wins() {
let buffer = b" 5 0 obj\n<< /A 1 >>\nendobj\n 5 0 obj\n<< /A 2 >>\nendobj\n";
let headers = scan_object_headers(buffer);
assert_eq!(headers.len(), 1);
assert_eq!(headers[&5].offset, 28);
}
fn damaged_pdf(prefix: &[u8], tail: &[u8]) -> Vec<u8> {
let mut pdf = prefix.to_vec();
pdf.extend_from_slice(b"%PDF-1.4\n");
pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
pdf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
pdf.extend_from_slice(
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] >>\nendobj\n",
);
pdf.extend_from_slice(tail);
pdf
}
#[test]
fn reconstructs_without_any_trailer() {
let pdf = damaged_pdf(b"", b"xref\ngarbage that is not a table\n%%EOF\n");
let document = reconstruct_document(&pdf).expect("catalog fallback should recover");
assert_eq!(
document
.trailer
.get(b"Root")
.unwrap()
.as_reference()
.unwrap(),
(1, 0)
);
assert_eq!(document.trailer.get(b"Size").unwrap().as_i64().unwrap(), 4);
for id in 1..=3u32 {
assert!(
matches!(
document.reference_table.get(id),
Some(XrefEntry::Normal { .. })
),
"object {id} missing from the recovered table"
);
}
}
#[test]
fn reconstructs_with_junk_before_header() {
let pdf = damaged_pdf(b"From mail-gateway garbage line\n", b"%%EOF\n");
let document = reconstruct_document(&pdf).expect("junk prefix should not matter");
let Some(XrefEntry::Normal { offset, .. }) = document.reference_table.get(1) else {
panic!("object 1 missing");
};
assert_eq!(&pdf[*offset as usize..*offset as usize + 7], b"1 0 obj");
}
#[test]
fn trailer_root_wins_over_catalog_fallback() {
let pdf = damaged_pdf(
b"",
b"trailer\n<< /Root 1 0 R /Size -7 >>\nstartxref\n999999\n%%EOF\n",
);
let document = reconstruct_document(&pdf).expect("trailer should recover");
assert_eq!(
document
.trailer
.get(b"Root")
.unwrap()
.as_reference()
.unwrap(),
(1, 0)
);
}
#[test]
fn refuses_encrypted_and_unrecoverable_buffers() {
let pdf = damaged_pdf(b"", b"trailer\n<< /Root 1 0 R /Encrypt 9 0 R >>\n%%EOF\n");
assert!(reconstruct_document(&pdf).is_none());
let pdf = damaged_pdf(b"", b"trailer\n<< /Encrypt 9 0 R >>\n%%EOF\n");
assert!(reconstruct_document(&pdf).is_none());
let pdf = damaged_pdf(b"", b"trailer garbage /Encrypt 9 0 R garbage\n%%EOF\n");
assert!(reconstruct_document(&pdf).is_none());
assert!(reconstruct_document(b"not a pdf, no objects here").is_none());
assert!(
reconstruct_document(b"%PDF-1.4\n1 0 obj\n<< /Type /Font >>\nendobj\n%%EOF\n")
.is_none()
);
}
#[test]
fn prose_encrypt_mentions_do_not_block_repair() {
let mut pdf = damaged_pdf(b"", b"");
pdf.extend_from_slice(b"4 0 obj\n(how /Encrypt works in PDF)\nendobj\n");
pdf.extend_from_slice(b"trailer\n<< /Root 1 0 R >>\nstartxref\n999999\n%%EOF\n");
assert!(
reconstruct_document(&pdf).is_some(),
"a prose /Encrypt mention must not block repair"
);
let mut pdf = damaged_pdf(b"", b"");
pdf.extend_from_slice(b"4 0 obj\n(how /Encrypt works in PDF)\nendobj\n%%EOF\n");
assert!(
reconstruct_document(&pdf).is_some(),
"prose /Encrypt without an N G R tail must not trip the backstop"
);
let mut pdf = damaged_pdf(b"", b"");
pdf.extend_from_slice(b"4 0 obj\n<< /EncryptMetadata 5 0 R >>\nendobj\n%%EOF\n");
assert!(reconstruct_document(&pdf).is_some());
}
#[test]
fn expands_object_stream_members() {
let members =
b"<< /Type /Catalog /Pages 6 0 R >> << /Type /Pages /Kids [7 0 R] /Count 1 >>";
let pairs = "5 0 6 34 ";
let first = pairs.len();
let content = format!("{pairs}{}", String::from_utf8_lossy(members));
let mut pdf = Vec::new();
pdf.extend_from_slice(b"%PDF-1.5\n");
pdf.extend_from_slice(
format!(
"4 0 obj\n<< /Type /ObjStm /N 2 /First {first} /Length {} >>\nstream\n{content}\nendstream\nendobj\n",
content.len()
)
.as_bytes(),
);
pdf.extend_from_slice(
b"7 0 obj\n<< /Type /Page /Parent 6 0 R /MediaBox [0 0 10 10] >>\nendobj\n",
);
pdf.extend_from_slice(b"garbage instead of an xref stream\n%%EOF\n");
let document = reconstruct_document(&pdf).expect("object stream members should recover");
assert_eq!(
document
.trailer
.get(b"Root")
.unwrap()
.as_reference()
.unwrap(),
(5, 0)
);
assert!(matches!(
document.reference_table.get(5),
Some(XrefEntry::Compressed {
container: 4,
index: 0
})
));
assert!(matches!(
document.reference_table.get(6),
Some(XrefEntry::Compressed {
container: 4,
index: 1
})
));
assert!(matches!(
document.reference_table.get(7),
Some(XrefEntry::Normal { .. })
));
}
#[test]
fn top_level_header_beats_object_stream_membership() {
let members = b"<< /Type /Catalog /Pages 6 0 R >> << /Ignored true >>";
let content = format!("5 0 6 34 {}", String::from_utf8_lossy(members));
let mut pdf = Vec::new();
pdf.extend_from_slice(b"%PDF-1.5\n");
pdf.extend_from_slice(
format!(
"4 0 obj\n<< /Type /ObjStm /N 2 /First 9 /Length {} >>\nstream\n{content}\nendstream\nendobj\n",
content.len()
)
.as_bytes(),
);
pdf.extend_from_slice(
b"6 0 obj\n<< /Type /Pages /Kids [7 0 R] /Count 1 >>\nendobj\n\
7 0 obj\n<< /Type /Page /Parent 6 0 R /MediaBox [0 0 10 10] >>\nendobj\n%%EOF\n",
);
let document = reconstruct_document(&pdf).expect("should recover");
assert!(matches!(
document.reference_table.get(6),
Some(XrefEntry::Normal { .. })
));
}
#[test]
fn skips_object_streams_whose_length_lies() {
let content = "5 0 6 34 << /Ignored true >> << /Ignored true >>";
let mut pdf = Vec::new();
pdf.extend_from_slice(b"%PDF-1.5\n");
pdf.extend_from_slice(
format!(
"4 0 obj\n<< /Type /ObjStm /N 2 /First 9 /Length 5 >>\nstream\n{content}\nendstream\nendobj\n"
)
.as_bytes(),
);
pdf.extend_from_slice(
b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n\
2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n%%EOF\n",
);
let document = reconstruct_document(&pdf).expect("top-level objects still recover");
assert!(
document.reference_table.get(5).is_none(),
"members of an unreadable container must not be registered"
);
assert!(matches!(
document.reference_table.get(4),
Some(XrefEntry::Normal { .. })
));
assert_eq!(
document
.trailer
.get(b"Root")
.unwrap()
.as_reference()
.unwrap(),
(1, 0)
);
}
}