From 24418ae3c5b46277b106b74f0e88500f8887dc92 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 09:20:36 -0400 Subject: [PATCH] M1.3: number parser cut a multi-byte token mid-character and panicked (found by fuzzing) Co-Authored-By: Claude Opus 5.5 --- crates/cos/src/parser.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index 807471312..8bb71398a 100644 --- a/crates/cos/src/parser.rs +++ b/crates/cos/src/parser.rs @@ -323,7 +323,11 @@ impl<'a> Lexer<'a> { let cleaned: String = s.trim_start_matches('+').replacen("--", "-", 1); let mut end = cleaned.len(); while end > 0 { - if let Ok(v) = cleaned[..end].parse::() { + // Only cut at character boundaries: a token can hold multi-byte UTF-8 (fuzzing + // found ".—", which panicked here). + if cleaned.is_char_boundary(end) + && let Ok(v) = cleaned[..end].parse::() + { return Ok(Object::Real(v)); } end -= 1; @@ -429,6 +433,17 @@ fn find_endstream(data: &[u8], from: usize) -> Option { mod tests { use super::*; + #[test] + fn numbers_with_multibyte_garbage_do_not_panic() { + // From `cargo xtask fuzz`: "2 0 obj\n.\u{2014}" (an em dash after a dot). + let doc = crate::Document::open(std::sync::Arc::new("2 0 obj\n.\u{2014}\n".as_bytes().to_vec())); + assert!(doc.is_err()); + for t in [".\u{2014}", "1.\u{e9}5", "-.\u{1F600}", "+.5\u{2014}"] { + let _ = Lexer::new(t.as_bytes(), 0).object(); + } + assert_eq!(Lexer::new("1.5\u{2014}".as_bytes(), 0).object().unwrap(), Object::Real(1.5)); + } + fn obj(s: &str) -> Object { Lexer::new(s.as_bytes(), 0).object().unwrap_or_else(|e| panic!("{s}: {e}")) }