fix(cos): read a damaged real's numeric prefix in one pass

To read the longest valid prefix of a token like 1.2.3 the parser
retried the conversion on each shorter prefix, so a long digit run
followed by long junk was converted once per junk byte: quadratic in
the token. The prefix is found in one scan and converted once. A
prefix that spells infinity or NaN no longer reads as a number.
This commit is contained in:
aiongg
2026-10-22 17:43:03 +00:00
parent a374d16515
commit 58dc10c227
+56 -12
View File
@@ -324,18 +324,8 @@ impl<'a> Lexer<'a> {
if s.contains('.') {
// Tolerate forms like "--5", "5-" or "1.2.3" by parsing the longest valid prefix.
let cleaned: String = s.trim_start_matches('+').replacen("--", "-", 1);
let mut end = cleaned.len();
while end > 0 {
// 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::<f64>()
{
return Ok(Object::Real(v));
}
end -= 1;
}
return Ok(Object::Real(0.0));
let v = cleaned.get(..real_prefix_len(cleaned.as_bytes())).and_then(|p| parse_counted::<f64>(p).ok());
return Ok(Object::Real(v.unwrap_or(0.0)));
}
// Tolerate a doubled sign ("--280") like the real branch does.
let t = s.trim_start_matches('+');
@@ -350,6 +340,44 @@ impl<'a> Lexer<'a> {
}
}
/// Length of the longest prefix of `s` that reads as a decimal number: a sign, digits with at
/// most one `.`, and an exponent with digits. Zero when that prefix has no digit. Found in one
/// pass so a long damaged token is converted once, not once per shorter prefix.
fn real_prefix_len(s: &[u8]) -> usize {
let digits = |from: usize| s.get(from..).map_or(0, |r| r.iter().take_while(|b| b.is_ascii_digit()).count());
let mut at = usize::from(matches!(s.first(), Some(b'+' | b'-')));
let int = digits(at);
at += int;
let mut frac = 0;
if s.get(at) == Some(&b'.') {
frac = digits(at + 1);
at += 1 + frac;
}
if int + frac == 0 {
return 0;
}
if matches!(s.get(at), Some(b'e' | b'E')) {
let sign = usize::from(matches!(s.get(at + 1), Some(b'+' | b'-')));
let exp = digits(at + 1 + sign);
if exp > 0 {
at += 1 + sign + exp;
}
}
at
}
#[cfg(test)]
thread_local! {
/// Bytes handed to number conversion on this thread.
static NUMBER_BYTES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn parse_counted<T: std::str::FromStr>(s: &str) -> Result<T, T::Err> {
#[cfg(test)]
NUMBER_BYTES.with(|c| c.set(c.get() + s.len()));
s.parse()
}
fn hexval(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
@@ -483,6 +511,22 @@ mod tests {
assert_eq!(Lexer::new("1.5\u{2014}".as_bytes(), 0).object().unwrap(), Object::Real(1.5));
}
#[test]
fn a_damaged_real_is_converted_once() {
let token = format!("1.{}{}", "5".repeat(2_000), "x".repeat(20_000));
NUMBER_BYTES.with(|c| c.set(0));
let Ok(Object::Real(v)) = Lexer::new(token.as_bytes(), 0).object() else { panic!("not a real") };
let converted = NUMBER_BYTES.with(|c| c.get());
assert!(converted <= token.len(), "{converted} bytes converted for a {}-byte token", token.len());
assert!((v - 1.5555).abs() < 1e-3);
// The longest numeric prefix wins, as before.
for (t, want) in
[("1.2.3", 1.2), ("--5.", -5.0), ("+.5x", 0.5), ("5.-", 5.0), ("1.5e3", 1500.0), ("1.5e+", 1.5), ("-.x", 0.0), (".", 0.0), ("-inf.", 0.0)]
{
assert_eq!(Lexer::new(t.as_bytes(), 0).object().ok(), Some(Object::Real(want)), "{t}");
}
}
fn obj(s: &str) -> Object {
Lexer::new(s.as_bytes(), 0).object().unwrap_or_else(|e| panic!("{s}: {e}"))
}