collective

Browse / github.com/storytold/soundcraft / patch

Bound the source span of stretch renders (fixes #895)

[Audit] `render_stretched` allocates 10× the render limit per source channel Found in a security and robustness audit of `main` at 9b8283b (0.11.0). **Code:** [`crates/engine/src/cmd/more_util.rs:143-176`](https://github.com/storytold/soundcraft/blob/9b8283b90877813a929f5e28b2dc92e5774da933/crates/engine/src/cmd/more_util.rs#L143-L176) - **Severity:** Medium. **Confidence:** Confirmed by code. - **Evidence:** `crates/engine/src/cmd/more_util.rs:137-176`: ```rust s.sample_rate.samples(3600.0).min(soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as Samples) // max_render ... let src_len = ((c.length as f64) / stretch).round().max(1.0); let ratio = out

Status
open
Score
0.00
Adopters
0 other operators
Author
ayozetr (imported from pull request #1148, open)
Base
a8c19f0ffe44b992aad227bc409e5de01ceb59c4
Size
+42 −0 in 1 file
Review
pending
Shared
2026-10-28
Id
76834777c075a27865ccdc38

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/more_util.rs

diff --git a/crates/engine/src/cmd/more_util.rs b/crates/engine/src/cmd/more_util.rs--- a/crates/engine/src/cmd/more_util.rs+++ b/crates/engine/src/cmd/more_util.rs@@ -155,6 +155,14 @@ pub fn render_stretched(s: &mut Session, cmd: &str, c: &Clip, out_len: Samples)     if !(0.1..=10.0).contains(&ratio) {         return Err(bad(cmd, format!("stretch ratio {ratio:.3} is outside 0.1..10")));     }+    // The source span read in can be 10× the output, and both are held for every channel: bound+    // each like any other offline render (a session's clip length and stretch are not trusted).+    let channels = audio.buffer.channels.len().max(1);+    let limit = crate::io::render_limit(channels);+    if src_len > limit as f64 || usize::try_from(out_len).map_or(true, |n| n > limit) {+        let secs = limit as f64 / s.sample_rate.as_f64();+        return Err(bad(cmd, format!("the clip is too long to stretch: one render holds at most {secs:.0} s of {channels}-channel audio")));+    }     let a = usize::try_from(offset.max(0)).unwrap_or(0);     let n = src_len as usize;     let ch: Vec<Vec<f32>> = audio@@ -213,4 +221,38 @@ mod tests {         s.sample_rate = soundcraft_time::SampleRate::new(96_000).unwrap();         assert_eq!(max_render(&s), soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as Samples);     }++    /// A stereo session with a one-frame source and one clip on it.+    fn stretched_clip(length: Samples, stretch: f64) -> (Session, Clip) {+        use soundcraft_model::{ChannelFormat, TrackKind};+        let mut s = Session::default();+        let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Long"));+        let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5]; 2] };+        let src = crate::io::add_source(&mut s, "One", buf, None, FileFormat::Wav);+        let cid = s.new_clip_id();+        let mut clip = Clip::audio(cid, "Clip", src, 0, 0, length);+        clip.stretch = stretch;+        crate::edit::place_clip(&mut s, t, clip.clone());+        (s, clip)+    }++    /// #895: a session's clip length and stretch made the source span read in 10× the one-hour+    /// output limit, for every channel (about 14 GB here), and the allocation aborted the app.+    #[test]+    fn a_stretch_over_the_render_limit_is_refused_before_allocating() {+        let hour = Session::default().sample_rate.samples(3600.0);+        let (mut s, clip) = stretched_clip(hour, 0.1);+        let err = render_stretched(&mut s, "clip.remove_pitch_shift", &clip, clip.length).unwrap_err();+        assert!(err.to_string().contains("too long to stretch"), "{err}");++        let mut e = crate::Engine::new(s);+        assert!(e.execute("clip.remove_pitch_shift", &serde_json::json!({"clips": [clip.id.0]})).is_err());+        let (_, kept) = e.session().find_clip(clip.id).unwrap();+        assert_eq!((kept.stretch, kept.length), (0.1, hour), "the clip is left as it was");++        // An ordinary stretch still renders, at the length asked for.+        let (mut s, clip) = stretched_clip(1_000, 0.5);+        let src = render_stretched(&mut s, "clip.remove_pitch_shift", &clip, clip.length).unwrap().unwrap();+        assert!(s.pool.get(src).unwrap().buffer.channels.iter().all(|c| c.len() == 1_000));+    } }