use pdfrum_common::{DiagKind, Diagnostics, Limits, Severity};
use pdfrum_object::{ByteSpan, NoResolve, Object, names};
use crate::error::Error;
use crate::lexer::{Lexer, Token, atoi64};
use crate::syntax::{Context, Strictness, indirect};
use crate::xref::{Trailer, Xref, classic, merge_into_walk, merge_trailers, rebuild, stream};
const MIN_XREF_OFFSET: i64 = 9;
#[derive(Debug, Clone, Copy, Default)]
struct Section {
table: usize,
stream: usize,
}
pub(crate) fn load(
file: &ByteSpan,
limits: &Limits,
diags: &mut Diagnostics,
) -> Result<(Xref, Trailer, XrefShape), Error> {
let shared = file.clone();
let start = start_xref(file, limits, diags);
let mut xref = Xref::new();
let mut trailer = Trailer::default();
if start >= MIN_XREF_OFFSET
&& let Ok(pos) = usize::try_from(start)
{
let main_is_stream = !probe_is_table(&shared, pos, limits, diags);
if read_chain(&shared, pos, &mut xref, &mut trailer, limits, diags) {
return Ok((
xref,
trailer,
XrefShape {
rebuilt: false,
last_offset: u64::try_from(start).unwrap_or(0),
main_is_stream,
},
));
}
}
diags.record(Severity::Recovered, DiagKind::XrefRebuilt, None);
match rebuild::rebuild(&shared, &mut xref, &mut trailer, limits, diags, &NoResolve) {
Ok(true) => Ok((xref, trailer, XrefShape::rebuilt())),
Ok(false) => Err(Error::XrefBroken),
Err(limit) => Err(Error::Limit(limit)),
}
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct XrefShape {
pub rebuilt: bool,
pub last_offset: u64,
pub main_is_stream: bool,
}
impl XrefShape {
pub(crate) const fn rebuilt() -> Self {
Self {
rebuilt: true,
last_offset: 0,
main_is_stream: false,
}
}
}
fn probe_is_table(file: &ByteSpan, pos: usize, limits: &Limits, diags: &mut Diagnostics) -> bool {
let mut probe = Xref::new();
classic::parse_table(file, pos, true, &mut probe, limits, diags).is_some()
}
pub(crate) fn start_xref(file: &[u8], limits: &Limits, diags: &mut Diagnostics) -> i64 {
let from = file.len().saturating_sub(9);
let window = usize::try_from(limits.startxref_scan).unwrap_or(usize::MAX);
let mut lx = Lexer::at(file, from);
let bad = |diags: &mut Diagnostics| {
diags.record(Severity::Recovered, DiagKind::BadStartXref, None);
0
};
if !lx.search_back(b"startxref", window) {
return bad(diags);
}
let _ = lx.next_word(limits);
match lx.next_word(limits) {
Token::Number(word) => {
let offset = atoi64(word);
if u64::try_from(offset).is_ok_and(|o| o < file.len() as u64) {
offset
} else {
bad(diags)
}
}
_ => bad(diags),
}
}
pub(crate) fn read_chain(
file: &ByteSpan,
pos: usize,
xref: &mut Xref,
trailer: &mut Trailer,
limits: &Limits,
diags: &mut Diagnostics,
) -> bool {
let main_is_table = probe_is_table(file, pos, limits, diags);
let Some(sections) = walk(file, pos, main_is_table, xref, trailer, limits, diags) else {
return false;
};
xref.set_sections(
sections
.iter()
.map(|s| super::Section {
offset: (if s.stream > 0 { s.stream } else { s.table }) as u64,
is_stream: s.stream > 0,
})
.collect(),
);
let Some((oldest, newer)) = sections.split_first() else {
return !xref.is_empty();
};
if oldest.table > 0 {
if classic::parse_table(file, oldest.table, false, xref, limits, diags).is_none() {
return false;
}
if !classic::verify_table(file, xref, limits) {
diags.record(
Severity::Suspicious,
DiagKind::XrefEntriesShifted,
Some(oldest.table as u64),
);
return false;
}
}
for section in newer {
if section.stream > 0
&& read_stream_section(file, section.stream, false, xref, limits, diags).is_none()
{
return false;
}
if section.table > 0
&& classic::parse_table(file, section.table, false, xref, limits, diags).is_none()
{
return false;
}
}
!xref.is_empty() || !trailer.dict.is_empty()
}
fn walk(
file: &ByteSpan,
main: usize,
main_is_table: bool,
xref: &mut Xref,
trailer: &mut Trailer,
limits: &Limits,
diags: &mut Diagnostics,
) -> Option<Vec<Section>> {
let mut sections: Vec<Section> = Vec::new();
let mut seen: Vec<usize> = vec![main];
let mut next = if main_is_table {
let end = classic::parse_table(file, main, true, &mut Xref::new(), limits, diags)?;
let dict = classic::read_trailer(file, end, limits, diags, &NoResolve)?;
let prev = dict.direct_int(names::PREV).unwrap_or(0);
let hybrid = dict.int(names::XREF_STM, &NoResolve).unwrap_or(0);
merge_trailers(
trailer,
&Trailer {
dict,
object_number: 0,
},
);
apply_size(xref, trailer, limits);
sections.push(Section {
table: main,
stream: usize::try_from(hybrid).unwrap_or(0),
});
prev
} else {
let mut entries = Xref::new();
let read = read_stream_section(file, main, true, &mut entries, limits, diags)?;
xref.merge_up(&entries);
merge_trailers(trailer, &read.trailer);
sections.push(Section {
table: 0,
stream: main,
});
read.prev
};
while next > 0 {
let Ok(pos) = usize::try_from(next) else {
return None;
};
if seen.contains(&pos) {
diags.record(
Severity::Suspicious,
DiagKind::XrefPrevLoop,
Some(pos as u64),
);
return None;
}
seen.push(pos);
if let Some(read) = read_stream_section(file, pos, false, xref, limits, diags) {
merge_into_walk(trailer, &read.trailer);
sections.insert(
0,
Section {
table: 0,
stream: pos,
},
);
next = read.prev;
continue;
}
let end =
classic::parse_table(file, pos, true, &mut Xref::new(), limits, diags).unwrap_or(pos);
let dict = classic::read_trailer(file, end, limits, diags, &NoResolve)?;
let prev = dict.direct_int(names::PREV).unwrap_or(0);
let hybrid = dict.int(names::XREF_STM, &NoResolve).unwrap_or(0);
merge_into_walk(
trailer,
&Trailer {
dict,
object_number: 0,
},
);
sections.insert(
0,
Section {
table: pos,
stream: usize::try_from(hybrid).unwrap_or(0),
},
);
next = prev;
}
Some(sections)
}
fn read_stream_section(
file: &ByteSpan,
pos: usize,
is_main: bool,
xref: &mut Xref,
limits: &Limits,
diags: &mut Diagnostics,
) -> Option<stream::XrefStream> {
let mut lx = Lexer::at(file, pos);
let mut ctx = Context {
limits,
diags,
file: Some(file),
store: Some(&NoResolve),
};
let parsed = indirect(&mut lx, &mut ctx, Strictness::Loose, 0).ok()?;
if parsed.num == 0 {
return None;
}
let Object::Stream(s) = parsed.object else {
return None;
};
let decoded = crate::decode::structural_bytes(&s, &NoResolve, limits, diags)?;
stream::read_xref_stream(&s, parsed.num, &decoded, is_main, xref, limits, diags)
}
fn apply_size(xref: &mut Xref, trailer: &Trailer, limits: &Limits) {
let Some(size) = trailer.dict.direct_int(names::SIZE) else {
return;
};
if size >= 1
&& size <= i64::from(limits.max_xref_size)
&& let Ok(size) = u32::try_from(size)
{
xref.set_size(size);
}
}
#[cfg(test)]
mod tests {
use pdfrum_object::ByteSpan;
use super::{load, start_xref};
use crate::xref::Entry;
use pdfrum_common::{DiagKind, Diagnostics, Limits};
use pdfrum_object::names;
fn read(file: &[u8]) -> Option<(crate::xref::Xref, crate::xref::Trailer, bool, Diagnostics)> {
shape(file).map(|(x, t, s, d)| (x, t, s.rebuilt, d))
}
fn shape(
file: &[u8],
) -> Option<(
crate::xref::Xref,
crate::xref::Trailer,
super::XrefShape,
Diagnostics,
)> {
let mut diags = Diagnostics::default();
load(
&ByteSpan::from(file.to_vec()),
&Limits::default(),
&mut diags,
)
.ok()
.map(|(x, t, s)| (x, t, s, diags))
}
#[test]
fn start_xref_reads_the_last_one() {
let mut diags = Diagnostics::default();
let file = b"junk\nstartxref\n17\n%%EOF\n";
assert_eq!(start_xref(file, &Limits::default(), &mut diags), 17);
}
#[test]
fn a_missing_start_xref_reads_as_zero() {
let mut diags = Diagnostics::default();
assert_eq!(
start_xref(b"no marker here", &Limits::default(), &mut diags),
0
);
assert!(diags.contains(&DiagKind::BadStartXref));
}
#[test]
fn an_offset_past_the_file_reads_as_zero() {
let mut diags = Diagnostics::default();
let file = b"startxref\n99999\n%%EOF\n";
assert_eq!(start_xref(file, &Limits::default(), &mut diags), 0);
}
#[test]
fn a_classic_chain_loads() {
let file = build_classic();
let (xref, trailer, rebuilt, _) = read(&file).expect("loaded");
assert!(!rebuilt);
assert!(matches!(xref.entry(1), Some(Entry::Offset(_))));
assert!(trailer.dict.raw(names::ROOT).is_some());
}
#[test]
fn a_start_xref_ending_at_the_search_origin_is_still_found() {
let base = build_classic();
let text = String::from_utf8_lossy(&base).into_owned();
let Some((head, tail)) = text.rsplit_once("startxref\n") else {
panic!("the builder writes a startxref");
};
let Some((offset, _)) = tail.split_once('\n') else {
panic!("the offset is on its own line");
};
let padded = format!("{offset:0>7}");
let truncated = format!("{head}startxref {padded}");
let origin = truncated.len() - 9;
assert_eq!(
truncated.as_bytes().get(origin - 8..=origin),
Some(&b"startxref"[..]),
"the keyword must end at the search origin"
);
let (xref, trailer, rebuilt, _) = read(truncated.as_bytes()).expect("loaded");
assert!(!rebuilt, "the table should be read, not rebuilt");
assert!(matches!(xref.entry(1), Some(Entry::Offset(_))));
assert!(trailer.dict.raw(names::ROOT).is_some());
}
#[test]
fn a_broken_start_xref_falls_back_to_the_scan() {
let text = String::from_utf8_lossy(&build_classic()).into_owned();
let Some((head, tail)) = text.rsplit_once("startxref\n") else {
panic!("the builder writes a startxref");
};
let Some((_, after)) = tail.split_once('\n') else {
panic!("the offset is on its own line");
};
let broken = format!("{head}startxref\n30\n{after}");
let (xref, trailer, rebuilt, diags) = read(broken.as_bytes()).expect("loaded");
assert!(rebuilt);
assert!(xref.entry(1).is_some());
assert!(trailer.dict.raw(names::ROOT).is_some());
assert!(diags.contains(&DiagKind::XrefRebuilt));
}
#[test]
fn a_file_with_nothing_readable_fails() {
assert!(read(b"not a pdf at all").is_none());
}
#[test]
fn a_classic_chain_reports_its_offset_and_that_it_is_not_a_stream() {
let file = build_classic();
let (_, _, shape, _) = shape(&file).expect("loaded");
assert!(!shape.rebuilt);
assert!(!shape.main_is_stream);
let at = usize::try_from(shape.last_offset).expect("fits");
assert_eq!(file.get(at..at + 4), Some(&b"xref"[..]));
}
#[test]
fn a_rebuilt_table_reports_offset_zero() {
let text = String::from_utf8_lossy(&build_classic()).into_owned();
let Some((head, tail)) = text.rsplit_once("startxref\n") else {
panic!("the builder writes a startxref");
};
let Some((_, after)) = tail.split_once('\n') else {
panic!("the offset is on its own line");
};
let broken = format!("{head}startxref\n30\n{after}");
let (_, _, shape, _) = shape(broken.as_bytes()).expect("loaded");
assert!(shape.rebuilt);
assert_eq!(shape.last_offset, 0);
assert!(!shape.main_is_stream);
}
fn build_classic() -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(b"%PDF-1.7\n");
let obj1 = out.len();
out.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
let obj2 = out.len();
out.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n");
let xref_at = out.len();
out.extend_from_slice(b"xref\n0 3\n");
out.extend_from_slice(b"0000000000 65535 f \n");
out.extend_from_slice(format!("{obj1:010} 00000 n \n").as_bytes());
out.extend_from_slice(format!("{obj2:010} 00000 n \n").as_bytes());
out.extend_from_slice(b"trailer\n<< /Size 3 /Root 1 0 R >>\nstartxref\n");
out.extend_from_slice(format!("{xref_at}\n").as_bytes());
out.extend_from_slice(b"%%EOF\n");
out
}
}