fix(captions): reject overflowing TTML times and round extreme clocks safely

This commit is contained in:
Nigel Ihza Firdausy 2026-10-10 23:52:06 +07:00
parent 98ba9acbce
commit 5763460a62
2 changed files with 40 additions and 4 deletions

View File

@ -307,7 +307,8 @@ pub fn parse_clock(s: &str) -> Option<Tick> {
/// Format ticks as `HH:MM:SS<sep>mmm` (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]

View File

@ -252,6 +252,10 @@ fn parse_time(s: &str, tm: Timing) -> Option<Tick> {
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<Tick> {
}
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<Tick> {
}
_ => 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#"<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p begin="999999999999999s" end="2s">Bad</p><p begin="1s" end="2s">Good</p></div></body></tt>"#;
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#"<?xml version="1.0"?>