From 5763460a6205800aa960dd100e301d4dda4e8591 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:52:06 +0700 Subject: [PATCH] fix(captions): reject overflowing TTML times and round extreme clocks safely --- crates/captions/src/lib.rs | 5 ++++- crates/captions/src/ttml.rs | 39 ++++++++++++++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/captions/src/lib.rs b/crates/captions/src/lib.rs index 87ef1e56..b4d4758b 100644 --- a/crates/captions/src/lib.rs +++ b/crates/captions/src/lib.rs @@ -307,7 +307,8 @@ pub fn parse_clock(s: &str) -> Option { /// Format ticks as `HH:MM:SSmmm` (rounded to the nearest millisecond). pub fn format_clock(t: Tick, sep: char) -> String { - let ms = (t.0.max(0) + TICKS_PER_MS / 2) / TICKS_PER_MS; + // The rounding addition must not overflow for an input-derived Tick near i64::MAX. + let ms = (i128::from(t.0.max(0)) + i128::from(TICKS_PER_MS) / 2) / i128::from(TICKS_PER_MS); let (h, m, s, f) = (ms / 3_600_000, (ms / 60_000) % 60, (ms / 1000) % 60, ms % 1000); format!("{h:02}:{m:02}:{s:02}{sep}{f:03}") } @@ -368,6 +369,8 @@ mod tests { assert_eq!(parse_clock("00:61:00,000"), None); assert_eq!(parse_clock("aa:00:00,000"), None); assert_eq!(format_clock(Tick(TICKS_PER_SECOND * 3723 + TICKS_PER_MS * 45), ','), "01:02:03,045"); + assert!(format_clock(Tick(i64::MAX), ',').ends_with(",077")); + assert_eq!(format_clock(Tick(i64::MIN), '.'), "00:00:00.000"); } #[test] diff --git a/crates/captions/src/ttml.rs b/crates/captions/src/ttml.rs index 1109890e..faff9dce 100644 --- a/crates/captions/src/ttml.rs +++ b/crates/captions/src/ttml.rs @@ -252,6 +252,10 @@ fn parse_time(s: &str, tm: Timing) -> Option { let (h, m) = (num(parts.first()?)?, num(parts.get(1)?)?); let sec_part = parts.get(2)?; let (si, sf) = sec_part.split_once('.').unwrap_or((sec_part, "")); + let seconds = num(si)?; + if m >= 60 || seconds >= 60 { + return None; + } let mut t = (h * 3600 + m * 60) * tps + decimal_ticks(si, sf, tps)?; if let Some(fp) = parts.get(3) { if parts.len() > 4 { @@ -259,13 +263,15 @@ fn parse_time(s: &str, tm: Timing) -> Option { } let (fi, fs) = fp.split_once('.').unwrap_or((fp, "")); let frames = num(fi)?; - let _ = tm.base; let sub: i128 = if fs.is_empty() { 0 } else { num(fs)? }; + if frames >= i128::from(tm.base) || sub >= i128::from(tm.sub_frame_rate) { + return None; + } // frames at the effective rate; sub-frames divide a frame let ft = tm.rate.tick_of(1).0 as i128; t += frames * ft + sub * ft / tm.sub_frame_rate.max(1) as i128; } - return Some(Tick(t as i64)); + return i64::try_from(t).ok().map(Tick); } let unit_start = s.find(|c: char| c.is_ascii_alphabetic())?; let (v, unit) = s.split_at(unit_start); @@ -290,7 +296,7 @@ fn parse_time(s: &str, tm: Timing) -> Option { } _ => return None, }; - Some(Tick(t as i64)) + i64::try_from(t).ok().map(Tick) } #[derive(Clone, Copy, Default, PartialEq)] @@ -552,6 +558,33 @@ mod tests { assert_eq!(parse_time("1:2", t), None); } + #[test] + fn invalid_and_overflowed_time_expressions_are_ignored() { + let normal = tm(FrameRate::FPS_25); + for expr in [ + "999999999999999h", + "999999999999999m", + "999999999999999s", + "999999999999999ms", + "999999999999999f", + "999999999999999t", + "00:60:00", + "00:00:60", + "00:00:01:25", + ] { + assert_eq!(parse_time(expr, normal), None, "{expr}"); + } + let subframes = Timing { sub_frame_rate: 2, ..normal }; + assert!(parse_time("00:00:01:12.1", subframes).is_some()); + assert_eq!(parse_time("00:00:01:12.2", subframes), None); + // Bad cues must not wrap to a plausible but unrelated timeline position. + let xml = r#"

Bad

Good

"#; + let doc = parse(xml).expect("well-formed TTML"); + assert_eq!(doc.cues.len(), 1); + assert_eq!(doc.cues[0].text, "Good"); + assert!(!doc.warnings.is_empty()); + } + #[test] fn reads_styles_spans_and_nesting() { let x = r#"