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
@@ -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));+ } }