PDF fonts and fax: bound charstring work, ToUnicode ranges and reference search

Type 1 and CFF charstrings bounded subroutine depth but not total work:
subroutines that call each other several times multiplied a small font into
unbounded instructions and geometry. Each glyph now has a shared budget of
100,000 instructions. The Type 1 Subrs parser looked up /CharStrings again
for every entry; it is found once. A ToUnicode bfrange expanded a long
destination string for each of up to 65,536 codes; destinations past 256
bytes are skipped and a CMap keeps at most 2^20 mappings. CCITT 2-D decoding
scanned the reference row from its start for every code; it bisects.
This commit is contained in:
Dylan M. Taylor
2026-11-03 11:02:26 +00:00
parent 7870b5916a
commit bf24186cd8
4 changed files with 114 additions and 7 deletions
+24 -2
View File
@@ -401,8 +401,10 @@ fn mode(r: &mut Reader) -> Option<Mode> {
/// (index parity: even indices start black runs), and the one after it.
fn b1b2(refl: &[usize], a0: i64, white: bool, columns: usize) -> (usize, usize) {
let want = if white { 0 } else { 1 };
let mut i = 0;
while i < refl.len() && (refl[i] as i64 <= a0 || i % 2 != want) {
// The changing elements are in order, so the first past `a0` is found by bisection (a scan
// from the start made every row quadratic in its transitions).
let mut i = refl.partition_point(|&c| c as i64 <= a0);
if i % 2 != want {
i += 1;
}
let b1 = refl.get(i).copied().unwrap_or(columns);
@@ -804,4 +806,24 @@ pub(crate) mod tests {
let _ = decode(&garbage, &Params { k, columns: 1, rows: 0, ..Params::default() }, 1 << 20);
}
}
/// `b1b2` by bisection agrees with the scan it replaced, for every position and colour
/// over a dense reference row.
#[test]
fn b1b2_by_bisection_matches_the_scan() {
let scan = |refl: &[usize], a0: i64, white: bool, columns: usize| {
let want = if white { 0 } else { 1 };
let mut i = 0;
while i < refl.len() && (refl[i] as i64 <= a0 || i % 2 != want) {
i += 1;
}
(refl.get(i).copied().unwrap_or(columns), refl.get(i + 1).copied().unwrap_or(columns))
};
let refl: Vec<usize> = (0..64).map(|i| i * 3 / 2).collect();
for a0 in -1..100 {
for white in [true, false] {
assert_eq!(b1b2(&refl, a0, white, 100), scan(&refl, a0, white, 100), "a0 {a0} white {white}");
}
}
}
}
+50 -3
View File
@@ -545,8 +545,21 @@ impl Cff {
let r = self.charstrings.get(gid as usize)?.clone();
let fd = self.fdselect.get(gid as usize).copied().unwrap_or(0) as usize;
let fd = fd.min(self.lsubrs.len() - 1);
let mut st =
T2 { cff: self, fd, path: BezPath::new(), x: 0.0, y: 0.0, stack: vec![], stems: 0, width: None, open: false, done: false, depth: 0, seac: None };
let mut st = T2 {
cff: self,
fd,
path: BezPath::new(),
x: 0.0,
y: 0.0,
stack: vec![],
stems: 0,
width: None,
open: false,
done: false,
depth: 0,
ops: MAX_GLYPH_OPS,
seac: None,
};
st.run(&self.data[r]);
if st.open {
st.path.close_path();
@@ -646,9 +659,16 @@ struct T2<'a> {
open: bool,
done: bool,
depth: usize,
/// Instructions this glyph may still run, across its subroutine calls.
ops: usize,
seac: Option<(f64, f64, u8, u8)>,
}
/// The most charstring instructions one glyph runs: subroutines that branch (two calls each,
/// ten levels deep) would otherwise multiply a small font into unbounded work and geometry.
/// A real glyph runs a few hundred.
pub(crate) const MAX_GLYPH_OPS: usize = 100_000;
impl T2<'_> {
fn move_to(&mut self, dx: f64, dy: f64) {
if self.open {
@@ -698,6 +718,11 @@ impl T2<'_> {
}
let mut i = 0;
while i < cs.len() && !self.done {
let Some(left) = self.ops.checked_sub(1) else {
self.done = true;
return;
};
self.ops = left;
let b0 = cs[i];
i += 1;
match b0 {
@@ -958,6 +983,12 @@ impl T2<'_> {
/// named by custom strings.
#[cfg(test)]
pub(crate) fn write_test_cff(glyphs: &[(&str, Vec<u8>)]) -> Vec<u8> {
write_test_cff_with_gsubrs(glyphs, &[])
}
/// [`write_test_cff`] with global subroutines.
#[cfg(test)]
pub(crate) fn write_test_cff_with_gsubrs(glyphs: &[(&str, Vec<u8>)], gsubrs: &[Vec<u8>]) -> Vec<u8> {
fn idx(items: &[Vec<u8>]) -> Vec<u8> {
let mut out = (items.len() as u16).to_be_bytes().to_vec();
if items.is_empty() {
@@ -983,7 +1014,7 @@ pub(crate) fn write_test_cff(glyphs: &[(&str, Vec<u8>)]) -> Vec<u8> {
let names = idx(&[b"Test".to_vec()]);
let strings: Vec<Vec<u8>> = glyphs.iter().skip(1).map(|(n, _)| n.as_bytes().to_vec()).collect();
let strings_idx = idx(&strings);
let gsubrs = idx(&[]);
let gsubrs = idx(gsubrs);
let cs = idx(&glyphs.iter().map(|(_, c)| c.clone()).collect::<Vec<_>>());
// charset format 0: SIDs of glyphs 1.. (custom strings from 391).
let mut charset = vec![0u8];
@@ -1090,6 +1121,22 @@ mod tests {
assert!(p.elements().is_empty());
}
/// A global subroutine that calls itself ten times: 10^10 calls within the depth limit of
/// ten. The glyph stops at its instruction budget instead.
#[test]
fn branching_subroutines_stop_at_the_instruction_budget() {
// `-107 callgsubr` (bias 107) is subroutine 0.
let call = [32u8, 29];
let mut subr = call.repeat(10);
subr.push(11);
let mut cs = call.to_vec();
cs.push(14);
let font = write_test_cff_with_gsubrs(&[(".notdef", vec![14]), ("A", cs)], &[subr]);
let cff = Cff::parse(&font).unwrap();
let (p, _) = cff.outline(cff.gid_by_name("A").unwrap()).unwrap();
assert!(p.elements().is_empty());
}
/// A CFF whose top DICT points its Private DICT at offsets near `usize::MAX`: `size` and `off`
/// come from the file, and reading the Private DICT must not overflow `off + size`.
#[test]
+10 -1
View File
@@ -211,6 +211,12 @@ fn utf16(b: &[u8]) -> String {
String::from_utf16_lossy(&u)
}
/// The most code → text mappings a CMap keeps (more than Unicode has code points), and the
/// longest destination string a `bfrange` is expanded with: a range of 65,536 codes would
/// otherwise multiply a long destination into gigabytes of text.
const MAX_UNICODE_MAPPINGS: usize = 1 << 20;
const MAX_RANGE_DST: usize = 256;
fn parse_cmap(data: &[u8]) -> CMap {
let mut m = CMap::default();
let mut lx = Lexer::new(data, 0);
@@ -252,9 +258,12 @@ fn parse_cmap(data: &[u8]) -> CMap {
continue;
}
match &p[2] {
Obj::Str(dst) => {
Obj::Str(dst) if dst.len() <= MAX_RANGE_DST => {
let mut d = dst.clone();
for c in lo..=hi {
if m.unicode.len() >= MAX_UNICODE_MAPPINGS {
break;
}
m.unicode.insert(c, utf16(&d));
if let Some(last) = d.last_mut() {
*last = last.wrapping_add(1);
+30 -1
View File
@@ -148,8 +148,9 @@ impl Type1 {
};
if let Some(p) = find(&priv_, b"/Subrs", 0) {
let mut q = p + 6;
// The section ends at `/CharStrings`, found once rather than for every entry.
let lim = find(&priv_, b"/CharStrings", q).unwrap_or(priv_.len());
while let Some(d) = find(&priv_, b"dup", q) {
let lim = find(&priv_, b"/CharStrings", q).unwrap_or(priv_.len());
if d > lim {
break;
}
@@ -223,6 +224,7 @@ impl Type1 {
flex: None,
done: false,
depth: 0,
ops: crate::cff::MAX_GLYPH_OPS,
seac: None,
};
st.run(cs);
@@ -270,6 +272,8 @@ struct T1<'a> {
flex: Option<Vec<(f64, f64)>>,
done: bool,
depth: usize,
/// Instructions this glyph may still run, across its subroutine calls.
ops: usize,
seac: Option<(f64, f64, f64, u8, u8)>,
}
@@ -314,6 +318,12 @@ impl T1<'_> {
}
let mut i = 0;
while i < cs.len() && !self.done {
// Subroutines that branch would multiply the work: the budget is shared.
let Some(left) = self.ops.checked_sub(1) else {
self.done = true;
return;
};
self.ops = left;
let b0 = cs[i];
i += 1;
let s = |st: &Self, k: usize| st.stack.get(k).copied().unwrap_or(0.0);
@@ -533,6 +543,25 @@ mod tests {
assert_eq!(p.bounding_box(), kurbo::Rect::new(50.0, 0.0, 550.0, 500.0));
}
/// A subroutine that calls itself ten times: 10^10 calls within the depth limit of ten. The
/// glyph stops at its instruction budget instead.
#[test]
fn branching_subroutines_stop_at_the_instruction_budget() {
let mut out = b"%!PS-AdobeFont-1.0: Test 001\n/FontMatrix [0.001 0 0 0.001 0 0] readonly def\ncurrentfile eexec\n".to_vec();
// `0 callsubr` is `139 10`; `return` is 11, `endchar` 14 (lenIV -1: charstrings in clear).
let mut subr = [139u8, 10].repeat(10);
subr.push(11);
let mut priv_ = b"dup /Private 8 dict dup begin /lenIV -1 def\n/Subrs 1 array\n".to_vec();
priv_.extend_from_slice(format!("dup 0 {} RD ", subr.len()).as_bytes());
priv_.extend_from_slice(&subr);
priv_.extend_from_slice(b" NP\nend\n/CharStrings 1 dict dup begin\n/A 3 RD \x8b\x0a\x0e ND\nend\nend\nmark currentfile closefile\n");
out.extend(encrypt(&priv_, EEXEC_R, 4));
out.extend_from_slice(b"\n0000000000000000\ncleartomark\n");
let t = Type1::parse(&out).unwrap();
let (p, _) = t.outline("A").unwrap();
assert!(p.elements().is_empty());
}
/// A `/Subrs` entry whose `RD` length is `usize::MAX`: the bytes are not in the file, and
/// `q + n` must not overflow.
#[test]