mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 20:18:06 -06:00
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:
co-authored by
Claude Opus 5.5
parent
ff496918a1
commit
e93a6f255f
+272
-65
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user