Compare commits

..
7 changed files with 124 additions and 62 deletions
+53 -6
View File
@@ -4,6 +4,12 @@
use crate::pcm::{be_u32, be_u64};
use crate::{AudioError, Result};
/// The most `stts` entries a sound track may have. Symphonia finds each packet's timestamp by
/// scanning the table from its start, so a table of one-sample runs makes decoding quadratic in
/// the packet count. Audio encoders write one run per packet length (a handful of entries);
/// this bound keeps the worst case at a few hundred million steps.
const MAX_AUDIO_STTS_ENTRIES: u32 = 1 << 14;
#[derive(Clone, Copy)]
enum Level {
Top,
@@ -11,7 +17,11 @@ enum Level {
Trak,
Mdia,
Minf,
Stbl,
/// `audio`: the sample entry is an audio format, so symphonia will decode its packets (whatever the
/// handler says: it builds codec parameters from `stsd` alone).
Stbl {
audio: bool,
},
Udta,
Meta,
Ilst,
@@ -55,13 +65,20 @@ fn atoms(b: &[u8], mut pos: usize, end: usize, level: Level) -> Result<bool> {
(Level::Moov, b"udta") => atoms(b, body, inner, Level::Udta)?,
(Level::Trak, b"mdia") => atoms(b, body, inner, Level::Mdia)?,
(Level::Mdia, b"minf") => atoms(b, body, inner, Level::Minf)?,
(Level::Minf, b"stbl") => atoms(b, body, inner, Level::Stbl)?,
(Level::Minf, b"stbl") => atoms(b, body, inner, Level::Stbl { audio: entry_is_audio(b, body, inner) })?,
(Level::Meta, b"ilst") => atoms(b, body, inner, Level::Ilst)?,
(Level::Ilst, _) => atoms(b, body, inner, Level::Tag)?,
(Level::Stbl, b"stts" | b"co64") => table(b, body + 4, 8, inner)?,
(Level::Stbl, b"stsc") => table(b, body + 4, 12, inner)?,
(Level::Stbl, b"stco") => table(b, body + 4, 4, inner)?,
(Level::Stbl, b"stsz") => sample_sizes(b, body, inner)?,
(Level::Stbl { audio }, b"stts") => {
let fits = table(b, body + 4, 8, inner)?;
if audio && be_u32(b, body + 4).is_some_and(|n| n > MAX_AUDIO_STTS_ENTRIES) {
return Err(AudioError::Malformed("MP4 audio timing table has more runs than packets could need".into()));
}
fits
}
(Level::Stbl { .. }, b"co64") => table(b, body + 4, 8, inner)?,
(Level::Stbl { .. }, b"stsc") => table(b, body + 4, 12, inner)?,
(Level::Stbl { .. }, b"stco") => table(b, body + 4, 4, inner)?,
(Level::Stbl { .. }, b"stsz") => sample_sizes(b, body, inner)?,
(Level::Mdia, b"hdlr") | (Level::Tag, b"data" | b"name" | b"mean") if len > (b.len() - pos) as u64 => {
return Err(malformed(&format!("`{}` atom", String::from_utf8_lossy(kind))));
}
@@ -75,6 +92,36 @@ fn atoms(b: &[u8], mut pos: usize, end: usize, level: Level) -> Result<bool> {
Ok(true)
}
/// The sample entry formats symphonia reads as audio (the list `stsd` is matched against in
/// symphonia-format-isomp4 0.6); every other format, video and subtitles included, has no audio packets.
const AUDIO_FORMATS: [&[u8; 4]; 18] = [
b"mp4a", b"alac", b"ac-3", b"ec-3", b"fLaC", b"Opus", b".mp3", b"lpcm", b"wave", b"alaw", b"ulaw", b"raw ", b"sowt", b"twos", b"in24", b"in32",
b"fl32", b"fl64",
];
/// Whether the `stbl` atom whose children lie in `pos..end` describes audio: its `stsd` may come before
/// or after the timing tables, so the children are walked once more for it.
fn entry_is_audio(b: &[u8], mut pos: usize, end: usize) -> bool {
while pos < end {
let (Some(size), Some(kind)) = (be_u32(b, pos), b.get(pos + 4..pos + 8)) else { return false };
let head = if size == 1 { 16 } else { 8 };
if kind == b"stsd" {
// Version and flags, the entry count, then the first entry's size and format.
return b.get(pos + head + 12..pos + head + 16).is_some_and(|f| AUDIO_FORMATS.iter().any(|a| a.as_slice() == f));
}
let len = match size {
0 => return false,
1 => be_u64(b, pos + 8).unwrap_or(0),
n => u64::from(n),
};
match usize::try_from(len).ok().filter(|&n| n >= head).and_then(|n| pos.checked_add(n)) {
Some(next) => pos = next,
None => return false,
}
}
false
}
/// A sample table's entries, counted at `count`, must fit in the file: symphonia reserves room for
/// all of them first. False if they run past the table's atom (`end`), where symphonia stops.
fn table(b: &[u8], count: usize, entry: u64, end: usize) -> Result<bool> {
+15 -1
View File
@@ -22,6 +22,11 @@ use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat
/// takes its frame length from the file (encoders use 4096).
const MAX_PACKET_FRAMES: u64 = 1 << 20;
/// The most samples (frames × channels) a packet may declare: the PCM decoder allocates that
/// many for its output up front, so a CAF with thousands of channels and a long packet could
/// otherwise claim gigabytes before a sample is decoded (64 MiB of f32).
const MAX_PACKET_SAMPLES: u64 = 1 << 24;
/// How far [`first_marker`] looks for the first format marker: the same 1 MiB a default symphonia
/// probe scans.
const MAX_PREFIX_SCAN: usize = 1 << 20;
@@ -508,7 +513,9 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
}
let params = audio_params(track).cloned().ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_ID_ALAC).and_then(|d| be_u32(d, 0));
if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES) {
let ch = u64::from(info.channels);
if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES || n.saturating_mul(ch) > MAX_PACKET_SAMPLES)
{
return Err(AudioError::Malformed("the file declares packets far longer than any codec uses".into()));
}
let mut decoder = symphonia::default::get_codecs().make_audio_decoder(&params, &decoder_options()).map_err(map_err)?;
@@ -531,6 +538,13 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
if packet.track_id != track_id {
continue;
}
// The declared frames per packet are not the packet's size: a container supplies that on its
// own, and the PCM decoder sizes its output by the bytes it is given.
if let Some(bytes_per_sample) = params.bits_per_coded_sample.map(|b| u64::from(b / 8)).filter(|&n| n > 0)
&& packet.data.len() as u64 / bytes_per_sample > MAX_PACKET_SAMPLES
{
return Err(AudioError::Malformed("a packet holds far more samples than any codec uses".into()));
}
let decoded = match decoder.decode(&packet) {
Ok(d) => d,
Err(SymError::DecodeError(m)) => {
+46 -1
View File
@@ -321,7 +321,7 @@ fn mp4(frames: u32, entry: &[u8], swap: impl Fn(&[u8; 4], Vec<u8>) -> Vec<u8>) -
};
let ftyp = atom(b"ftyp", b"M4A \0\0\0\0M4A isom");
let offset = (ftyp.len() + moov(0).len() + 8) as u32;
let pcm: Vec<u8> = (0..frames as i16).flat_map(|i| (i * 40).to_le_bytes()).collect();
let pcm: Vec<u8> = (0..frames as i16).flat_map(|i| i.wrapping_mul(40).to_le_bytes()).collect();
[ftyp, moov(offset), atom(b"mdat", &pcm)].concat()
}
@@ -373,6 +373,51 @@ fn packets_longer_than_any_codec_uses_are_refused() {
for entry in [lpcm(1 << 21), alac(1 << 21)] {
assert!(matches!(decode(&mp4(480, &entry, |_, a| a), None), Err(AudioError::Malformed(m)) if m.contains("declares packets")));
}
// Frames and channels multiply into the decoder's packet buffer; an ordinary stereo stream
// with long packets stays well inside the bound.
let wide = |channels: u32, frames: u32| {
let fields = [&be(&[72])[..], &48_000f64.to_be_bytes(), &be(&[channels, 0x7f00_0000, 16, 4, 2 * channels, frames])].concat();
sample_entry(b"lpcm", 2, &fields, &[])
};
assert!(decode(&mp4(480, &wide(2, 1 << 13), |_, a| a), None).is_ok());
}
#[test]
fn an_audio_timing_table_with_a_run_per_sample_is_bounded() {
let pcm = sample_entry(b"sowt", 0, &[], &[]);
// One stts run per sample: symphonia scans the runs from the start for every packet.
let runs = |n: u32| atom(b"stts", &[&[0; 4][..], &n.to_be_bytes(), &[1u32, 1].map(u32::to_be_bytes).concat().repeat(n as usize)[..]].concat());
let frames = 1 << 14;
let (_, buf) = decode(&mp4(frames, &pcm, |k, a| if k == b"stts" { runs(frames) } else { a }), None).unwrap();
assert_eq!(buf.frames(), frames as usize, "a table at the bound still decodes");
let f = mp4(frames + 1, &pcm, |k, a| if k == b"stts" { runs(frames + 1) } else { a });
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("timing table")), "one run past the bound is refused");
}
#[test]
fn the_audio_timing_bound_follows_the_sample_entry_not_the_handler() {
let pcm = sample_entry(b"sowt", 0, &[], &[]);
let runs = |n: u32| atom(b"stts", &[&[0; 4][..], &n.to_be_bytes(), &[1u32, 1].map(u32::to_be_bytes).concat().repeat(n as usize)[..]].concat());
// an audio sample entry under a video handler is still decoded as audio by symphonia
let video_handler = atom(b"hdlr", &[0u32, 0, u32::from_be_bytes(*b"vide"), 0, 0, 0, 0].map(u32::to_be_bytes).concat());
let frames = (1 << 14) + 1;
let f = mp4(frames, &pcm, |k, a| match k {
b"stts" => runs(frames),
b"hdlr" => video_handler.clone(),
_ => a,
});
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("timing table")), "the handler does not exempt an audio entry");
}
#[test]
fn a_pcm_packet_longer_than_its_declared_frames_is_refused_before_decoding() {
let pcm = sample_entry(b"sowt", 0, &[], &[]);
// one `stsz` sample of 2 * 2^24 + 2 bytes (just over the packet bound) in a file that holds it
let big = 2 * (1usize << 24) + 2;
let stsz = atom(b"stsz", &[0u32, big as u32, 1].map(u32::to_be_bytes).concat());
let mut f = mp4(1, &pcm, |k, a| if k == b"stsz" { stsz.clone() } else { a });
f.resize(f.len() + big, 0);
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("packet holds far more samples")));
}
/// FLAC metadata blocks with field lengths exceeding their block boundary are refused as malformed.
+1 -1
View File
@@ -103,7 +103,7 @@ fn decode_group_audio(
let bytes = crate::io::read_file(&full)?;
let (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?;
let buf = crate::io::at_rate(buf, rate, crate::io::MAX_IMPORT_SAMPLES)
.map_err(|r| EngineError::Io(format!("{rel}: {}", r.message("too large to import"))))?;
.ok_or_else(|| EngineError::Io(format!("{rel}: too large to import")))?;
decoded.push((rel.clone(), buf));
index.insert(key, decoded.len() - 1);
decoded.len() - 1
+1 -1
View File
@@ -75,7 +75,7 @@ pub(crate) fn attach(e: &mut Engine, t: TrackId, name: &str, bytes: &[u8], path:
return Err(EngineError::Io(format!("{name}: no audio")));
}
let buf = crate::io::at_rate(buf, e.session().sample_rate.hz(), MAX_SAMPLE_VALUES)
.map_err(|r| EngineError::Io(format!("{name}: {}", r.message("too long for a sample (ten minutes of 192 kHz stereo at most)"))))?;
.ok_or_else(|| EngineError::Io(format!("{name}: too long for a sample (ten minutes of 192 kHz stereo at most)")))?;
let channels = buf.num_channels();
let s = e.session_mut();
let src = crate::io::add_source(s, name, buf, path, format.format);
+7 -51
View File
@@ -7,48 +7,18 @@ use super::{
/// Largest import we accept (samples per channel × channels), ~4 GiB of f32.
pub(crate) const MAX_IMPORT_SAMPLES: usize = 1 << 30;
/// The most a file's audio may grow when converted to the session rate. Sessions run at 8 kHz to
/// 768 kHz (96×); past this a file is lying about its rate, and converting a few kilobytes would
/// otherwise allocate and filter hundreds of megabytes.
const MAX_RATE_EXPANSION: u32 = 128;
/// Why [`at_rate`] refused a conversion.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum RateError {
/// The converted audio would hold more samples than the limit.
TooLarge,
/// The file's rate is so far below the session's that converting would grow it past [`MAX_RATE_EXPANSION`].
Ratio { from: u32, to: u32 },
}
impl RateError {
/// The refusal as a sentence: `size` says what a size refusal means to the caller.
pub(crate) fn message(&self, size: &str) -> String {
match self {
RateError::TooLarge => size.to_string(),
RateError::Ratio { from, to } => {
format!("its {from} Hz sample rate is too low to convert to the session's {to} Hz (at most {MAX_RATE_EXPANSION}-fold)")
}
}
}
}
/// `buf` converted to `rate`, unless it would then hold more than `limit` samples, or grow more
/// than [`MAX_RATE_EXPANSION`]-fold: a file with a tiny sample rate grows by orders of magnitude.
pub(crate) fn at_rate(mut buf: AudioBuffer, rate: u32, limit: usize) -> std::result::Result<AudioBuffer, RateError> {
/// `buf` converted to `rate`, unless it would then hold more than `limit` samples: a file with a
/// tiny sample rate grows by orders of magnitude.
pub(crate) fn at_rate(mut buf: AudioBuffer, rate: u32, limit: usize) -> Option<AudioBuffer> {
let from = buf.sample_rate.max(1);
// widened integers: dividing first would truncate 128.86-fold down to 128
if u64::from(rate) > u64::from(from) * u64::from(MAX_RATE_EXPANSION) {
return Err(RateError::Ratio { from, to: rate });
}
if buf.frames() as f64 * buf.num_channels() as f64 * f64::from(rate) / f64::from(from) > limit as f64 {
return Err(RateError::TooLarge);
return None;
}
if from != rate {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, from, rate);
buf.sample_rate = rate;
}
Ok(buf)
Some(buf)
}
/// A relative `Audio Files/` path no other source in `s` already uses.
@@ -145,7 +115,7 @@ pub fn decode_import(path: &str, rate: u32) -> Result<DecodedImport> {
fn decode_import_bytes(name: &str, bytes: &[u8], rate: u32) -> Result<(soundcraft_audio_io::AudioInfo, Arc<SourceAudio>)> {
let ext = Path::new(name).extension().and_then(OsStr::to_str).map(str::to_ascii_lowercase);
let (info, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).map_err(|e| EngineError::Io(format!("{name}: {}", e.message("file is too large to import"))))?;
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?;
if buf.frames() == 0 {
return Err(EngineError::Io(format!("{name}: no audio")));
}
@@ -245,22 +215,8 @@ mod rate_tests {
#[test]
fn a_tiny_sample_rate_is_refused_before_it_is_converted() {
let buf = |rate| AudioBuffer { sample_rate: rate, channels: vec![vec![0.5; 100]; 2] };
assert!(at_rate(buf(1), 48_000, 1_000_000).is_err());
assert!(at_rate(buf(1), 48_000, 1_000_000).is_none());
let ok = at_rate(buf(8_000), 48_000, 1_000_000).unwrap();
assert_eq!((ok.sample_rate, ok.frames()), (48_000, 600));
}
#[test]
fn an_extreme_rate_expansion_is_refused_whatever_the_sample_budget() {
let buf = |rate| AudioBuffer { sample_rate: rate, channels: vec![vec![0.5; 100]; 2] };
// 100 frames at 300 Hz would fit the budget as 128 000 samples, but grow 640-fold.
assert_eq!(at_rate(buf(300), 192_000, usize::MAX).unwrap_err(), RateError::Ratio { from: 300, to: 192_000 });
assert!(at_rate(buf(1_500), 192_000, usize::MAX).is_ok(), "128-fold is the supported extreme");
assert!(at_rate(buf(8_000), 768_000, usize::MAX).is_ok(), "the slowest file at the fastest session");
// 128.86-fold: integer division would truncate it to 128 and let it through
assert_eq!(at_rate(buf(1_490), 192_000, usize::MAX).unwrap_err(), RateError::Ratio { from: 1_490, to: 192_000 });
// a short file that is only over the sample budget is a size refusal, not a rate one
assert_eq!(at_rate(buf(48_000), 48_000, 10).unwrap_err(), RateError::TooLarge);
assert!(RateError::Ratio { from: 300, to: 192_000 }.message("big").contains("300 Hz"));
}
}
+1 -1
View File
@@ -72,7 +72,7 @@ pub(crate) fn open_session_linked(e: &mut Engine, path: &Path, links: &Path) ->
/// Decode one source's audio file at the session rate.
pub(super) fn decode_source(bytes: &[u8], ext: Option<&str>, rate: u32) -> std::result::Result<Arc<SourceAudio>, String> {
let (_, buf) = soundcraft_audio_io::decode(bytes, ext).map_err(|err| err.to_string())?;
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).map_err(|e| e.message("too large to load"))?;
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).ok_or("too large to load")?;
Ok(Arc::new(SourceAudio::new(buf)))
}