From e93a6f255f08be2aa7a24d5d827ab6ee805a1d3e Mon Sep 17 00:00:00 2001 From: Hal Goff Date: Fri, 9 Oct 2026 14:14:13 +0100 Subject: [PATCH] Never crash: CMap parsing can't panic, overflow or run away; tokens need no spaces (#495) The font-metrics CMap parser (ToUnicode and embedded CID CMaps) split the stream on whitespace and sliced hex tokens as UTF-8 text: - a non-ASCII byte inside `<...>` sliced a character in half and panicked - `bfrange` arrays and `/Differences` incremented code counters past u32::MAX, and CID lookup added `c + (code - lo)` unchecked - 4 KB of overlapping `bfrange`s could ask for millions of map entries - legal CMaps with no space between tokens (`<0001><0070>`) mapped nothing, so their text was not found for editing A byte-level lexer now reads hex strings (whitespace inside allowed), brackets, names, literal strings and comments. Counters use checked or saturating arithmetic, array entries stop at the range's `hi`, and a CMap adds at most 1 Mi ToUnicode entries and 64 Ki codespace/CID ranges. A bad array entry still stands for its code, as before. Claude-Session: https://claude.ai/code/session_01VNJqS2dEQawHJn1qTRJiKp Co-authored-by: Claude Opus 5.5 --- crates/fonts/src/pdf.rs | 337 ++++++++++++++++++++++++++++++++-------- 1 file changed, 272 insertions(+), 65 deletions(-) diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 88b77c061..aeee43e1b 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -68,12 +68,125 @@ fn dict(doc: &Document, o: Option<&Object>) -> Option { o.and_then(|o| doc.resolve(o).as_dict().cloned()) } -fn hex_bytes(tok: &str) -> Option> { - let t = tok.trim_start_matches('<').trim_end_matches('>'); - if !t.len().is_multiple_of(2) || t.is_empty() { - return None; +/// Most entries a CMap may add, so a small stream of overlapping `bfrange`s can't take minutes +/// or gigabytes; a full 2-byte code space is 65 536. Entries past it are ignored. +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; + +/// A token of a CMap stream (PostScript syntax, ISO 32000-2 §9.10.3). +#[derive(Debug, PartialEq)] +enum Tok<'a> { + /// A `<…>` string; `None` when it isn't an even number of hex digits. + Hex(Option>), + Open, + Close, + /// An operator, number or name (with its `/`), a `(…)` string or a `<<`/`>>`. + Word(&'a [u8]), +} + +/// Splits a CMap into tokens without assuming whitespace between them (`<01><0041>` is two +/// strings), skipping `%` comments. +struct Lexer<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Lexer<'a> { + fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0 } + } + + fn at(&self, i: usize) -> Option { + self.data.get(i).copied() + } +} + +fn is_cmap_space(b: u8) -> bool { + matches!(b, 0 | b'\t' | b'\n' | 0x0c | b'\r' | b' ') +} + +fn is_cmap_delimiter(b: u8) -> bool { + matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%') +} + +impl<'a> Iterator for Lexer<'a> { + type Item = Tok<'a>; + + fn next(&mut self) -> Option> { + loop { + let b = self.at(self.pos)?; + if is_cmap_space(b) { + self.pos += 1; + } else if b == b'%' { + while self.at(self.pos).is_some_and(|c| c != b'\n' && c != b'\r') { + self.pos += 1; + } + } else { + break; + } + } + let start = self.pos; + let b = self.at(start)?; + self.pos += 1; + match b { + b'[' => Some(Tok::Open), + b']' => Some(Tok::Close), + b'<' | b'>' if self.at(self.pos) == Some(b) => { + self.pos += 1; + Some(Tok::Word(self.data.get(start..self.pos)?)) + } + b'<' => { + let mut digits = Vec::new(); + let mut valid = true; + loop { + match self.at(self.pos) { + // Unterminated: what's left is one bad string. + None => return Some(Tok::Hex(None)), + Some(b'>') => break, + Some(c) if is_cmap_space(c) => {} + Some(c) => match (c as char).to_digit(16) { + // A hex digit is below 16, so it fits a u8. + Some(d) => digits.push(d as u8), + None => valid = false, + }, + } + self.pos += 1; + } + self.pos += 1; + if !valid || digits.is_empty() || !digits.len().is_multiple_of(2) { + return Some(Tok::Hex(None)); + } + // `chunks` never yields an empty slice, so `p[0]` exists. + Some(Tok::Hex(Some(digits.chunks(2).map(|p| p[0] << 4 | p.get(1).copied().unwrap_or(0)).collect()))) + } + b'(' => { + // Literal strings nest and escape their parentheses; only skipping them matters. + let mut depth = 1usize; + while depth > 0 { + match self.at(self.pos) { + None => break, + Some(b'\\') => self.pos += 1, + Some(b'(') => depth += 1, + Some(b')') => depth -= 1, + _ => {} + } + self.pos += 1; + } + Some(Tok::Word(self.data.get(start..self.pos.min(self.data.len()))?)) + } + _ => { + // A name keeps its `/`; other delimiters (`)`, `>`, `{`, `}`) stand alone. + if b == b'/' || !is_cmap_delimiter(b) { + while self.at(self.pos).is_some_and(|c| !is_cmap_space(c) && !is_cmap_delimiter(c)) { + self.pos += 1; + } + } + Some(Tok::Word(self.data.get(start..self.pos)?)) + } + } } - (0..t.len()).step_by(2).map(|i| u8::from_str_radix(&t[i..i + 2], 16).ok()).collect() } fn be(b: &[u8]) -> u32 { @@ -89,47 +202,64 @@ fn style_from_name(name: &str) -> (bool, bool) { ) } -/// Codespace ranges and CID mappings from an embedded CMap stream. +/// The next entry of a `begin… end…` section: `N` tokens, or `None` at its `end` operator or +/// the end of the stream. +fn entry<'a, const N: usize>(toks: &mut Lexer<'a>, end: &[u8]) -> Option<[Tok<'a>; N]> { + let mut out = Vec::with_capacity(N); + for _ in 0..N { + match toks.next()? { + Tok::Word(w) if w == end => return None, + t => out.push(t), + } + } + out.try_into().ok() +} + +fn number(t: &Tok) -> Option { + match t { + Tok::Word(w) => std::str::from_utf8(w).ok()?.parse().ok(), + _ => None, + } +} + +/// Codespace ranges and CID mappings from an embedded CMap stream, at most +/// [`MAX_CMAP_RANGES`] of each (every glyph searches them). #[allow(clippy::type_complexity)] fn parse_cmap(data: &[u8]) -> (Vec<(usize, Vec, Vec)>, Vec<(u32, u32, u32)>) { - let text = String::from_utf8_lossy(data); - let toks: Vec<&str> = text.split_whitespace().collect(); let (mut spaces, mut cids) = (Vec::new(), Vec::new()); - let mut i = 0; - while i < toks.len() { - match toks[i] { - "begincodespacerange" => { - i += 1; - while i + 1 < toks.len() && toks[i] != "endcodespacerange" { - if let (Some(lo), Some(hi)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1])) + let mut toks = Lexer::new(data); + while let Some(t) = toks.next() { + match t { + Tok::Word(b"begincodespacerange") => { + while let Some([lo, hi]) = entry(&mut toks, b"endcodespacerange") { + if let (Tok::Hex(Some(lo)), Tok::Hex(Some(hi))) = (lo, hi) && lo.len() == hi.len() + && spaces.len() < MAX_CMAP_RANGES { spaces.push((lo.len(), lo, hi)); } - i += 2; } } - "begincidrange" => { - i += 1; - while i + 2 < toks.len() && toks[i] != "endcidrange" { - if let (Some(lo), Some(hi), Ok(c)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1]), toks[i + 2].parse::()) { - cids.push((be(&lo), be(&hi), c)); + Tok::Word(b"begincidrange") => { + while let Some([lo, hi, c]) = entry(&mut toks, b"endcidrange") { + if let (Tok::Hex(Some(lo)), Tok::Hex(Some(hi)), Some(c)) = (&lo, &hi, number(&c)) + && cids.len() < MAX_CMAP_RANGES + { + cids.push((be(lo), be(hi), c)); } - i += 3; } } - "begincidchar" => { - i += 1; - while i + 1 < toks.len() && toks[i] != "endcidchar" { - if let (Some(code), Ok(c)) = (hex_bytes(toks[i]), toks[i + 1].parse::()) { - cids.push((be(&code), be(&code), c)); + Tok::Word(b"begincidchar") => { + while let Some([code, c]) = entry(&mut toks, b"endcidchar") { + if let (Tok::Hex(Some(code)), Some(c)) = (&code, number(&c)) + && cids.len() < MAX_CMAP_RANGES + { + cids.push((be(code), be(code), c)); } - i += 2; } } _ => {} } - i += 1; } spaces.sort_by_key(|s| s.0); (spaces, cids) @@ -312,7 +442,7 @@ impl Metrics { if self.cid_map.is_empty() { return code; } - self.cid_map.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map_or(0, |(lo, _, c)| c + (code - lo)) + self.cid_map.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map_or(0, |(lo, _, c)| c.saturating_add(code - lo)) } /// The advance of `code` in text space per unit of font size. @@ -392,60 +522,66 @@ fn utf16(b: &[u8]) -> String { String::from_utf16_lossy(&units) } -/// `bfchar`/`bfrange` entries of a ToUnicode CMap. +/// `bfchar`/`bfrange` entries of a ToUnicode CMap, at most [`MAX_CMAP_ENTRIES`] of them. fn parse_to_unicode(data: &[u8], out: &mut HashMap) { - let text = String::from_utf8_lossy(data); - // Tokenise, keeping arrays' brackets as tokens. - let spaced = text.replace('[', " [ ").replace(']', " ] "); - let toks: Vec<&str> = spaced.split_whitespace().collect(); - let mut i = 0; - while i < toks.len() { - match toks[i] { - "beginbfchar" => { - i += 1; - while i + 1 < toks.len() && toks[i] != "endbfchar" { - if let (Some(c), Some(u)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1])) { + let mut budget = MAX_CMAP_ENTRIES; + let mut toks = Lexer::new(data); + while let Some(t) = toks.next() { + match t { + Tok::Word(b"beginbfchar") => { + while let Some([c, u]) = entry(&mut toks, b"endbfchar") { + if let (Tok::Hex(Some(c)), Tok::Hex(Some(u))) = (c, u) { + let Some(left) = budget.checked_sub(1) else { return }; + budget = left; out.insert(be(&c), utf16(&u)); } - i += 2; } } - "beginbfrange" => { - i += 1; - while i + 2 < toks.len() && toks[i] != "endbfrange" { - let (Some(lo), Some(hi)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1])) else { break }; + Tok::Word(b"beginbfrange") => { + while let Some([lo, hi, dst]) = entry(&mut toks, b"endbfrange") { + let (Tok::Hex(Some(lo)), Tok::Hex(Some(hi))) = (lo, hi) else { break }; let (lo, hi) = (be(&lo), be(&hi)); - if toks[i + 2] == "[" { - let mut j = i + 3; - let mut code = lo; - while j < toks.len() && toks[j] != "]" { - if let Some(u) = hex_bytes(toks[j]) { - out.insert(code, utf16(&u)); + match dst { + // One destination per code, from `lo` up to `hi`. + Tok::Open => { + let mut code = (hi >= lo).then_some(lo); + for t in toks.by_ref() { + let Some(c) = code else { + if t == Tok::Close { + break; + } + continue; + }; + match t { + Tok::Close => break, + Tok::Hex(Some(u)) => { + let Some(left) = budget.checked_sub(1) else { return }; + budget = left; + out.insert(c, utf16(&u)); + } + // A bad entry still stands for its code. + _ => {} + } + code = c.checked_add(1).filter(|c| *c <= hi); } - code += 1; - j += 1; } - i = j + 1; - } else { - if let Some(u) = hex_bytes(toks[i + 2]) - && hi >= lo - && hi - lo < 65536 - { + Tok::Hex(Some(u)) if hi >= lo && hi - lo < 65536 => { let mut units: Vec = u.chunks(2).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect(); for code in lo..=hi { + let Some(left) = budget.checked_sub(1) else { return }; + budget = left; out.insert(code, String::from_utf16_lossy(&units)); if let Some(last) = units.last_mut() { *last = last.wrapping_add(1); } } } - i += 3; + _ => {} } } } _ => {} } - i += 1; } } @@ -484,13 +620,14 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> HashMap code = (*n).max(0) as u32, + // Out-of-range codes map nothing a simple font can show. + Object::Int(n) => code = u32::try_from((*n).max(0)).unwrap_or(u32::MAX), Object::Name(n) => { match glyph_unicode(&String::from_utf8_lossy(n)) { Some(c) => out.insert(code, c.to_string()), None => out.remove(&code), }; - code += 1; + code = code.saturating_add(1); } _ => {} } @@ -637,4 +774,74 @@ mod tests { assert_eq!(glyph_unicode("u1F600"), Some('😀')); assert_eq!(glyph_unicode("nonsense"), None); } + + fn to_unicode(cmap: &[u8]) -> HashMap { + let mut out = HashMap::new(); + parse_to_unicode(cmap, &mut out); + out + } + + #[test] + fn cmap_tokens_need_no_space_between_them() { + // Hex strings, brackets and operators may touch; comments and literal strings are skipped. + let cmap = b"/CIDSystemInfo << /Registry (Adobe (x) \\) beginbfchar) >> def % beginbfchar <0001> <0058>\n\ + 2 beginbfchar<0003><0020><0010><00 E9>endbfchar \ + 1 beginbfrange<0024><0026><0041>endbfrange 1 beginbfrange<0030><0031>[<0048><0069>]endbfrange"; + let map = to_unicode(cmap); + let mut got: Vec<_> = map.iter().map(|(c, u)| (*c, u.as_str())).collect(); + got.sort_unstable(); + assert_eq!(got, [(3, " "), (0x10, "é"), (0x24, "A"), (0x25, "B"), (0x26, "C"), (0x30, "H"), (0x31, "i")]); + let (spaces, cids) = parse_cmap(b"1 begincodespacerange<00>endcodespacerange 1 begincidrange<20><7E>1 endcidrange"); + assert_eq!(spaces, [(1, vec![0], vec![0xff])]); + assert_eq!(cids, [(0x20, 0x7e, 1)]); + } + + #[test] + fn malformed_cmaps_never_panic() { + // Non-ASCII bytes inside hex strings once split a UTF-8 character and panicked. + for cmap in [ + "1 beginbfchar <0é0> <0041> <0042> endbfchar".as_bytes(), + "1 beginbfrange <0é0> <00é> <0041> endbfrange".as_bytes(), + b"1 beginbfchar <0001> <004", + b"1 beginbfchar <123> <0041> <0002> <0003> endbfchar", + b"1 beginbfrange <0001> [<0041>] endbfrange", + b"1 beginbfrange <0001> <0002> [<0041> (", + b"1 beginbfrange <0001>", + b"\xff\xfe beginbfchar \x80<\xc3\xa9> <0041> endbfchar %", + ] { + to_unicode(cmap); + parse_cmap(cmap); + } + // Code counters at the top of the range stop instead of overflowing. + let map = to_unicode(b"1 beginbfrange [<0041> <0042> <0043>] endbfrange"); + assert_eq!(map.len(), 2); + assert_eq!(map.get(&u32::MAX).map(String::as_str), Some("B")); + // A malformed array entry keeps its place, so the next one maps the next code. + let map = to_unicode(b"1 beginbfrange <0001> <0003> [<0041> <0043>] endbfrange"); + assert_eq!((map.get(&2), map.get(&3).map(String::as_str)), (None, Some("C"))); + let mut m = Metrics::fallback(); + (_, m.cid_map) = parse_cmap(b"1 begincidrange <0000> 4294967295 endcidrange"); + assert_eq!(m.cid(5), u32::MAX); + let mut doc = Document::new_empty(); + let mut enc = Dict::new(); + enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(i64::MAX), Object::name("A"), Object::name("B")])); + let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("Encoding", Object::Dict(enc))]); + assert_eq!(unicode_map(&doc, &f, false).get(&u32::MAX).map(String::as_str), Some("B")); + } + + #[test] + fn a_cmap_adds_at_most_max_entries() { + // 200 ranges of 65 536 codes would be 13 million strings from 4 KB of stream. + let mut cmap = b"200 beginbfrange ".to_vec(); + for k in 0..200u32 { + cmap.extend(format!("<{:08X}> <{:08X}> <0041> ", k << 16, (k << 16) | 0xffff).bytes()); + } + cmap.extend(b"endbfrange"); + assert_eq!(to_unicode(&cmap).len(), MAX_CMAP_ENTRIES); + let mut many = b"begincidrange ".to_vec(); + for k in 0..MAX_CMAP_RANGES + 10 { + many.extend(format!("<{k:08X}> <{k:08X}> 1 ").bytes()); + } + assert_eq!(parse_cmap(&many).1.len(), MAX_CMAP_RANGES); + } }