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 <noreply@anthropic.com>
This commit is contained in:
Hal Goff
2026-10-09 09:14:13 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent ff496918a1
commit e93a6f255f
+272 -65
View File
@@ -68,12 +68,125 @@ fn dict(doc: &Document, o: Option<&Object>) -> Option<Dict> {
o.and_then(|o| doc.resolve(o).as_dict().cloned())
}
fn hex_bytes(tok: &str) -> Option<Vec<u8>> {
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<Vec<u8>>),
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<u8> {
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<Tok<'a>> {
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<u32> {
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<u8>, Vec<u8>)>, 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::<u32>()) {
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::<u32>()) {
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<u32, String>) {
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<u16> = 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<u32, Str
let mut code = 0u32;
for item in diffs {
match &item {
Object::Int(n) => 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<u32, String> {
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><FF>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> <AéB> <0042> endbfchar".as_bytes(),
"1 beginbfrange <0é0> <00é> <0041> endbfrange".as_bytes(),
b"1 beginbfchar <0001> <004",
b"1 beginbfchar <123> <0041> <0002> <zz> <0003> endbfchar",
b"1 beginbfrange <FFFF> <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 <FFFFFFFE> <FFFFFFFF> [<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> <zz> <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> <FFFF> 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);
}
}