Merge pull request #4117 from aiongg/fix/coderabbit-b-caps

fix(limits): bound the decoding and listing work that repeated references and small inputs multiply
This commit is contained in:
Dylan M. Taylor
2026-10-22 21:38:59 -04:00
committed by GitHub
22 changed files with 999 additions and 98 deletions
+6
View File
@@ -120,12 +120,18 @@ fn smooth_curve(pts: &[(f64, f64)]) -> String {
smooth_segments(pts).iter().map(|[a, b, e]| format!("{} {} {} {} {} {} c\n", n(a.0), n(a.1), n(b.0), n(b.1), n(e.0), n(e.1))).collect()
}
/// Bumps one cloudy outline draws at most (plus one per edge); past it the bumps grow.
pub(crate) const MAX_CLOUD_BUMPS: usize = 20_000;
/// A closed cloudy outline through `pts` (§12.5.4 `/BE /S /C`): each edge becomes a row of
/// half-circle bumps of about `r`, bulging outwards.
pub fn cloud_path(pts: &[(f64, f64)], r: f64) -> String {
let area: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| a.0 * b.1 - b.0 * a.1).sum();
// Outward normal of an edge: to the right of travel for counter-clockwise outlines.
let sign = if area >= 0.0 { 1.0 } else { -1.0 };
let perimeter: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| (b.0 - a.0).hypot(b.1 - a.1)).sum();
// Each edge rounds to at least one bump, so the total stays within the budget plus one per edge.
let r = r.max(perimeter / (2.0 * MAX_CLOUD_BUMPS as f64));
let mut c = String::new();
for (i, (a, b)) in pts.iter().zip(pts.iter().cycle().skip(1)).enumerate() {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
+28 -5
View File
@@ -78,11 +78,15 @@ pub struct LinkItem {
pub style: LinkStyle,
}
/// Bytes of web-address text one listing keeps, counted after conversion to UTF-8; past it a link
/// lists as [`LinkAction::Other`].
pub(crate) const MAX_URI_BYTES: usize = 16 << 20;
fn nums(doc: &Document, o: Option<&Object>) -> Vec<f64> {
o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())).unwrap_or_default()
}
fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef]) -> LinkAction {
fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef], uri_budget: &mut usize) -> LinkAction {
let dest_page = |dest: &Object| -> Option<usize> {
let a = doc.resolve(dest);
let first = a.as_array()?.first()?.clone();
@@ -97,9 +101,20 @@ fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef]) -> LinkAction {
}
let Some(a) = d.get(b"A").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()) else { return LinkAction::Other("no action".into()) };
match a.name(b"S") {
Some(b"URI") => LinkAction::Uri(
a.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| String::from_utf8_lossy(&s.bytes).into_owned())).unwrap_or_default(),
),
Some(b"URI") => {
let uri = a.get(b"URI").map(|u| doc.resolve(u));
let bytes = uri.as_ref().and_then(|u| u.as_string()).map_or(&[][..], |s| &s.bytes[..]);
// The raw length first, so an address already over the budget is never converted; then the
// converted text, which is up to three times longer where the bytes are not UTF-8.
let text = (bytes.len() <= *uri_budget).then(|| String::from_utf8_lossy(bytes));
match text.and_then(|t| Some((uri_budget.checked_sub(t.len())?, t))) {
Some((left, text)) => {
*uri_budget = left;
LinkAction::Uri(text.into_owned())
}
None => LinkAction::Other("web address too long".into()),
}
}
Some(b"GoTo") => a.get(b"D").and_then(dest_page).map_or_else(|| LinkAction::Other("named destination".into()), LinkAction::Page),
Some(s) => LinkAction::Other(String::from_utf8_lossy(s).into_owned()),
None => LinkAction::Other("no action".into()),
@@ -133,8 +148,16 @@ fn style_of(doc: &Document, d: &Dict) -> LinkStyle {
pub fn list(doc: &Document) -> Vec<LinkItem> {
let Ok(pages) = page_refs(doc) else { return Vec::new() };
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut uri_budget = MAX_URI_BYTES;
for (pi, p) in pages.iter().enumerate() {
for (k, a) in annots(doc, *p).iter().enumerate() {
// One annotation object listed under several entries or pages is one link.
if let Some(r) = a.as_ref()
&& !seen.insert(r)
{
continue;
}
let Some(d) = doc.resolve(a).as_dict().cloned() else { continue };
if d.name(b"Subtype") != Some(b"Link") {
continue;
@@ -148,7 +171,7 @@ pub fn list(doc: &Document) -> Vec<LinkItem> {
index: k,
obj: a.as_ref(),
rect: [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])],
action: action_of(doc, &d, &pages),
action: action_of(doc, &d, &pages, &mut uri_budget),
style: style_of(doc, &d),
});
}
+63
View File
@@ -1504,3 +1504,66 @@ fn typed_signature_uses_nonzero_winding_fill() {
assert!(ap.ends_with("h\nf\n"), "expected non-zero winding fill (`f`), got: {ap}");
assert!(!ap.contains("f*"), "must not use even-odd fill (`f*`): {ap}");
}
fn doc_of(objs: &[String]) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).expect("opens")
}
#[test]
fn link_listing_skips_repeated_refs_and_caps_web_address_text() {
// One link referenced 1000 times, and 300 small links sharing one 64 KiB address.
let annots = format!("{}{}", " 4 0 R".repeat(1000), "<< /Subtype /Link /Rect [0 0 1 1] /A 5 0 R >>".repeat(300));
let doc = doc_of(&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(),
format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"),
"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A 5 0 R >>".into(),
"<< /S /URI /URI 6 0 R >>".into(),
format!("({})", "u".repeat(64 << 10)),
]);
let all = links::list(&doc);
assert_eq!(all.iter().filter(|l| l.obj == Some(ObjRef::new(4, 0))).count(), 1);
assert_eq!(all.len(), 301, "every distinct link is still listed");
let kept: usize = all.iter().map(|l| if let links::LinkAction::Uri(u) = &l.action { u.len() } else { 0 }).sum();
assert!(kept <= links::MAX_URI_BYTES, "{kept} bytes of web addresses kept");
assert!(matches!(&all[0].action, links::LinkAction::Uri(u) if u.len() == 64 << 10));
}
#[test]
fn web_address_cap_counts_the_text_kept_after_conversion() {
// 200 links sharing a 64 KiB address of invalid UTF-8: 12.5 MiB of bytes, each byte kept as a
// three-byte replacement character.
let annots = "<< /Subtype /Link /Rect [0 0 1 1] /A 4 0 R >>".repeat(200);
let doc = doc_of(&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(),
format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"),
"<< /S /URI /URI 5 0 R >>".into(),
format!("<{}>", "FF".repeat(64 << 10)),
]);
let all = links::list(&doc);
assert_eq!(all.len(), 200);
let kept: usize = all.iter().map(|l| if let links::LinkAction::Uri(u) = &l.action { u.len() } else { 0 }).sum();
assert!(kept <= links::MAX_URI_BYTES, "{kept} bytes of web addresses kept");
assert!(kept > 0);
}
#[test]
fn cloud_outline_has_a_bump_budget() {
// 50 long edges would each ask for 10,000 bumps of radius 1.
let pts: Vec<(f64, f64)> = (0..50).map(|i| (f64::from(i) * std::f64::consts::TAU / 50.0).sin_cos()).map(|(s, c)| (1e7 * c, 1e7 * s)).collect();
let bumps = appearance::cloud_path(&pts, 1.0).lines().filter(|l| l.ends_with(" c")).count();
assert!((pts.len()..=appearance::MAX_CLOUD_BUMPS + 2 * pts.len()).contains(&bumps), "{bumps} bumps");
}
+35 -1
View File
@@ -10,6 +10,9 @@ use crate::object::{Dict, ObjRef, Object, PdfString, Stream};
use crate::{Bytes, CosError};
const MAX_DEPTH: usize = 128;
/// Objects one top-level [`Lexer::object`] call may build. Real arrays (page trees, parent
/// trees, widths) stay far below; a decompressed object stream of `0 0 0 ...` does not.
const MAX_NODES: usize = 1 << 22;
/// One of the six PDF whitespace characters (ISO 32000-2 §7.2.3, Table 1).
pub fn is_whitespace(b: u8) -> bool {
@@ -28,11 +31,13 @@ fn is_regular(b: u8) -> bool {
pub struct Lexer<'a> {
pub data: &'a [u8],
pub pos: usize,
/// Objects built by the current top-level [`Lexer::object`] call.
nodes: usize,
}
impl<'a> Lexer<'a> {
pub fn new(data: &'a [u8], pos: usize) -> Self {
Self { data, pos: pos.min(data.len()) }
Self { data, pos: pos.min(data.len()), nodes: 0 }
}
fn peek(&self) -> Option<u8> {
@@ -84,13 +89,22 @@ impl<'a> Lexer<'a> {
/// Parse one direct object. References `N G R` are recognised.
pub fn object(&mut self) -> Result<Object, CosError> {
self.nodes = 0;
self.object_depth(0)
}
fn over_budget(&self) -> bool {
self.nodes > MAX_NODES
}
fn object_depth(&mut self, depth: usize) -> Result<Object, CosError> {
if depth > MAX_DEPTH {
return Err(self.err("nesting too deep"));
}
self.nodes = self.nodes.saturating_add(1);
if self.over_budget() {
return Err(self.err("object too large"));
}
self.skip_ws();
let Some(b) = self.peek() else { return Err(self.err("unexpected end of data")) };
match b {
@@ -125,6 +139,7 @@ impl<'a> Lexer<'a> {
let before = self.pos;
match self.object_depth(depth + 1) {
Ok(o) => items.push(o),
Err(e) if self.over_budget() => return Err(e),
// Tolerate junk inside arrays by skipping one byte.
Err(_) if self.pos == before => self.pos += 1,
Err(e) => return Err(e),
@@ -283,6 +298,9 @@ impl<'a> Lexer<'a> {
// Tolerate garbage between entries (seen in damaged files): skip a token.
let before = self.pos;
let _ = self.object_depth(depth + 1);
if self.over_budget() {
return Err(self.err("object too large"));
}
if self.pos == before {
self.pos += 1;
}
@@ -673,4 +691,20 @@ mod tests {
let deep = "[".repeat(10_000);
assert!(Lexer::new(deep.as_bytes(), 0).object().is_err());
}
#[test]
fn one_object_has_a_node_budget() {
// A wide array (from a tiny compressed object stream) must not grow without bound.
let wide = |n: usize| format!("[{}]", "0 ".repeat(n));
let at_cap = Lexer::new(wide(MAX_NODES - 1).as_bytes(), 0).object();
assert!(matches!(at_cap, Ok(Object::Array(ref items)) if items.len() == MAX_NODES - 1));
assert!(Lexer::new(wide(MAX_NODES).as_bytes(), 0).object().is_err());
// The same inside a dictionary value and behind tolerated junk.
assert!(Lexer::new(format!("<< /A {} >>", wide(MAX_NODES)).as_bytes(), 0).object().is_err());
assert!(Lexer::new(format!("[[{}] ]", "0 ".repeat(MAX_NODES)).as_bytes(), 0).object().is_err());
// The budget is per top-level object: a lexer reused for many operands keeps working.
let two = wide(MAX_NODES - 1).repeat(2);
let mut lx = Lexer::new(two.as_bytes(), 0);
assert!(lx.object().is_ok() && lx.object().is_ok());
}
}
+6 -1
View File
@@ -359,6 +359,9 @@ impl Space {
}
}
/// A lookup table holds at most 256 entries of 4 bytes; decoding stops well past that.
const MAX_LOOKUP: usize = 64 << 10;
fn space(doc: &Document, cs: Option<&Object>) -> Result<Space, String> {
let Some(cs) = cs else { return Err("the image has no colour space".into()) };
let cs = doc.resolve(cs);
@@ -400,7 +403,9 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result<Space, String> {
};
let table = match a.get(3).map(|t| doc.resolve(t)).as_deref() {
Some(Object::String(s)) => s.bytes.clone(),
Some(Object::Stream(t)) => t.decoded().map_err(|e| e.to_string())?,
Some(Object::Stream(t)) => {
t.decoded_within(MAX_LOOKUP).map_err(|_| "an indexed colour space with an oversized or damaged lookup table".to_string())?
}
_ => return Err("an indexed colour space without a lookup table".into()),
};
Ok(Space::Indexed(Box::new(base), hival, table))
+9
View File
@@ -832,6 +832,15 @@ fn indexed_hival_resolves_indirect_values_and_stream_palettes() {
}
}
#[test]
fn indexed_lookup_stream_has_a_size_cap() {
// A lookup table needs 256 × 4 bytes at most; a 1 MiB one is refused, not decoded whole.
let mut palette = vec![255, 0, 0, 0, 0, 255];
palette.resize(1 << 20, 0);
let (doc, image) = indexed_export_fixture(Object::Int(1), palette, true, false, 8, vec![0, 1, 0, 1]);
assert!(crate::extract::image_file(&doc, image).unwrap_err().contains("lookup table"));
}
#[test]
fn indexed_hival_boundary_values_keep_valid_palette_entries() {
let palette: Vec<u8> = (0..=255u8).flat_map(|i| [i, 255 - i, 17]).collect();
+108 -32
View File
@@ -1,9 +1,10 @@
//! Unicode embedding of an explicitly supplied, embeddable TrueType face. Font files are
//! document inputs or the optional craft-fonts build input, never system-interface fallbacks.
use std::collections::HashMap;
use std::fmt::Write as _;
use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
use skrifa::{
FontRef, MetadataProvider,
instance::{LocationRef, Size},
@@ -173,7 +174,8 @@ pub fn embed_mapping(
return Err("the font family name is invalid".into());
}
let glyph_map = if cff { cid_encoding.as_bytes() } else { gids.as_slice() };
if let Some(resource) = same_resource(doc, fonts, &name, bytes, glyph_map, cmap.as_bytes()) {
let mut programs = ProgramMatch::new(bytes);
if let Some(resource) = same_resource(doc, fonts, &name, &mut programs, glyph_map, cmap.as_bytes()) {
return Ok(resource);
}
let mut descriptor = Dict::new();
@@ -193,12 +195,7 @@ pub fn embed_mapping(
);
// Reuse the program another resource of this page already has, else one registered on another
// page; source subset programs stay untouched.
let existing = written(doc, fonts).find(|w| w.has_program(doc, bytes)).map(|w| w.program).or_else(|| {
doc.scan_objects().take(8192).find_map(|(r, o)| match o.as_ref() {
Object::Stream(s) if is_program(doc, s, bytes) => Some(Object::Ref(r)),
_ => None,
})
});
let existing = existing_program(doc, fonts, &mut programs);
let program = existing.unwrap_or_else(|| {
let mut d = Dict::new();
d.set(b"Length1".to_vec(), Object::Int(bytes.len() as i64));
@@ -246,12 +243,64 @@ pub fn embed_mapping(
Ok(resource)
}
/// The decoded data of the stream `object` refers to.
fn stream_data(doc: &Document, object: Option<&Object>) -> Option<Vec<u8>> {
match doc.resolve(object?).as_ref() {
Object::Stream(s) => s.decoded_within(17 << 20).ok(),
_ => None,
/// Whether the stream `object` refers to decodes to `expected`.
fn stream_is(doc: &Document, object: Option<&Object>, expected: &[u8]) -> bool {
object.is_some_and(|o| match doc.resolve(o).as_ref() {
Object::Stream(s) => s.decoded_within(expected.len()).is_ok_and(|d| d == expected),
_ => false,
})
}
/// Full programs one embedding decodes at most while looking for its own. A real match passes
/// the cheap checks and decodes once; many candidates that pass them come from a hostile file.
const MAX_PROGRAM_DECODES: usize = 16;
/// Recognises the full program written for `bytes`: each candidate stream decodes at most once
/// and within `bytes.len()`, and at most [`MAX_PROGRAM_DECODES`] decode in all.
struct ProgramMatch<'a> {
bytes: &'a [u8],
known: HashMap<ObjRef, bool>,
decodes: usize,
}
impl<'a> ProgramMatch<'a> {
fn new(bytes: &'a [u8]) -> Self {
Self { bytes, known: HashMap::new(), decodes: 0 }
}
/// Whether `r`, holding `object`, is the program.
fn is(&mut self, doc: &Document, r: ObjRef, object: &Object) -> bool {
if let Some(&known) = self.known.get(&r) {
return known;
}
let found = match object {
Object::Stream(s)
if s.dict.get(b"PCFullFont").is_some()
&& s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(self.bytes.len()).ok()
&& self.decodes < MAX_PROGRAM_DECODES =>
{
self.decodes = self.decodes.saturating_add(1);
s.decoded_within(self.bytes.len()).is_ok_and(|data| data == self.bytes)
}
_ => false,
};
self.known.insert(r, found);
found
}
/// Whether the resource's program is this one.
fn written(&mut self, doc: &Document, w: &Written) -> bool {
w.program.as_ref().is_some_and(|r| self.is(doc, r, &doc.get(r)))
}
}
/// The program for `programs.bytes` that this page's `PCFull` resources, or else the first
/// objects of the document, already have.
fn existing_program(doc: &Document, fonts: &Dict, programs: &mut ProgramMatch) -> Option<Object> {
written(doc, fonts)
.find(|w| programs.written(doc, w))
.map(|w| w.program)
.or_else(|| doc.scan_objects().take(8192).find_map(|(r, o)| programs.is(doc, r, &o).then_some(Object::Ref(r))))
}
/// A `PCFull` resource that [`embed_mapping`] wrote.
@@ -263,21 +312,6 @@ struct Written {
program: Object,
}
impl Written {
/// Whether its program is `bytes`.
fn has_program(&self, doc: &Document, bytes: &[u8]) -> bool {
matches!(doc.resolve(&self.program).as_ref(), Object::Stream(s) if is_program(doc, s, bytes))
}
}
/// Whether `s` is a full program written for `bytes`, compressed or not. The length it was
/// written with is compared before decoding.
fn is_program(doc: &Document, s: &Stream, bytes: &[u8]) -> bool {
s.dict.get(b"PCFullFont").is_some()
&& s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(bytes.len()).ok()
&& s.decoded_within(17 << 20).is_ok_and(|data| data == bytes)
}
/// The `PCFull` resources in `fonts`, in order.
fn written<'a>(doc: &'a Document, fonts: &'a Dict) -> impl Iterator<Item = Written> + 'a {
fonts.iter().filter(|(key, _)| key.starts_with(b"PCFull")).take(256).filter_map(move |(key, value)| {
@@ -291,14 +325,14 @@ fn written<'a>(doc: &'a Document, fonts: &'a Dict) -> impl Iterator<Item = Writt
/// A resource in `fonts` that already is this embedding: the same font name, program, glyph map
/// and Unicode map. Editing the same text again reuses it instead of adding another copy.
fn same_resource(doc: &Document, fonts: &Dict, name: &str, bytes: &[u8], glyph_map: &[u8], unicode: &[u8]) -> Option<String> {
fn same_resource(doc: &Document, fonts: &Dict, name: &str, programs: &mut ProgramMatch, glyph_map: &[u8], unicode: &[u8]) -> Option<String> {
written(doc, fonts)
.find(|w| {
let glyphs = w.cid.get(b"CIDToGIDMap").or_else(|| w.top.get(b"Encoding"));
w.top.name(b"BaseFont") == Some(name.as_bytes())
&& stream_data(doc, w.top.get(b"ToUnicode")).is_some_and(|d| d == unicode)
&& stream_data(doc, glyphs).is_some_and(|d| d == glyph_map)
&& w.has_program(doc, bytes)
&& stream_is(doc, w.top.get(b"ToUnicode"), unicode)
&& stream_is(doc, glyphs, glyph_map)
&& programs.written(doc, w)
})
.map(|w| w.resource)
}
@@ -385,6 +419,48 @@ mod tests {
assert_eq!(programs(&doc), 2);
}
#[test]
fn the_program_search_decodes_each_candidate_once_and_few_in_all() {
let bytes = b"the program".as_slice();
let decoy = |doc: &mut Document| {
let mut d = Dict::new();
d.set(b"PCFullFont".to_vec(), Object::Bool(true));
d.set(b"Length1".to_vec(), Object::Int(bytes.len() as i64));
doc.add(Object::Stream(Stream::flate(d, &[0; 4096])))
};
let resource = |doc: &mut Document, program: ObjRef| {
let mut descriptor = Dict::new();
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
let mut cid = Dict::new();
cid.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
let mut top = Dict::new();
top.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(cid)]));
doc.add(Object::Dict(top))
};
// 200 resources sharing one decoy program decode it once.
let mut doc = Document::new_empty();
let shared = decoy(&mut doc);
let mut fonts = Dict::new();
for i in 0..200 {
let r = resource(&mut doc, shared);
fonts.set(format!("PCFull{i}").into_bytes(), Object::Ref(r));
}
let mut programs = ProgramMatch::new(bytes);
assert_eq!(existing_program(&doc, &fonts, &mut programs), None);
assert_eq!(programs.decodes, 1);
// 100 distinct decoys decode a bounded number of times.
let mut doc = Document::new_empty();
let mut fonts = Dict::new();
for i in 0..100 {
let program = decoy(&mut doc);
let r = resource(&mut doc, program);
fonts.set(format!("PCFull{i}").into_bytes(), Object::Ref(r));
}
let mut programs = ProgramMatch::new(bytes);
assert_eq!(existing_program(&doc, &fonts, &mut programs), None);
assert_eq!(programs.decodes, MAX_PROGRAM_DECODES);
}
#[test]
fn the_full_program_is_written_compressed_and_another_page_reuses_it() {
let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
+37 -2
View File
@@ -284,6 +284,9 @@ const MAX_CMAP_ENTRIES: usize = 1 << 20;
/// Most codespace or CID ranges kept from a CMap; Adobe's largest published CMaps have a few
/// thousand.
const MAX_CMAP_RANGES: usize = 1 << 16;
/// Largest decoded Encoding or ToUnicode CMap stream read; real CMaps stay well under it, and a larger
/// one is not decoded at all.
const MAX_CMAP_BYTES: usize = 16 << 20;
/// Code → Unicode of a font. Explicit mappings and sequential ranges are kept as declared, so a range
/// costs one record however many codes it covers; where two overlap, the one declared later wins.
@@ -688,7 +691,7 @@ impl Metrics {
m.codes = Codes::Two;
if let Some(e) = font.get(b"Encoding").map(|e| doc.resolve(e))
&& let Object::Stream(s) = &*e
&& let Ok(data) = s.decoded()
&& let Ok(data) = s.decoded_within(MAX_CMAP_BYTES)
{
let (spaces, cids) = parse_cmap(&data);
if !spaces.is_empty() {
@@ -1290,7 +1293,7 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode {
}
match font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref() {
Some(Object::Stream(s)) => {
if let Ok(data) = s.decoded() {
if let Ok(data) = s.decoded_within(MAX_CMAP_BYTES) {
parse_to_unicode(&data, &mut out);
}
}
@@ -1648,6 +1651,38 @@ mod tests {
assert_eq!(m.encode("abc"), None);
}
#[test]
fn cmap_streams_decode_within_a_size_cap() {
// A valid CMap padded past the cap (a small Flate stream of whitespace) is not decoded whole.
let to_unicode = b"1 begincodespacerange <0000> <FFFF> endcodespacerange 1 beginbfrange <0024> <0026> <0041> endbfrange";
let encoding = b"1 begincodespacerange <0000> <FFFF> endcodespacerange 1 begincidrange <0000> <FFFF> 0 endcidrange";
for padded in [false, true] {
let mut doc = Document::new_empty();
let mut stream = |cmap: &[u8]| {
let mut data = cmap.to_vec();
if padded {
data.resize(MAX_CMAP_BYTES + 1, b' ');
}
Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &data))))
};
let (tu, enc) = (stream(to_unicode), stream(encoding));
let mut desc = Dict::new();
desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2"));
let f = font(
&mut doc,
vec![
("Subtype", Object::name("Type0")),
("Encoding", enc),
("DescendantFonts", Object::Array(vec![Object::Dict(desc)])),
("ToUnicode", tu),
],
);
let m = Metrics::from_dict(&doc, &f);
assert_eq!(m.decode(&[0, 0x24]) == "A", !padded, "ToUnicode, padded: {padded}");
assert_eq!(matches!(m.codes, Codes::Ranges(_)), !padded, "Encoding, padded: {padded}");
}
}
#[test]
fn composite_fonts_use_to_unicode() {
let mut doc = Document::new_empty();
+27 -2
View File
@@ -448,10 +448,20 @@ pub struct Unsupported {
pub struct Listing {
pub measurements: Vec<Measurement>,
pub unsupported: Vec<Unsupported>,
/// The annotation limit stopped enumeration early.
/// The annotation or retained-size limit stopped enumeration early.
pub truncated: bool,
}
const MAX_ANNOTATIONS: usize = 100_000;
/// Points and text one listing keeps, as counted by [`retained`]: about a thousand
/// measurements of [`MAX_POINTS`] points each.
const MAX_LISTED_BYTES: usize = 32 << 20;
/// The bytes a listed measurement keeps in points and text.
fn retained(m: &Measurement) -> usize {
let s = &m.scale;
[m.id.len(), m.author.len(), m.label.len(), s.unit.len(), s.area_unit.len(), s.ratio.len()]
.into_iter()
.fold(m.points.len().saturating_mul(std::mem::size_of::<Point>()), usize::saturating_add)
}
fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: usize) -> Result<Measurement> {
let scale = Scale::read(doc, d.get(b"Measure").ok_or_else(|| invalid("measurement has no scale"))?)?;
let key = if kind == Kind::Distance { &b"L"[..] } else { b"Vertices" };
@@ -471,6 +481,8 @@ fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: us
pub fn list(doc: &Document) -> Listing {
let mut out = Listing::default();
let mut count = 0usize;
let mut kept = 0usize;
let mut seen = std::collections::HashSet::new();
for (page, p) in pdfcraft_model::pages(doc).iter().enumerate() {
let Some(o) = p.dict.get(b"Annots") else { continue };
let o = doc.resolve(o);
@@ -481,6 +493,12 @@ pub fn list(doc: &Document) -> Listing {
out.truncated = true;
return out;
}
// One annotation object under several entries or pages is one measurement.
if let Some(r) = a.as_ref()
&& !seen.insert(r)
{
continue;
}
let o = doc.resolve(a);
let Some(d) = o.as_dict() else { continue };
let kind = match d.name(b"IT") {
@@ -490,7 +508,14 @@ pub fn list(doc: &Document) -> Listing {
_ => continue,
};
match read_measurement(doc, d, kind, page, index) {
Ok(m) => out.measurements.push(m),
Ok(m) => {
kept = kept.saturating_add(retained(&m));
if kept > MAX_LISTED_BYTES {
out.truncated = true;
return out;
}
out.measurements.push(m);
}
Err(e) => out.unsupported.push(Unsupported { page, index, reason: e.to_string() }),
}
}
+9 -3
View File
@@ -214,14 +214,14 @@ impl Walker<'_> {
self.geometry.truncated = true;
break;
}
// An undecodable part is skipped; the rest of the page still snaps.
// An undecodable part is skipped; the parts before it still snap.
match s.decoded_within(remaining) {
Ok(data) => {
joined.extend(data);
// A space, as the renderer joins pieces: a trailing comment runs on.
joined.push(b' ');
}
Err(_) => self.geometry.unreadable = self.geometry.unreadable.saturating_add(1),
Err(_) => self.decode_failed(remaining),
}
}
}
@@ -236,11 +236,17 @@ impl Walker<'_> {
match stream.decoded_within(remaining) {
Ok(data) => self.ops(&data, resources, initial, depth),
Err(_) => {
self.geometry.unreadable = self.geometry.unreadable.saturating_add(1);
self.decode_failed(remaining);
Ok(())
}
}
}
/// A tolerant decode fails on an unsupported filter or once it has produced `remaining` bytes,
/// so a failure uses up the byte budget: repeating a failing stream cannot repeat that work.
fn decode_failed(&mut self, remaining: usize) {
self.geometry.unreadable = self.geometry.unreadable.saturating_add(1);
self.bytes = self.bytes.saturating_add(remaining);
}
fn ops(&mut self, data: &[u8], resources: &Dict, initial: Matrix, depth: usize) -> Result<()> {
self.bytes = self.bytes.saturating_add(data.len());
self.streams = self.streams.saturating_add(1);
+33
View File
@@ -403,3 +403,36 @@ fn a_scale_ratio_converts_its_page_side_to_points() {
assert!(Scale::ratio([0.0, 0.0], [1.0, 0.0], page, PageUnit::Mm, real, "mm", 2).is_err(), "{page} {real}");
}
}
#[test]
fn listing_skips_repeated_refs_and_caps_what_it_keeps() {
let mut d = fixture("", "", &[]);
let points: Vec<Point> = (0..MAX_POINTS).map(|i| [i as f64 * 0.1, 0.0]).collect();
add(&mut d, &new(Kind::Perimeter, points), &Meta::default()).unwrap();
let (r, mut annotation) = annot(&d, 0, 0).unwrap();
let page = pdfcraft_model::pages(&d)[0].obj;
let set_annots = |d: &mut Document, list: Vec<Object>| d.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(list))).unwrap();
// One annotation referenced 1000 times is one measurement.
set_annots(&mut d, vec![Object::Ref(r); 1000]);
assert_eq!(list(&d).measurements.len(), 1);
// Many small annotations sharing one 2048-point vertex array stop at the retained budget.
let vertices = d.add(annotation.get(b"Vertices").unwrap().clone());
annotation.set(b"Vertices".to_vec(), Object::Ref(vertices));
set_annots(&mut d, vec![Object::Dict(annotation); 1200]);
let listing = list(&d);
assert!(listing.truncated);
assert!(listing.measurements.iter().map(retained).sum::<usize>() <= MAX_LISTED_BYTES);
assert!(!listing.measurements.is_empty());
}
#[test]
fn a_stream_over_the_byte_budget_uses_it_up_once() {
// 50 references to one Flate stream that inflates past the 16 MiB budget decode once.
let big = pdfcraft_cos::Stream::flate(Dict::new(), &vec![b' '; 17 << 20]);
let hex: String = big.raw.iter().map(|b| format!("{b:02x}")).collect();
let bomb = String::from_utf8(pdfcraft_testkit::stream_obj("/Filter [/ASCIIHexDecode /FlateDecode]", hex)).unwrap();
let contents = format!("[4 0 R{}]", " 5 0 R".repeat(50));
let d = fixture_with(&contents, "0 0 m 10 0 l S", "", &[bomb.as_str()]);
let g = snap::geometry(&d, 0).unwrap();
assert_eq!(g.unreadable, 1);
assert!(g.truncated);
assert_eq!(g.segments.len(), 1, "the parts before it still snap");
}
+4 -1
View File
@@ -214,6 +214,9 @@ fn has_pdfa_intent(doc: &Document) -> bool {
pdfa_intent_profile(doc).is_some()
}
/// Largest output-intent profile decoded to read its header; real ICC profiles stay well under it.
const MAX_ICC_PROFILE: usize = 16 << 20;
/// The PDF/A output intent's destination profile: `Some(n)` with the profile's number of colour
/// components (`/N`, else the ICC header's data colour space; `None` when neither says), or
/// `None` when the document has no PDF/A output intent.
@@ -229,7 +232,7 @@ fn pdfa_intent_profile(doc: &Document) -> Option<Option<i64>> {
let Object::Stream(s) = &*profile else { return Some(None) };
Some(s.dict.int(b"N").or_else(|| {
// ICC.1 header: the data colour space is bytes 16–19.
match s.decoded().ok()?.get(16..20)? {
match s.decoded_within(MAX_ICC_PROFILE).ok()?.get(16..20)? {
b"GRAY" => Some(1),
b"RGB " => Some(3),
b"CMYK" => Some(4),
+12
View File
@@ -417,3 +417,15 @@ fn oversized_xmp_is_refused_while_decoding() {
assert_eq!(stream.decoded().unwrap().len(), xmp::MAX_XMP_BYTES + 1, "fixture must actually decompress");
assert!(metadata(&doc).is_none(), "compressed metadata must be limited during decoding");
}
#[test]
fn an_output_intent_profile_header_is_read_within_a_size_cap() {
let intent = "/OutputIntents [<< /S /GTS_PDFA1 /DestOutputProfile 2 0 R >>]";
for (size, n) in [(128, Some(4)), (MAX_ICC_PROFILE + 1, None)] {
let mut doc = build(&[&format!("<< /Type /Catalog {intent} >>"), "null"], "");
let mut profile = vec![0; size];
profile[16..20].copy_from_slice(b"CMYK");
doc.set(pdfcraft_cos::ObjRef::new(2, 0), Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &profile)));
assert_eq!(pdfa_intent_profile(&doc), Some(n), "a {size}-byte profile");
}
}
+406 -27
View File
@@ -38,6 +38,31 @@ const MAX_XFA_CONFIG_BYTES: usize = 16 << 20;
const MAX_REMOTE_FILE_SPEC_BYTES: usize = 4096;
const MAX_REMOTE_DESTINATION_NAME_BYTES: usize = 1024;
/// Text and geometry `DocInfo::annotations` keeps, as counted by [`retained`]. Past it the rest
/// are not listed: real comments stay far below, many small ones sharing one long string do not.
const MAX_ANNOTATION_BYTES: usize = 64 << 20;
/// Web-address text the link listing, and name, description and embedded-file key text the
/// attachment listing, keep together. Many links or attachments can share one long string, which
/// each would otherwise copy. Past it, a link to a web address lists as a URI action without its
/// address, an attachment under its generic name without a description, and an embedded file whose
/// key does not fit is not listed.
const MAX_LINK_ATTACHMENT_TEXT_BYTES: usize = 16 << 20;
/// The bytes a listed annotation keeps.
fn retained(a: &Annotation) -> usize {
let text = [&a.author, &a.contents, &a.modified, &a.name, &a.in_reply_to, &a.state, &a.intent];
text.into_iter()
.map(|t| t.as_ref().map_or(0, String::len))
.chain([
a.subtype.len(),
std::mem::size_of_val(a.quads.as_slice()),
std::mem::size_of_val(a.points.as_slice()),
std::mem::size_of::<Annotation>(),
])
.fold(0, usize::saturating_add)
}
/// At most this many layers are read from all of `/RBGroups` together.
const MAX_LAYER_GROUP_ENTRIES: usize = 4096;
@@ -676,7 +701,8 @@ impl<'a> Inspector<'a> {
if let Some(first) = catalog.get(b"Outlines").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(b"First").ok()) {
(info.outline, info.outline_more) = self.outline_window(first);
}
self.annotations(info);
let mut text_budget = MAX_LINK_ATTACHMENT_TEXT_BYTES;
self.annotations(info, &mut text_budget);
self.transitions(info);
let mut field_seen = HashSet::new();
if let Some(form) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o))
@@ -720,8 +746,10 @@ impl<'a> Inspector<'a> {
if let Some(names) = catalog.get(b"Names").ok().and_then(|o| self.dict(o)) {
if let Some(ef) = names.get(b"EmbeddedFiles").ok().and_then(|o| self.dict(o)) {
let mut seen = HashSet::new();
for (name, spec) in self.name_tree(ef, &mut seen, 0) {
info.attachments.push(self.attachment(name.clone(), spec, AttachmentSource::Document { key: name }));
for (name, spec) in self.name_tree(ef, &mut seen, 0, &mut text_budget) {
// The key is kept a second time when it is also the name.
let fallback = |budget: &mut usize| spend(name.as_bytes(), budget, |_| name.clone());
info.attachments.push(self.attachment(spec, AttachmentSource::Document { key: name.clone() }, &mut text_budget, fallback));
}
}
info.has_javascript |= names.get(b"JavaScript").is_ok();
@@ -809,6 +837,12 @@ impl<'a> Inspector<'a> {
self.doc.dereference(o).map(|(_, o)| o).unwrap_or(o)
}
/// The object a reference ends at after following any chain of references; `None` for a
/// direct object or a broken reference.
fn resolved_id(&self, o: &Object) -> Option<ObjectId> {
self.doc.dereference(o).ok().and_then(|(id, _)| id)
}
fn dict(&self, o: &'a Object) -> Option<&'a Dictionary> {
match self.resolve(o) {
Object::Dictionary(d) => Some(d),
@@ -818,16 +852,19 @@ impl<'a> Inspector<'a> {
}
fn text(&self, d: &Dictionary, key: &[u8]) -> Option<String> {
let o = self.resolve(d.get(key).ok()?);
let s = match o {
Object::String(bytes, _) => text_string(bytes),
Object::Name(n) => String::from_utf8_lossy(n).into_owned(),
_ => return None,
};
let s = s.trim_matches('\0').trim().to_string();
let s = trimmed_text(self.resolve(d.get(key).ok()?))?;
(!s.is_empty()).then_some(s)
}
/// [`Self::text`], converted and spent from `budget` as [`spend`] does, so text that trims
/// short or to nothing still spends its raw length.
fn budgeted_text(&self, d: &Dictionary, key: &[u8], budget: &mut usize) -> Option<String> {
let o = self.resolve(d.get(key).ok()?);
let (Object::String(bytes, _) | Object::Name(bytes)) = o else { return None };
let text = spend(bytes, budget, |_| trimmed_text(o).unwrap_or_default())?;
(!text.is_empty()).then_some(text)
}
fn name(&self, d: &Dictionary, key: &[u8]) -> Option<String> {
self.resolve(d.get(key).ok()?).as_name().ok().map(|n| String::from_utf8_lossy(n).into_owned())
}
@@ -916,7 +953,15 @@ impl<'a> Inspector<'a> {
}
}
fn name_tree(&self, node: &'a Dictionary, seen: &mut HashSet<*const Dictionary>, depth: u32) -> Vec<(String, &'a Object)> {
/// The entries of an embedded-files name tree. Each string key is spent from `text_budget`
/// (see [`MAX_LINK_ATTACHMENT_TEXT_BYTES`]), and an entry whose key does not fit is left out.
fn name_tree(
&self,
node: &'a Dictionary,
seen: &mut HashSet<*const Dictionary>,
depth: u32,
text_budget: &mut usize,
) -> Vec<(String, &'a Object)> {
let mut out = Vec::new();
if depth > 32 || !seen.insert(node as *const _) {
return out;
@@ -925,7 +970,10 @@ impl<'a> Inspector<'a> {
for pair in pairs.chunks(2) {
if let [k, v] = pair {
let key = match self.resolve(k) {
Object::String(bytes, _) => text_string(bytes),
Object::String(bytes, _) => match spend(bytes, text_budget, text_string) {
Some(key) => key,
None => continue,
},
_ => String::new(),
};
out.push((key, v));
@@ -935,13 +983,53 @@ impl<'a> Inspector<'a> {
if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) {
for k in kids {
if let Some(d) = self.dict(k) {
out.extend(self.name_tree(d, seen, depth + 1));
out.extend(self.name_tree(d, seen, depth + 1, text_budget));
}
}
}
out
}
/// The first entry of an embedded-files name tree whose key is `key`, in the order
/// [`Self::name_tree`] lists them, with no key budget. It keeps no key, and converts a key
/// object or `/Names` array that several places reference only once (a second visit cannot
/// match, since the first would have returned).
fn name_tree_find(&self, node: &'a Dictionary, key: &str, seen: &mut NameTreeSeen, depth: u32) -> Option<&'a Object> {
if depth > 32 || !seen.nodes.insert(node as *const _) {
return None;
}
let names = node.get(b"Names").ok();
if let Some(names) = names
&& self.resolved_id(names).is_none_or(|id| seen.objects.insert(id))
&& let Object::Array(pairs) = self.resolve(names)
{
for pair in pairs.chunks(2) {
if let [k, v] = pair {
if self.resolved_id(k).is_some_and(|id| !seen.objects.insert(id)) {
continue;
}
let found = match self.resolve(k) {
Object::String(bytes, _) => text_string(bytes) == key,
_ => key.is_empty(),
};
if found {
return Some(v);
}
}
}
}
if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) {
for k in kids {
if let Some(d) = self.dict(k)
&& let Some(v) = self.name_tree_find(d, key, seen, depth + 1)
{
return Some(v);
}
}
}
None
}
// ── outline ─────────────────────────────────────────────────────────────────────────────
/// The window `DocInfo::outline` keeps: the first `OUTLINE_WINDOW` bookmarks in outline order,
@@ -1141,21 +1229,44 @@ impl<'a> Inspector<'a> {
// ── annotations ─────────────────────────────────────────────────────────────────────────
fn annotations(&self, info: &mut DocInfo) {
for (&id, &page) in &self.page_index {
/// `text_budget` is what is left of [`MAX_LINK_ATTACHMENT_TEXT_BYTES`].
fn annotations(&self, info: &mut DocInfo, text_budget: &mut usize) {
let mut pages: Vec<(ObjectId, usize)> = self.page_index.iter().map(|(&id, &page)| (id, page)).collect();
pages.sort_unstable_by_key(|&(_, page)| page);
// An annotation, or a whole `/Annots` array, that several entries or pages list is on the
// first of them (§12.5.2 allows one page per annotation). Both are told apart by the object
// a reference resolves to, since an indirect object may itself hold a reference.
let mut seen_arrays = HashSet::new();
let mut seen_annotations = HashSet::new();
let mut kept = 0usize;
// Once the comment budget is spent, comments are no longer built, but links and attachments
// are still listed.
let mut comments_full = false;
for (id, page) in pages {
let Ok(page_dict) = self.doc.get_dictionary(id) else { continue };
if let Ok(annots) = page_dict.get(b"Annots")
&& let Some(r) = self.resolved_id(annots)
&& !seen_arrays.insert(r)
{
continue;
}
let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue };
for (index, a) in annots.iter().enumerate() {
if let Some(r) = self.resolved_id(a)
&& !seen_annotations.insert(r)
{
continue;
}
let Some(d) = self.dict(a) else { continue };
let Some(subtype) = self.name(d, b"Subtype") else { continue };
if subtype == "FileAttachment"
&& let Ok(fs) = d.get(b"FS")
{
let name = self.text(d, b"Contents").unwrap_or_else(|| "attachment".into());
info.attachments.push(self.attachment(name, fs, AttachmentSource::Annotation { page, index }));
let fallback = |budget: &mut usize| self.budgeted_text(d, b"Contents", budget);
info.attachments.push(self.attachment(fs, AttachmentSource::Annotation { page, index }, text_budget, fallback));
}
if subtype == "Link" {
if let Some(link) = self.link(page, index, d) {
if let Some(link) = self.link(page, index, d, text_budget) {
if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") {
info.has_javascript = true;
}
@@ -1170,6 +1281,9 @@ impl<'a> Inspector<'a> {
{
continue;
}
if comments_full {
continue;
}
let rect = rect4(self.resolve(d.get(b"Rect").unwrap_or(&Object::Null)));
let mut color = match d.get(b"C").map(|o| self.resolve(o)) {
Ok(Object::Array(c)) if c.len() == 3 => {
@@ -1209,7 +1323,7 @@ impl<'a> Inspector<'a> {
}
_ => Vec::new(),
};
info.annotations.push(Annotation {
let annotation = Annotation {
page,
subtype,
author: self.text(d, b"T"),
@@ -1228,13 +1342,19 @@ impl<'a> Inspector<'a> {
intent: self.name(d, b"IT"),
fill_sign,
points,
});
};
kept = kept.saturating_add(retained(&annotation));
if kept > MAX_ANNOTATION_BYTES {
comments_full = true;
continue;
}
info.annotations.push(annotation);
}
}
info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3])));
}
fn link(&self, page: usize, index: usize, d: &Dictionary) -> Option<Link> {
fn link(&self, page: usize, index: usize, d: &Dictionary, text_budget: &mut usize) -> Option<Link> {
let rect = rect4(self.resolve(d.get(b"Rect").ok()?));
let target = if let Ok(dest) = d.get(b"Dest") {
let (page, view) = self.dest(dest, 0)?;
@@ -1247,7 +1367,11 @@ impl<'a> Inspector<'a> {
LinkTarget::Page(page, view)
}
Some("URI") => {
LinkTarget::Uri(a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok()).map(|b| String::from_utf8_lossy(b).into_owned())?)
let uri = a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok())?;
match spend(uri, text_budget, |b| String::from_utf8_lossy(b).into_owned()) {
Some(uri) => LinkTarget::Uri(uri),
None => LinkTarget::Other("URI".into()),
}
}
Some("SetOCGState") => self.layer_state(a),
Some("GoToR") => self.remote_go_to(a).unwrap_or_else(|| LinkTarget::Other("GoToR".into())),
@@ -1578,7 +1702,14 @@ impl<'a> Inspector<'a> {
out.sort_by(|a, b| a.name.cmp(&b.name));
}
fn attachment(&self, name: String, spec: &Object, source: AttachmentSource) -> Attachment {
/// The attachment's name is the file specification's, else `fallback`'s, else a generic one.
fn attachment(
&self,
spec: &Object,
source: AttachmentSource,
text_budget: &mut usize,
fallback: impl FnOnce(&mut usize) -> Option<String>,
) -> Attachment {
let d = self.dict(spec);
let size =
d.and_then(|d| d.get(b"EF").ok()).and_then(|o| self.dict(o)).and_then(|ef| ef.get(b"F").ok().or_else(|| ef.get(b"UF").ok())).and_then(
@@ -1595,8 +1726,11 @@ impl<'a> Inspector<'a> {
_ => None,
},
);
let display = d.and_then(|d| self.text(d, b"UF").or_else(|| self.text(d, b"F"))).unwrap_or(name);
Attachment { name: display, description: d.and_then(|d| self.text(d, b"Desc")), size, source }
let display = d
.and_then(|d| self.budgeted_text(d, b"UF", text_budget).or_else(|| self.budgeted_text(d, b"F", text_budget)))
.or_else(|| fallback(text_budget))
.unwrap_or_else(|| "attachment".into());
Attachment { name: display, description: d.and_then(|d| self.budgeted_text(d, b"Desc", text_budget)), size, source }
}
}
@@ -1606,6 +1740,42 @@ fn text_string(bytes: &[u8]) -> String {
pdfcraft_cos::PdfString::literal(bytes).to_text()
}
/// What [`Inspector::name_tree_find`] has visited: node dictionaries, and the key and `/Names`
/// array objects it reached by reference.
#[derive(Default)]
struct NameTreeSeen {
nodes: HashSet<*const Dictionary>,
objects: HashSet<ObjectId>,
}
/// A string or name value as text, without surrounding NULs and white space.
fn trimmed_text(o: &Object) -> Option<String> {
let s = match o {
Object::String(bytes, _) => text_string(bytes),
Object::Name(n) => String::from_utf8_lossy(n).into_owned(),
_ => return None,
};
Some(s.trim_matches('\0').trim().to_string())
}
/// `convert(bytes)` when the result fits in `budget`, which it then spends by the longer of the raw
/// and converted lengths. Bytes longer than the budget are not converted. A conversion that comes
/// out too long spends the raw length, so a string many entries share is converted only until the
/// budget runs out.
fn spend(bytes: &[u8], budget: &mut usize, convert: impl FnOnce(&[u8]) -> String) -> Option<String> {
if bytes.len() > *budget {
return None;
}
let text = convert(bytes);
if text.len() > *budget {
*budget = budget.saturating_sub(bytes.len());
return None;
}
// The conversion's work follows the raw length, even when the text it keeps is shorter.
*budget = budget.saturating_sub(text.len().max(bytes.len()));
Some(text)
}
fn limited_pdf_string(bytes: &[u8], max_bytes: usize) -> Option<String> {
if bytes.len() > max_bytes {
return None;
@@ -1678,8 +1848,7 @@ pub fn attachment_data(bytes: &[u8], password: Option<&str>, att: &Attachment) -
AttachmentSource::Document { key } => {
let names = doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| insp.dict(o)).ok_or("no /Names")?;
let ef = names.get(b"EmbeddedFiles").ok().and_then(|o| insp.dict(o)).ok_or("no /EmbeddedFiles")?;
let mut seen = HashSet::new();
insp.name_tree(ef, &mut seen, 0).into_iter().find(|(k, _)| k == key).map(|(_, v)| v).ok_or("attachment not found")?
insp.name_tree_find(ef, key, &mut NameTreeSeen::default(), 0).ok_or("attachment not found")?
}
AttachmentSource::Annotation { page, index } => {
let (&pid, _) = insp.page_index.iter().find(|(_, i)| **i == *page).ok_or("page not found")?;
@@ -2583,6 +2752,216 @@ trailer << /Root 1 0 R >>
}
}
/// One comment referenced many times, or a page-shared `/Annots` array, is listed once, and many
/// small comments sharing one large `/Contents` string stop at the retained-bytes budget.
#[test]
fn repeated_annotations_are_listed_once_within_a_byte_budget() {
let small = "<< /Subtype /Text /Rect [0 0 1 1] /Contents 7 0 R >> ".repeat(1200);
let pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 5 0 R >> endobj\n\
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 5 0 R >> endobj\n\
5 0 obj [{} {small}] endobj\n\
6 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (note) >> endobj\n\
7 0 obj ({}) endobj\ntrailer << /Root 1 0 R >>\n%%EOF",
"6 0 R ".repeat(1000),
"x".repeat(64 << 10),
);
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
let mut info = DocInfo { pages: vec![page.clone(), page], ..Default::default() };
Inspector::new(&doc).fill(&mut info);
let notes = &info.annotations;
assert_eq!(notes.iter().filter(|a| a.contents.as_deref() == Some("note")).count(), 1);
assert!(notes.iter().all(|a| a.page == 0), "the shared array is the first page's");
assert!(notes.len() > 1);
assert!(notes.iter().map(retained).sum::<usize>() <= MAX_ANNOTATION_BYTES);
}
/// A link reached through an indirect object that holds only a reference to it is the same
/// annotation as the link itself, and so is a page array reached the same way: each is listed once.
#[test]
fn an_annotation_reached_through_a_reference_chain_is_listed_once() {
let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >> endobj\n\
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 8 0 R >> endobj\n\
5 0 obj 6 0 R endobj\n\
6 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 1 1] /A << /S /URI /URI (https://example.com) >> >> endobj\n\
7 0 obj [5 0 R 6 0 R] endobj\n\
8 0 obj 7 0 R endobj\n\
trailer << /Root 1 0 R >>\n%%EOF";
let mut doc = Document::load_mem(pdf.as_bytes()).expect("loads");
let mut saved = Vec::new();
doc.save_to(&mut saved).expect("saves");
let lazy = LazyStructure::new(Arc::new(saved), None).expect("opens");
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
for reader in [&doc as &dyn Structure, &lazy] {
let mut info = DocInfo { pages: vec![page.clone(), page.clone()], ..Default::default() };
Inspector::new(reader).fill(&mut info);
assert_eq!(info.links.len(), 1, "{:?}", info.links);
}
}
/// A conversion rejected as too long, or trimmed short or to nothing, still spends the raw
/// length, so a string many entries share is converted only a few times.
#[test]
fn rejected_text_conversions_spend_the_budget() {
let mut budget = 10;
let mut conversions = 0;
for _ in 0..10 {
// Each 0xFF becomes the three-byte replacement character.
let kept = spend(&[0xFF; 5], &mut budget, |b| {
conversions += 1;
String::from_utf8_lossy(b).into_owned()
});
assert_eq!(kept, None);
}
assert_eq!((conversions, budget), (2, 0));
let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\n\
trailer << /Root 1 0 R >>\n%%EOF";
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
let d = Dictionary::from_iter([("Contents", Object::string_literal(" "))]);
let mut budget = 10;
assert_eq!(Inspector::new(&doc).budgeted_text(&d, b"Contents", &mut budget), None);
assert_eq!(budget, 7);
// Text that trims short spends its raw length.
let d = Dictionary::from_iter([("Contents", Object::string_literal(" x"))]);
let mut budget = 10;
assert_eq!(Inspector::new(&doc).budgeted_text(&d, b"Contents", &mut budget).as_deref(), Some("x"));
assert_eq!(budget, 3);
}
/// An annotation attachment's `/Contents` spends the text budget only when it names the
/// attachment, so long descriptions of named files leave a later web address listed.
#[test]
fn a_named_attachment_leaves_its_contents_unspent() {
let files = "<< /Subtype /FileAttachment /Rect [0 0 1 1] /Contents 6 0 R /FS << /F (a.txt) >> >> ".repeat(20);
// Longer than what 20 MiB of charged `/Contents` would leave of the 16 MiB budget.
let uri = format!("https://example.com/{}", "a".repeat(2 << 20));
let pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{files} \
<< /Subtype /Link /Rect [0 0 1 1] /A << /S /URI /URI ({uri}) >> >>] >> endobj\n\
6 0 obj ({}) endobj\n\
trailer << /Root 1 0 R >>\n%%EOF",
"x".repeat(1 << 20),
);
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
let mut info = DocInfo { pages: vec![page], ..Default::default() };
Inspector::new(&doc).fill(&mut info);
assert!(info.attachments.iter().all(|a| a.name == "a.txt"));
assert_eq!(info.links.len(), 1);
assert!(matches!(&info.links[0].target, LinkTarget::Uri(u) if *u == uri));
}
/// Embedded files whose name-tree keys share one long string keep it, as key and as name, only
/// within `MAX_LINK_ATTACHMENT_TEXT_BYTES`.
#[test]
fn embedded_file_keys_sharing_a_long_string_keep_it_within_a_budget() {
let entries = "4 0 R << /Type /Filespec >> ".repeat(20);
let pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [{entries}] >> >> >> endobj\n\
2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\n\
4 0 obj ({}) endobj\n\
trailer << /Root 1 0 R >>\n%%EOF",
"x".repeat(1 << 20),
);
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
let mut info = DocInfo::default();
Inspector::new(&doc).fill(&mut info);
let kept: usize =
info.attachments.iter().map(|a| a.name.len() + if let AttachmentSource::Document { key } = &a.source { key.len() } else { 0 }).sum();
assert!(!info.attachments.is_empty());
// Past the budget an attachment still has the generic name.
let generic = info.attachments.len() * "attachment".len();
assert!(kept <= MAX_LINK_ATTACHMENT_TEXT_BYTES + generic, "{kept} bytes kept");
}
/// An embedded file the listing keeps is extracted, though earlier keys the listing could not
/// keep would fill a fresh key budget before it.
#[test]
fn a_listed_embedded_file_is_extracted_past_keys_the_listing_left_out() {
let skipped = "6 0 R 7 0 R ".repeat(9);
let pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [{skipped}(b.txt) 9 0 R] >> >> >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [\
<< /Subtype /Link /Rect [0 0 1 1] /A << /S /URI /URI ({}) >> >>] >> endobj\n\
6 0 obj ({}) endobj\n\
7 0 obj << /Type /Filespec /F (a.txt) >> endobj\n\
8 0 obj << /Type /EmbeddedFile /Length 5 >> stream\nhello\nendstream endobj\n\
9 0 obj << /Type /Filespec /F (b.txt) /EF << /F 8 0 R >> >> endobj\n\
trailer << /Root 1 0 R >>\n%%EOF",
"u".repeat(15 << 20),
"k".repeat(2 << 20),
);
let bytes = Arc::new(pdf.into_bytes());
let info = inspect(bytes.clone(), None).expect("opens");
let listed = info.attachments.iter().find(|a| a.name == "b.txt").expect("b.txt is listed");
assert_eq!(attachment_data(&bytes, None, listed).as_deref(), Ok(&b"hello"[..]));
}
/// Links and attachments sharing one long string each keep a copy only within
/// `MAX_LINK_ATTACHMENT_TEXT_BYTES`, and every one is still listed.
#[test]
fn links_and_attachments_sharing_a_long_string_keep_it_within_a_budget() {
const EACH: usize = 20;
let links = "<< /Subtype /Link /Rect [0 0 1 1] /A 5 0 R >> ".repeat(EACH);
let files = "<< /Subtype /FileAttachment /Rect [0 0 1 1] /FS 7 0 R >> ".repeat(EACH);
let pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{links} {files}] >> endobj\n\
5 0 obj << /S /URI /URI 6 0 R >> endobj\n\
6 0 obj ({}) endobj\n\
7 0 obj << /Type /Filespec /F (a.txt) /Desc 6 0 R >> endobj\n\
trailer << /Root 1 0 R >>\n%%EOF",
"x".repeat(1 << 20),
);
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
let mut info = DocInfo { pages: vec![page], ..Default::default() };
Inspector::new(&doc).fill(&mut info);
assert_eq!(info.links.len(), EACH);
assert_eq!(info.attachments.len(), EACH);
let uris = info.links.iter().map(|l| if let LinkTarget::Uri(u) = &l.target { u.len() } else { 0 });
let descriptions = info.attachments.iter().map(|a| a.description.as_ref().map_or(0, String::len));
let kept: usize = uris.chain(descriptions).sum();
assert!(kept > 0 && kept <= MAX_LINK_ATTACHMENT_TEXT_BYTES, "{kept} bytes kept");
assert!(info.links.iter().any(|l| matches!(&l.target, LinkTarget::Other(s) if s == "URI")));
}
/// Links and attachments after the comment budget fills are still listed, and a JavaScript link
/// among them still marks the document.
#[test]
fn links_and_attachments_past_the_comment_budget_are_listed() {
let small = "<< /Subtype /Text /Rect [0 0 1 1] /Contents 6 0 R >> ".repeat(1100);
let pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{small} \
<< /Subtype /Link /Rect [0 0 1 1] /A << /S /JavaScript /JS (1) >> >>] >> endobj\n\
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [\
<< /Subtype /FileAttachment /Rect [0 0 1 1] /FS << /Type /Filespec /F (a.txt) >> >>] >> endobj\n\
6 0 obj ({}) endobj\ntrailer << /Root 1 0 R >>\n%%EOF",
"x".repeat(64 << 10),
);
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
let mut info = DocInfo { pages: vec![page.clone(), page], ..Default::default() };
Inspector::new(&doc).fill(&mut info);
assert!(info.annotations.len() < 1100, "the comment budget filled");
assert_eq!(info.links.len(), 1);
assert!(info.has_javascript);
assert_eq!(info.attachments.len(), 1);
}
/// Widgets without `/P` are placed on the page that lists them with one pass over the pages'
/// `/Annots`, not a search of every page per field: that was O(fields × annotations), a
/// second to open a form of 8,000 fields. A widget two pages list is on the first of them.
+5
View File
@@ -114,6 +114,11 @@ impl Structure for lopdf::Document {
fn get_object(&self, id: ObjectId) -> Result<&Object> {
self.get_object(id)
}
// lopdf's `get_object` already follows an indirect object that holds a reference, so the
// trait's walk would report the first object of a chain. lopdf's own walk reports the last.
fn dereference<'a>(&'a self, object: &'a Object) -> Result<(Option<ObjectId>, &'a Object)> {
lopdf::Document::dereference(self, object)
}
fn encrypted(&self) -> bool {
self.is_encrypted() || self.was_encrypted() || self.trailer.has(b"Encrypt")
}
+101 -23
View File
@@ -892,7 +892,8 @@ fn finish_validation(
// Revocation evidence embedded in the document security store, if any. A verified
// revocation is final: later blocks must not soften the verdict.
let signer = info.chain.first().cloned();
let revoked = check_dss_revocation(doc, signer.as_ref(), &at, info);
let revocation = check_dss_revocation(doc, signer.as_ref(), &at, info);
let revoked = revocation == DssRevocation::Revoked;
// Changes after signing.
info.modification = if covered == bytes.len() || bytes[covered..].iter().all(|b| b.is_ascii_whitespace() || *b == 0) {
Modification::None
@@ -929,6 +930,12 @@ fn finish_validation(
problems = true;
}
}
if revocation == DssRevocation::Incomplete {
if !problems {
info.status = Status::Unknown;
}
problems = true;
}
if let Some(t) = info.timestamp_time.or(info.signing_time)
&& !cert.valid_at(t)
{
@@ -975,27 +982,86 @@ fn finish_validation(
}
}
/// Distinct evidence streams read from one `/DSS` array.
const MAX_DSS_ENTRIES: usize = 1024;
/// Decoded bytes read from one `/DSS` array; real CRLs and OCSP responses are kilobytes each.
const MAX_DSS_BYTES: usize = 64 << 20;
/// The decoded streams of the `/DSS` evidence array `key` (`/CRLs`, `/OCSPs`), one at a time so
/// each is checked before the next decodes: each stream once and within the evidence size cap,
/// at most [`MAX_DSS_ENTRIES`] distinct ones and [`MAX_DSS_BYTES`] together. A stream past a cap
/// sets [`DssEvidence::cut`] and ends the iteration.
struct DssEvidence<'a> {
doc: &'a Document,
items: std::vec::IntoIter<Object>,
seen: HashSet<ObjRef>,
taken: usize,
left: usize,
/// A cap stopped the array being read in full.
cut: bool,
}
impl<'a> DssEvidence<'a> {
fn new(doc: &'a Document, dss: &Dict, key: &[u8]) -> Self {
let items = dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default();
DssEvidence { doc, items: items.into_iter(), seen: HashSet::new(), taken: 0, left: MAX_DSS_BYTES, cut: false }
}
}
impl Iterator for DssEvidence<'_> {
type Item = Vec<u8>;
fn next(&mut self) -> Option<Vec<u8>> {
if self.cut {
return None;
}
// Repeats are skipped before the caps count anything, so repeating one stream cannot use
// them up; the scan of the array itself is bounded by the parser's cap on array length.
for item in self.items.by_ref() {
if item.as_ref().is_some_and(|r| !self.seen.insert(r)) {
continue;
}
let o = self.doc.resolve(&item);
let Object::Stream(s) = o.as_ref() else { continue };
if self.taken >= MAX_DSS_ENTRIES {
self.cut = true;
return None;
}
self.taken += 1;
// A stream past the evidence cap would not parse as evidence either.
let Ok(bytes) = s.decoded_within(crate::revocation::MAX_EVIDENCE) else { continue };
let Some(left) = self.left.checked_sub(bytes.len()) else {
self.cut = true;
return None;
};
self.left = left;
return Some(bytes);
}
None
}
}
/// What the catalog's `/DSS` says about the signer's certificate.
#[derive(Clone, Copy, Debug, PartialEq)]
enum DssRevocation {
/// Verified evidence revokes it.
Revoked,
/// A cap stopped the evidence being read in full, and what was read does not revoke it.
Incomplete,
/// All of the evidence was read, and none of it revokes it.
NotRevoked,
}
/// Check the revocation evidence in the catalog's `/DSS` against the signer's chain at `at`.
/// Only evidence whose signature verifies against the issuer counts; returns whether the
/// certificate is revoked (which invalidates the signature).
fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Option<Time>, info: &mut SignatureInfo) -> bool {
/// certificate is revoked (which invalidates the signature), or whether a cap kept the check from
/// reading all of the evidence.
fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Option<Time>, info: &mut SignatureInfo) -> DssRevocation {
use crate::revocation::{CertificateList, OcspResponse, RevocationStatus};
let (Some(signer), Some(at)) = (signer, *at) else { return false };
let Some(root) = doc.root() else { return false };
let (Some(signer), Some(at)) = (signer, *at) else { return DssRevocation::NotRevoked };
let Some(root) = doc.root() else { return DssRevocation::NotRevoked };
let Some(dss) = doc.get(root).as_dict().and_then(|c| c.get(b"DSS").cloned()).map(|d| doc.resolve(&d)).and_then(|d| d.as_dict().cloned()) else {
return false;
};
let pull = |key: &[u8]| -> Vec<Vec<u8>> {
let mut out = Vec::new();
for item in dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() {
let o = doc.resolve(&item);
if let pdfcraft_cos::Object::Stream(s) = &*o
&& let Ok(bytes) = s.decoded()
{
out.push(bytes);
}
}
out
return DssRevocation::NotRevoked;
};
let issuer = info.chain.get(1).unwrap_or(signer);
// A verified revocation wins; "good" from any covering evidence is reported, but never
@@ -1003,7 +1069,8 @@ fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Optio
let mut revoked = None;
let mut covered = false;
let mut sources = Vec::new();
for r in pull(b"CRLs") {
let mut crls = DssEvidence::new(doc, &dss, b"CRLs");
for r in crls.by_ref() {
let Ok(list) = CertificateList::parse(&r) else { continue };
sources.push("CRL");
match list.check(signer, issuer, at) {
@@ -1015,8 +1082,10 @@ fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Optio
RevocationStatus::Unknown => {}
}
}
let mut cut = crls.cut;
if revoked.is_none() {
for r in pull(b"OCSPs") {
let mut ocsps = DssEvidence::new(doc, &dss, b"OCSPs");
for r in ocsps.by_ref() {
let Ok(resp) = OcspResponse::parse(&r) else { continue };
sources.push("OCSP");
match resp.check(signer, issuer, at) {
@@ -1028,6 +1097,7 @@ fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Optio
RevocationStatus::Unknown => {}
}
}
cut |= ocsps.cut;
}
match revoked {
Some(at) => {
@@ -1035,14 +1105,22 @@ fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Optio
// The evidence and the date go in together: one hole, so a reader can put the
// sentence's parts in whatever order its language wants.
info.details.push(Detail::of("The signer's certificate has been revoked ({}).", Fill::Value(format!("{}, {at}", sources.join(", ")))));
true
DssRevocation::Revoked
}
// Evidence past a cap may revoke the certificate, so a partial check proves nothing.
None if cut => {
info.details.push(Detail::of(
"The signature could not be checked ({}).",
Fill::Reason("the /DSS holds more revocation evidence than PdfCraft reads".into()),
));
DssRevocation::Incomplete
}
None if covered => {
info.details
.push(Detail::plain("The document's embedded revocation information shows that the signer's certificate has not been revoked."));
false
DssRevocation::NotRevoked
}
None => false,
None => DssRevocation::NotRevoked,
}
}
+26
View File
@@ -371,3 +371,29 @@ fn only_grew_compares_huge_arrays_without_quadratic_work() {
assert!(!only_grew(&doc, &doc, &ints(20_000), &ints(20_001), 0));
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
}
#[test]
fn dss_evidence_decodes_each_stream_once_within_its_caps() {
// One small stream listed 1000 times, then streams that inflate past the evidence cap.
let mut doc = fixture(&[]);
let small = doc.add(Object::Stream(Stream::flate(Dict::new(), b"evidence")));
let mut items = vec![Object::Ref(small); 1000];
for _ in 0..4 {
items.push(Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &vec![0; crate::revocation::MAX_EVIDENCE + 1])))));
}
let mut dss = Dict::new();
dss.set(b"CRLs".to_vec(), Object::Array(items));
let mut crls = DssEvidence::new(&doc, &dss, b"CRLs");
let read: Vec<Vec<u8>> = crls.by_ref().collect();
assert_eq!(read, vec![b"evidence".to_vec()]);
assert!(!crls.cut, "repeats and oversized streams are not a cap");
// Distinct streams stop at the entry and byte budgets, and say so.
let items: Vec<Object> =
(0..MAX_DSS_ENTRIES + 10).map(|_| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &[1; 1 << 16]))))).collect();
dss.set(b"OCSPs".to_vec(), Object::Array(items));
let mut ocsps = DssEvidence::new(&doc, &dss, b"OCSPs");
let total: usize = ocsps.by_ref().map(|b| b.len()).sum();
assert!(total <= MAX_DSS_BYTES, "{total}");
assert!(ocsps.cut);
assert!(DssEvidence::new(&doc, &dss, b"OCSPs").count() <= MAX_DSS_ENTRIES);
}
+1 -1
View File
@@ -8,7 +8,7 @@ use crate::x509::Certificate;
use crate::{SignError, Time};
/// A DER size cap: real CRLs and OCSP responses are far smaller.
const MAX_EVIDENCE: usize = 2 * 1024 * 1024;
pub(crate) const MAX_EVIDENCE: usize = 2 * 1024 * 1024;
const ID_PKIX_OCSP_BASIC: &str = "1.3.6.1.5.5.7.48.1.1";
const EKU_OCSP_SIGNING: &str = "1.3.6.1.5.5.7.3.9";
+31
View File
@@ -1546,6 +1546,37 @@ fn a_revoked_certificate_stays_invalid_when_it_was_also_expired_at_signing() {
}
}
#[test]
fn a_revocation_behind_many_dss_entries_is_not_reported_valid() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
let t = |year| Time { year, month: 1, day: 1, hour: 0, minute: 0, second: 0 };
let crl = self_crl(&id, t(2026), t(2027), Time { year: 2026, month: 6, day: 1, hour: 8, minute: 0, second: 0 });
let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone());
let trust = TrustStore { certs: vec![anchor] };
// `/CRLs`: the revoking CRL after `benign` other entries; `repeated` lists one stream for all of them.
let status = |benign: usize, repeated: bool| {
let ltv = edit_after(&signed, |doc| {
let one = doc.add(stream_of(b"benign"));
let mut crls: Vec<Object> = (0..benign)
.map(|k| if repeated { Object::Ref(one) } else { Object::Ref(doc.add(stream_of(format!("benign {k}").as_bytes()))) })
.collect();
crls.push(Object::Ref(doc.add(stream_of(&crl))));
set_dss(doc, Object::Dict(dict_of(vec![("Type", Object::name("DSS")), ("CRLs", Object::Array(crls))])));
});
let s = signatures(&open(&ltv), &ltv, &trust).into_iter().find(|s| s.signed && !s.doc_timestamp).unwrap();
(s.status, s.details)
};
let (found, details) = status(1, false);
assert_eq!(found, Status::Invalid, "{details:?}");
// Repeats of one stream don't use up the entry cap, so the revocation is still found.
let (found, details) = status(2000, true);
assert_eq!(found, Status::Invalid, "{details:?}");
// Past the cap of distinct entries the check is incomplete, so the signature is not valid.
let (found, details) = status(1100, false);
assert_eq!(found, Status::Unknown, "{details:?}");
}
#[test]
fn an_untrusted_timestamp_does_not_set_the_validation_time() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
+7
View File
@@ -769,6 +769,9 @@ impl Layouter<'_> {
self.warn_truncated(node, &ctx.som, instances);
let ctx_parent = ctx;
for i in 0..instances {
// Every instance spends budget, so nested repeats that place nothing still stop.
self.measures.set(self.measures.get().saturating_add(1));
self.check_measures()?;
let ctx = ctx_parent.with_som(&sf.common, sib + i);
let (rect, _, _) = self.cursor();
let (x, w) = avail.unwrap_or((rect.x, rect.w));
@@ -1315,6 +1318,10 @@ impl Layouter<'_> {
Ok(())
}
Node::Subform(s) => {
// Every drawn instance spends budget, as a flowed one does, so nested repeats that
// place nothing still stop.
self.measures.set(self.measures.get().saturating_add(1));
self.check_measures()?;
if let Some(b) = &s.common.border {
self.draw_border(b, rect)?;
}
+6
View File
@@ -113,7 +113,13 @@ pub fn read_packets(doc: &Document) -> Result<Option<Packets>, XfaError> {
let needs_rendering = bool_of(catalog.as_dict().and_then(|c| c.get(b"NeedsRendering"))).or_else(|| bool_of(acro.get(b"NeedsRendering")));
let has_fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().map(|a| !a.is_empty())).unwrap_or(false);
let mut total = 0usize;
// A stream listed again adds nothing but another decode, which may cost far more than the
// bytes it yields (a Flate stream of hex whitespace).
let mut seen = std::collections::HashSet::new();
let mut read = |o: &Object| -> Result<Option<String>, XfaError> {
if o.as_ref().is_some_and(|r| !seen.insert(r)) {
return Ok(None);
}
let s = doc.resolve(o);
let Object::Stream(s) = &*s else { return Ok(None) };
let bytes = s.decoded_within(MAX_XDP)?;
+39
View File
@@ -404,6 +404,45 @@ fn repeated_pictures_are_bounded_in_total_bytes() {
assert!(layout_xml(&ok).is_ok(), "one small picture must lay out");
}
#[test]
fn empty_repeating_subforms_spend_the_layout_budget() {
// Four nested levels of 50 empty instances are 6.25 million instances that place nothing
// and measure nothing; each one counts against the layout budget.
let level = "<subform layout=\"tb\"><occur initial=\"50\" max=\"-1\"/>";
let tpl = format!("<template><subform layout=\"tb\">{}{}</subform></template>", level.repeat(4), "</subform>".repeat(4));
assert!(matches!(layout_xml(&tpl), Err(XfaError::TooLarge(_))));
// Three levels (125,000 instances) still lay out.
let tpl = format!("<template><subform layout=\"tb\">{}{}</subform></template>", level.repeat(3), "</subform>".repeat(3));
assert!(layout_xml(&tpl).is_ok());
}
#[test]
fn a_packet_stream_listed_many_times_is_read_once() {
let mut doc = Document::open(Arc::new(shell("<template><subform/></template>"))).unwrap();
let packet = pdfcraft_cos::ObjRef::new(5, 0);
let items: Vec<Object> =
(0..1000).flat_map(|_| [Object::String(pdfcraft_cos::PdfString::literal(b"template".to_vec())), Object::Ref(packet)]).collect();
let root = doc.root().unwrap();
doc.update_dict(root, |c| {
let mut acro = c.get(b"AcroForm").and_then(Object::as_dict).cloned().unwrap();
acro.set(b"XFA".to_vec(), Object::Array(items));
c.set(b"AcroForm".to_vec(), Object::Dict(acro));
})
.unwrap();
let p = read_packets(&doc).unwrap().unwrap();
assert_eq!(p.xdp.matches("<template").count(), 1);
}
#[test]
fn empty_repeating_subforms_drawn_in_a_positioned_root_spend_the_layout_budget() {
// The same four levels inside a positioned root are drawn, not flowed.
let level = "<subform layout=\"tb\"><occur initial=\"50\" max=\"-1\"/>";
let nest =
|n: usize| format!("<template><subform><subform layout=\"tb\">{}{}</subform></subform></template>", level.repeat(n), "</subform>".repeat(n));
assert!(matches!(layout_xml(&nest(4)), Err(XfaError::TooLarge(_))));
assert!(layout_xml(&nest(3)).is_ok());
}
#[test]
fn the_packets_can_be_one_stream_or_named_parts() {
// Named parts, with the template split from the rest.