Compare commits

..
5 changed files with 125 additions and 177 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.
+2 -59
View File
@@ -87,23 +87,16 @@ fn decode_group_audio(
cmd_id: &str,
) -> Result<(Vec<(String, soundcraft_audio_io::AudioBuffer)>, BTreeMap<u64, usize>)> {
let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new());
// The folder as the file system knows it, so a link among the group's files can't lead out.
let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
let root = crate::io::canonical_path(dir)?;
for (old, rel) in audio_entries {
let full = dir.join(rel);
let escapes = |rel: &str| EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}"));
if !full.starts_with(dir)
|| Path::new(rel)
.components()
.any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_)))
{
return Err(escapes(rel));
}
let key = crate::io::canonical_path(&full)?;
if !key.starts_with(&root) {
return Err(escapes(rel));
return Err(EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}")));
}
let key = crate::io::file_key(&full);
let n = match index.get(&key) {
Some(n) => *n,
None => {
@@ -275,59 +268,9 @@ mod tests {
std::fs::write(&path, file.to_string()).unwrap();
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
assert!(matches!(r, Err(crate::EngineError::BadParams(..))), "absolute paths are refused: {r:?}");
// A link inside the folder that leads out of it is refused once resolved.
#[cfg(unix)]
{
let outside = std::env::temp_dir().join(format!("sc-grp-outside-{}.wav", std::process::id()));
std::fs::copy(dir.join("Wait... what_0.wav"), &outside).unwrap();
std::os::unix::fs::symlink(&outside, dir.join("link.wav")).unwrap();
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
*rel = json!("link.wav");
}
std::fs::write(&path, file.to_string()).unwrap();
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
assert!(matches!(r, Err(crate::EngineError::BadParams(..))), "links out of the folder are refused: {r:?}");
let _ = std::fs::remove_file(&outside);
}
let _ = std::fs::remove_dir_all(&dir);
}
/// Under a read root, a link in the group folder to a folder outside the root is refused the same way
/// whether or not the file asked for exists there, so the group cannot be used to probe for names.
#[cfg(unix)]
#[test]
fn links_out_of_the_read_root_are_refused_alike_for_existing_and_missing_files() {
let base = std::env::temp_dir().join(format!("sc-grp-root-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (dir, outside) = (base.join("group"), base.join("outside"));
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let path = dir.join("drums.scgrp").to_string_lossy().to_string();
let mut e = crate::demo::demo_engine();
let kick = e.session().tracks.iter().find(|t| t.name == "Kick").map(|t| t.id).unwrap();
let ids: Vec<_> = e.session().track(kick).unwrap().clips().iter().take(1).map(|c| c.id.0).collect();
e.execute("file.export_clip_groups", &json!({"path": path, "clips": ids})).unwrap();
let mut file: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let exported = file["audio"].as_object().unwrap().values().next().unwrap().as_str().unwrap().to_string();
std::fs::copy(dir.join(&exported), outside.join("real.wav")).unwrap();
std::os::unix::fs::symlink(&outside, dir.join("out")).unwrap();
let roots = crate::file_access::AutomationRoots::new(&[&dir], &[]).unwrap().unwrap();
let mut refusals = Vec::new();
for target in ["out/real.wav", "out/missing.wav"] {
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
*rel = json!(target);
}
std::fs::write(&path, file.to_string()).unwrap();
let r = crate::file_access::confine(Some(&roots), || {
e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}))
});
refusals.push(r.unwrap_err().to_string());
}
assert!(refusals[0].contains("outside the read root"), "{}", refusals[0]);
assert_eq!(refusals[0].replace("real.wav", "X"), refusals[1].replace("missing.wav", "X"), "existing and missing names are refused alike");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn imported_clips_carry_no_auto_rename_scheme() {
let dir = std::env::temp_dir().join(format!("sc-grp-names-{}", std::process::id()));
+9 -110
View File
@@ -85,17 +85,6 @@ pub(crate) fn make_dir(dir: impl AsRef<Path>) -> Result<()> {
std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))
}
/// `path` with its links followed, inside the automation read root when there is one. The root is
/// checked on the path as named (links followed, a missing tail resolved by its nearest folder)
/// before anything is resolved, so a name that leads out of the root is refused the same way
/// whether or not it exists there.
pub(crate) fn canonical_path(path: impl AsRef<Path>) -> Result<PathBuf> {
let path = path.as_ref();
let checked = if path.as_os_str().is_empty() { Path::new(".") } else { path };
crate::file_access::check_read(checked).map_err(EngineError::Io)?;
std::fs::canonicalize(path).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))
}
/// Write `bytes` to the file at `path`, inside the automation write root when there is one.
pub(crate) fn write_path(path: impl AsRef<Path>, bytes: impl AsRef<[u8]>) -> Result<()> {
let path = path.as_ref();
@@ -340,7 +329,7 @@ impl Out {
if cfg!(target_arch = "wasm32") {
return Ok(Out::Memory(std::io::Cursor::new(Vec::new())));
}
Ok(Out::File(std::io::BufWriter::with_capacity(1 << 20, create_new(part)?)))
Ok(Out::File(std::io::BufWriter::with_capacity(1 << 20, std::fs::File::create(part)?)))
}
/// Puts the finished file at `path` and returns its size: the part file is synced and renamed
@@ -473,27 +462,14 @@ pub fn export_clips(e: &Engine, ids: &[soundcraft_model::ClipId], dir: &str) ->
let stem = sanitize_name(&c.name);
let mut path = Path::new(dir).join(format!("{stem}.wav"));
let mut k = 2u32;
// The name is taken by creating the file, so whatever entry already has it (a link
// to elsewhere included) is left alone rather than written through.
let mut file = loop {
// An occupied name is taken whatever it is (a link whose target is outside the write
// root too): it is only looked at, never resolved, so the root check does not stop the
// search for a free name. The folder itself was checked above.
if std::fs::symlink_metadata(&path).is_err() {
crate::file_access::check_write(&path).map_err(EngineError::Io)?;
match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(f) => break f,
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(err) => return Err(EngineError::Io(format!("{}: {err}", path.display()))),
}
}
while path.exists() {
path = Path::new(dir).join(format!("{stem}-{k}.wav"));
k = k.saturating_add(1);
if k > 10_000 {
return Err(EngineError::Io(format!("{dir}: too many files named {stem}")));
}
};
std::io::Write::write_all(&mut file, &bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?;
}
write_path(&path, bytes)?;
n += 1;
}
Ok(n)
@@ -530,13 +506,13 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
{
src.path = f.join(&src.path).to_string_lossy().into_owned();
}
let exists_rel = same_dir && !Path::new(&src.path).is_absolute() && file_found(dir.join(&src.path));
let exists_rel = same_dir && !Path::new(&src.path).is_absolute() && dir.join(&src.path).exists();
let shared = shared_paths.get(i).copied().unwrap_or(false);
if exists_rel && !src.unsaved && !shared {
continue;
}
let in_folder = Path::new(&src.path).strip_prefix(&audio_dir).ok().and_then(Path::to_str).map(str::to_string);
if let Some(f) = in_folder.filter(|_| !src.unsaved && file_found(&src.path)) {
if let Some(f) = in_folder.filter(|_| !src.unsaved && Path::new(&src.path).is_file()) {
src.path = format!("Audio Files/{f}");
continue;
}
@@ -552,7 +528,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
if overwrite {
// One file per source id so autosaves never collide or pile up.
stem = format!("{stem}_{}", src.id.0);
if file_found(audio_dir.join(format!("{stem}.wav"))) {
if audio_dir.join(format!("{stem}.wav")).exists() {
src.path = format!("Audio Files/{stem}.wav");
src.unsaved = false;
files.insert(Arc::as_ptr(audio), src.path.clone());
@@ -612,22 +588,11 @@ fn sync_dir(dir: &Path) {
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut f = create_new(path)?;
let mut f = std::fs::File::create(path)?;
f.write_all(bytes)?;
f.sync_all()
}
/// Creates `path` as a new file. A leftover of that name (a part file from an interrupted write)
/// is removed first, but an existing entry is never opened: a link planted at a predictable name
/// in the destination folder can't redirect the write elsewhere.
fn create_new(path: &Path) -> std::io::Result<std::fs::File> {
match std::fs::remove_file(path) {
Err(err) if err.kind() != std::io::ErrorKind::NotFound => return Err(err),
_ => {}
}
std::fs::OpenOptions::new().write(true).create_new(true).open(path)
}
/// Copies `from` to `to`, which must not exist yet; an existing entry, even a link, is never followed.
fn copy_new(from: &Path, to: &Path) -> std::io::Result<()> {
let mut src = std::fs::File::open(from)?;
@@ -722,12 +687,7 @@ fn store_wav(dir: &Path, stem: &str, bytes: &[u8]) -> Result<(String, bool)> {
return Ok((name, true));
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
// Reused only when it may be read: whether it equals the audio must not be
// learnt about a file the roots allow writing beside but not reading.
if crate::file_access::check_read(&path).is_ok()
&& std::fs::metadata(&path).is_ok_and(|m| m.len() == bytes.len() as u64)
&& std::fs::read(&path).is_ok_and(|b| b == bytes)
{
if std::fs::metadata(&path).is_ok_and(|m| m.len() == bytes.len() as u64) && std::fs::read(&path).is_ok_and(|b| b == bytes) {
return Ok((name, false));
}
}
@@ -825,67 +785,6 @@ pub fn plan_takes(e: &Engine, unsaved_dir: &Path, input_channels: usize) -> Vec<
.collect()
}
#[cfg(test)]
mod exclusive_tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("soundcraft-io-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[cfg(unix)]
#[test]
fn a_planted_link_at_the_part_name_is_not_written_through() {
let dir = scratch("part-link");
let (target, part) = (dir.join("elsewhere"), dir.join("session.scraft.tmp"));
std::os::unix::fs::symlink(&target, &part).unwrap();
write_synced(&part, b"new").unwrap();
assert!(!target.exists(), "the link's target was created");
assert_eq!(std::fs::read(&part).unwrap(), b"new", "the link itself was replaced by the file");
// A leftover regular part file is still replaced.
write_synced(&part, b"newer").unwrap();
assert_eq!(std::fs::read(&part).unwrap(), b"newer");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn export_skips_a_name_held_by_a_dangling_link() {
let dir = scratch("export-link");
let e = crate::demo::demo_engine();
let (t, c) = e.session().tracks.iter().find_map(|t| t.clips().iter().find(|c| c.is_audio()).map(|c| (t.id, c.clone()))).unwrap();
let _ = t;
let target = dir.join("elsewhere.wav");
std::os::unix::fs::symlink(&target, dir.join(format!("{}.wav", sanitize_name(&c.name)))).unwrap();
assert_eq!(export_clips(&e, &[c.id], &dir.to_string_lossy()).unwrap(), 1);
assert!(!target.exists(), "the export followed the link");
assert!(dir.join(format!("{}-2.wav", sanitize_name(&c.name))).is_file(), "the next free name was used");
let _ = std::fs::remove_dir_all(&dir);
}
/// With a write root, a link out of it that holds the name is still just an occupied name.
#[cfg(unix)]
#[test]
fn export_skips_a_link_out_of_the_write_root_too() {
let dir = scratch("export-root-link");
let e = crate::demo::demo_engine();
let c = e.session().tracks.iter().find_map(|t| t.clips().iter().find(|c| c.is_audio()).cloned()).unwrap();
let elsewhere = scratch("export-root-elsewhere");
let target = elsewhere.join("target.wav");
std::os::unix::fs::symlink(&target, dir.join(format!("{}.wav", sanitize_name(&c.name)))).unwrap();
let roots = crate::file_access::AutomationRoots::new(&[], &[&dir]).unwrap().unwrap();
let n = crate::file_access::confine(Some(&roots), || export_clips(&e, &[c.id], &dir.to_string_lossy())).unwrap();
assert_eq!(n, 1);
assert!(!target.exists(), "the export followed the link out of the root");
assert!(dir.join(format!("{}-2.wav", sanitize_name(&c.name))).is_file(), "the next free name was used");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&elsewhere);
}
}
#[cfg(test)]
mod stem_tests {
#[test]