Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion crates/rustmotion/src/encode/video_audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,26 +199,51 @@ fn ffmpeg_available() -> bool {
/// rate 4.0 → `atempo=2.0,atempo=2.0`
/// rate 0.1 → `atempo=0.5,atempo=0.2` (0.5 * 0.2 = 0.1)
///
/// Returns `None` if rate == 1.0 (no filter needed).
/// Returns `None` if rate == 1.0 (no filter needed), or if `rate` cannot
/// possibly be reached by any chain of `atempo` stages (`<= 0.0` or
/// non-finite — see the guard below).
pub fn build_atempo_filter(rate: f64) -> Option<String> {
const EPSILON: f64 = 1e-9;
// `remaining` only ever converges toward `[0.5, 2.0]` by repeatedly
// multiplying or dividing by 2.0 starting from a *positive, finite*
// `rate`. At `rate == 0.0`, `remaining /= 0.5` stays `0.0` forever; at a
// negative or non-finite rate it diverges away from the loop's own exit
// test. Either way the `while` below never terminates and pushes a new
// `String` on every turn — the guard has to reject these before that
// loop is ever reached, not inside it.
if !rate.is_finite() || rate <= 0.0 {
return None;
}
if (rate - 1.0).abs() < EPSILON {
return None;
}

// A ceiling on the chain length, independent of the guard above: a
// legitimate rate as extreme as 1e9 only needs ~30 stages, so this never
// fires for real input. It exists so that a future mistake in this
// arithmetic degrades into "no atempo filter" instead of reopening the
// same unbounded loop the guard above closes.
const MAX_STAGES: usize = 64;

let mut parts: Vec<String> = Vec::new();
let mut remaining = rate;

if rate > 1.0 {
// Each stage multiplies by at most 2.0
while remaining > 2.0 + EPSILON {
if parts.len() >= MAX_STAGES {
return None;
}
parts.push("atempo=2.0".to_string());
remaining /= 2.0;
}
parts.push(format!("atempo={:.6}", remaining));
} else {
// Each stage multiplies by at least 0.5
while remaining < 0.5 - EPSILON {
if parts.len() >= MAX_STAGES {
return None;
}
parts.push("atempo=0.5".to_string());
remaining /= 0.5;
}
Expand Down
39 changes: 39 additions & 0 deletions crates/rustmotion/tests/audit_ws_c.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,42 @@ fn a_render_ffmpeg_rejects_leaves_no_debris_at_the_output_path() {
out.path().display()
);
}

// ─── build_atempo_filter(rate) must not loop forever ───────────────────────
//
// `remaining /= 0.5` never reaches the loop's `>= 0.5` exit test starting
// from `0.0`, and diverges away from it starting from any negative rate —
// either way the pre-fix loop pushed a fresh `String` forever. Calling the
// unguarded function directly on the test thread would hang the whole
// suite, so each candidate rate runs on its own thread with a bounded
// `recv_timeout`: a present guard returns well inside the timeout, a
// missing one times out and fails the assertion instead of the process.
#[test]
fn atempo_guard_rejects_non_positive_and_non_finite_rates_without_hanging() {
use std::sync::mpsc;
use std::time::Duration;

for rate in [
0.0_f64,
-1.0,
-0.25,
f64::NAN,
f64::INFINITY,
f64::NEG_INFINITY,
] {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(rustmotion::encode::video_audio::build_atempo_filter(rate));
});
match rx.recv_timeout(Duration::from_secs(2)) {
Ok(result) => assert_eq!(
result, None,
"rate={rate} must return None instead of building an atempo chain"
),
Err(_) => panic!(
"rate={rate} did not return within 2s — the guard against the infinite loop \
in build_atempo_filter is missing or broken"
),
}
}
}
Loading