mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 20:18:06 -06:00
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:
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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() }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(<v), <v, &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();
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user