Skip to content
Open
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
152 changes: 152 additions & 0 deletions crates/audio/src/audio_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ impl AudioData {
stream_start_time
};
let stream_time_base = f64::from(stream_time_base);
let stream_duration = input_stream.duration();
let source_start_sample = range.as_ref().map_or(0, |window| window.start);
let covered_source_end_sample = range.as_ref().map_or(usize::MAX, |window| window.end);
let mut sought = false;
Expand All @@ -105,6 +106,14 @@ impl AudioData {
let mut decoded_frame = ffmpeg::frame::Audio::empty();
let mut resampler_output = ResamplerOutput::new();
let mut samples = Vec::new();
let compressed_bytes = std::fs::metadata(path).map(|meta| meta.len()).unwrap_or(0);
let _ = samples.try_reserve(decoded_sample_capacity(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Container-reported audio duration can force unbounded decoded-sample memory reservation

New reserve trusts container duration, so a tiny crafted audio file can request a multi-GB Vec capacity before decoding any samples.

Cap the reserve by actual file size and a maximum duration/sample count before try_reserve; never use unvalidated container duration as the allocation bound.

AI prompt
Check if this security scanner issue is valid. If so, understand the root cause and fix it. If appropriate, update or add tests. Keep the change focused and preserve intended behavior.

<file name="crates/audio/src/audio_data.rs">
<violation number="1" location="crates/audio/src/audio_data.rs:109">
<priority>P2</priority>
<title>Container-reported audio duration can force unbounded decoded-sample memory reservation</title>
<evidence>The new decode path reads the demuxer-reported stream duration at line 92 and passes it to decoded_sample_capacity, which computes from_duration as duration * time_base * 48 kHz plus a 64-frame margin (lines 329-335). When no explicit range is supplied, from_file returns that unbounded value (lines 336-338), and line 109 asks the allocator to reserve it before any audio frames are decoded. The only cap is the requested window (lines 339-343); load_track deliberately supplies the open-ended usize::MAX / 4 end, so a forged container duration still controls the reservation. Duration is header metadata, not proof of file size: a small crafted FLAC, Matroska, MP3, or similar file can declare hours of audio and reach this code through desktop imports/project playback, captions, and render-farm load_track (crates/render-farm/src/audio.rs:349,361). try_reserve prevents a panic on allocation failure, but it does not prevent the large allocation request or retain-and-repeat behavior when the allocator succeeds; on allocators that commit address space this can exhaust process or shared-worker memory even though the actual decoded payload is tiny.</evidence>
<recommendation>Do not size the initial reservation from unvalidated container duration alone. Clamp the hint against the actual file/stream size and a hard maximum duration/sample count, or cap the initial reserve to a small fixed window and let the vector grow only as real decoded frames arrive. Keep try_reserve, and add a regression test with a tiny file declaring an enormous duration to prove the reservation stays bounded.</recommendation>
</violation>
</file>

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Duration is demuxer metadata, so a tiny file could still drive try_reserve after the usize::MAX/4 window cap. Fixed in c3e34b1 (now the PR head): the hint is min(duration, range, file-size bound, 8 h stereo 48 kHz cap). The file-size bound is packed-f32 samples from the fs::metadata length at 1024× (~6 kbps to 48 kHz stereo f32 is ~512×). try_reserve is unchanged.

Regression test decoded_sample_capacity_clamps_forged_duration_to_file_size: an 8-frame WAV with a forged 10-hour duration and an open-ended MAX/4 window stays at the file-size bound (<1e6 samples). The existing MAX/4 tests still pass, and the 60s WAV bench is still ~1.2×.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix accepted: the developer replaced the unvalidated container-duration hint with min(duration, range, file-size bound, 8 h stereo 48 kHz cap), directly addressing the root cause. Bounding by actual fs::metadata size prevents a tiny forged file from driving a large reservation, the hard duration/sample cap protects against large legitimate files with inflated headers, try_reserve is preserved, and a regression test with an 8-frame WAV claiming 10 hours verifies the reservation stays bounded. Good-faith resolution.

stream_duration,
stream_time_base,
target_channels,
range.as_ref(),
compressed_bytes,
));
let mut resampled_samples = Vec::new();
let mut next_source_sample = if sought { None } else { Some(0usize) };
let mut complete = source_start_sample == covered_source_end_sample;
Expand Down Expand Up @@ -309,6 +318,47 @@ fn target_channels_for_source(channels: u16) -> u16 {
if channels <= 1 { 1 } else { 2 }
}

// Packed f32 bytes per compressed byte. ~6 kbps speech expands to 48 kHz stereo
// f32 by ~512×; 1024× leaves a 2× margin for container overhead and resampler delay.
const MAX_F32_BYTES_PER_COMPRESSED_BYTE: u64 = 1024;
const MAX_DECODED_SAMPLES: usize = 8 * 3_600 * AudioData::SAMPLE_RATE as usize * 2;

fn decoded_samples_from_compressed_bytes(compressed_bytes: u64) -> usize {
let decoded_bytes = compressed_bytes.saturating_mul(MAX_F32_BYTES_PER_COMPRESSED_BYTE);
usize::try_from(decoded_bytes / std::mem::size_of::<f32>() as u64).unwrap_or(usize::MAX)
}

fn decoded_sample_capacity(
duration: i64,
stream_time_base: f64,
target_channels: u16,
range: Option<&Range<usize>>,
compressed_bytes: u64,
) -> usize {
let channels = usize::from(target_channels);
let from_file =
decoded_samples_from_compressed_bytes(compressed_bytes).min(MAX_DECODED_SAMPLES);
if duration <= 0 || !stream_time_base.is_finite() || stream_time_base <= 0.0 {
return 0;
}
let frames = duration as f64 * stream_time_base * f64::from(AudioData::SAMPLE_RATE);
if !frames.is_finite() || frames <= 0.0 {
return 0;
}
let from_duration = (frames.ceil() as usize)
.saturating_add(64)
.saturating_mul(channels)
.min(from_file);
let Some(window) = range else {
return from_duration;
};
window
.end
.saturating_sub(window.start)
.saturating_mul(channels)
.min(from_duration)
}

pub(crate) struct ResamplerOutput {
frame: FFAudio,
capacity: usize,
Expand Down Expand Up @@ -781,4 +831,106 @@ mod tests {
assert_eq!(data.sample_count(), 0);
assert!(data.covers_source_range(1_000, 1_000));
}

#[test]
fn decoded_sample_capacity_uses_range_or_duration() {
assert_eq!(
decoded_sample_capacity(48_000, 1.0 / 48_000.0, 2, Some(&(1_000..1_250)), u64::MAX),
500
);
assert_eq!(
decoded_sample_capacity(0, 1.0 / 48_000.0, 2, Some(&(1_000..1_250)), u64::MAX),
0
);
assert_eq!(
decoded_sample_capacity(-1, 1.0 / 48_000.0, 2, None, u64::MAX),
0
);
assert_eq!(
decoded_sample_capacity(48_000, 1.0 / 48_000.0, 2, None, u64::MAX),
(48_000 + 64) * 2
);
}

#[test]
fn decoded_sample_capacity_caps_load_track_open_ended_end() {
let end = usize::MAX / 4;
assert_eq!(
decoded_sample_capacity(48_000, 1.0 / 48_000.0, 1, Some(&(0..end)), u64::MAX),
48_000 + 64
);
assert_eq!(
decoded_sample_capacity(48_000, 1.0 / 48_000.0, 2, Some(&(1_000..end)), u64::MAX),
(48_000 + 64) * 2
);
assert_eq!(
decoded_sample_capacity(-1, 1.0 / 48_000.0, 2, Some(&(0..end)), u64::MAX),
0
);
assert_eq!(
decoded_sample_capacity(48_000, f64::NAN, 2, Some(&(0..end)), u64::MAX),
0
);
}

#[test]
fn decoded_sample_capacity_clamps_forged_duration_to_file_size() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("tiny.wav");
write_pcm_wav(&path, AudioData::SAMPLE_RATE, 8, &[1]);
let file_len = std::fs::metadata(&path).unwrap().len();
assert!(file_len < 1_024);

let ten_hours = 10 * 3_600 * i64::from(AudioData::SAMPLE_RATE);
let open_ended = 0..(usize::MAX / 4);
let reserved =
decoded_sample_capacity(ten_hours, 1.0 / 48_000.0, 2, Some(&open_ended), file_len);
let max_from_file =
decoded_samples_from_compressed_bytes(file_len).min(MAX_DECODED_SAMPLES);

assert_eq!(reserved, max_from_file);
assert!(reserved > 0);
assert!(reserved < 1_000_000);

let unclamped_hours = (10 * 3_600 * AudioData::SAMPLE_RATE as usize + 64) * 2;
assert!(unclamped_hours > 3_000_000_000usize);
assert!(reserved < unclamped_hours / 1_000);
}

#[test]
fn from_file_range_accepts_load_track_open_ended_end() {
let _ = ffmpeg::init();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("open_ended.wav");
let frames = AudioData::SAMPLE_RATE as usize;
write_pcm_wav(&path, AudioData::SAMPLE_RATE, frames, &[2_000]);

let data = AudioData::from_file_range(&path, 0, usize::MAX / 4).unwrap();
assert_eq!(data.channels(), 1);
assert!(data.sample_count().abs_diff(frames) <= 64);
assert!(data.covers_source_range(0, usize::MAX / 4));
}

#[test]
#[ignore]
fn benchmark_from_file_decode() {
let _ = ffmpeg::init();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("stereo_60s.wav");
let frames = AudioData::SAMPLE_RATE as usize * 60;
write_pcm_wav(&path, AudioData::SAMPLE_RATE, frames, &[2_000, -2_000]);
let mut runs = Vec::new();
for _ in 0..5 {
let start = std::time::Instant::now();
let data = AudioData::from_file(&path).unwrap();
runs.push(start.elapsed().as_secs_f64() * 1000.0);
assert_eq!(data.sample_count(), frames);
}
runs.sort_by(f64::total_cmp);
println!(
"from_file_60s_stereo_wav median_ms={:.1} min_ms={:.1}",
runs[runs.len() / 2],
runs[0]
);
}
}