Merge pull request #1370 from ZIDAN44/fix/892-session-json-cap

fix(engine): cap sessions at 256 MiB and parse them without a Value tree (#892)
This commit is contained in:
FredApps authored and GitHub committed 2026-11-05 15:01:35 +01:00
commit 73eeb692fe
8 files changed
+541 -75

No files matched your search

+96 -10
View File
@@ -215,6 +215,8 @@ impl Autosave {
// Copies made in this write, by the audio they hold: sources sharing one decoded buffer
// (a session may name one file under many ids) share one file.
let mut written: BTreeMap<*const SourceAudio, String> = BTreeMap::new();
// Copies still to make, written once the size is checked; their numbers are taken then too.
let (mut fresh, mut next) = (Vec::new(), self.next);
for src in &mut s.sources {
let file = if Path::new(&src.path).is_absolute() { Some(PathBuf::from(&src.path)) } else { base.as_ref().map(|b| b.join(&src.path)) };
let audio = s.pool.get(src.id);
@@ -225,15 +227,13 @@ impl Autosave {
continue;
}
let Some(audio) = audio else { continue };
let name = match self.copies.remove(&src.id) {
Some((copy, name)) if copy.upgrade().is_some_and(|a| Arc::ptr_eq(&a, audio)) => name,
let name = match self.copies.get(&src.id) {
Some((copy, name)) if copy.upgrade().is_some_and(|a| Arc::ptr_eq(&a, audio)) => name.clone(),
_ if written.contains_key(&Arc::as_ptr(audio)) => written.get(&Arc::as_ptr(audio)).cloned().unwrap_or_default(),
_ => {
let name = format!("{} {}.wav", sanitize_name(&src.name), self.next);
self.next += 1;
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None };
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
create_folder(&audio_dir).and_then(|()| write_synced(&audio_dir.join(&name), &bytes)).map_err(|err| io_error(&audio_dir, err))?;
let name = format!("{} {}.wav", sanitize_name(&src.name), next);
next += 1;
fresh.push((name.clone(), Arc::clone(audio)));
name
}
};
@@ -243,6 +243,15 @@ impl Autosave {
copies.insert(src.id, (Arc::downgrade(audio), name));
}
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
// An autosave too large to recover is not written, nor its new copies: the last autosave
// stays as it was, and the failure is reported (#892).
crate::io::session_size("the autosave", text.len() as u64, true)?;
self.next = next;
for (name, audio) in &fresh {
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None };
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
create_folder(&audio_dir).and_then(|()| write_synced(&audio_dir.join(name), &bytes)).map_err(|err| io_error(&audio_dir, err))?;
}
write_atomically(&self.dir.join(ORIGIN), origin.unwrap_or_default().as_bytes())?;
write_atomically(&self.dir.join(SESSION), text.as_bytes())?;
for (_, name) in std::mem::replace(&mut self.copies, copies).into_values() {
@@ -297,9 +306,7 @@ pub fn leftovers(root: &Path) -> Vec<Leftover> {
drop(lock);
match saved {
Ok(saved) if recording || origin_saved.is_none_or(|o| saved > o) => {
let text = std::fs::read_to_string(dir.join(SESSION)).unwrap_or_default();
let named = serde_json::from_str::<serde_json::Value>(&text).ok().and_then(|v| v.get("name")?.as_str().map(str::to_string));
let name = named.unwrap_or_else(|| dir.file_name().unwrap_or_default().to_string_lossy().into_owned());
let name = saved_name(&dir.join(SESSION)).unwrap_or_else(|| dir.file_name().unwrap_or_default().to_string_lossy().into_owned());
out.push(Leftover { dir, name, origin, saved, recording });
}
_ => {
@@ -311,6 +318,21 @@ pub fn leftovers(root: &Path) -> Vec<Leftover> {
out
}
/// The name of the session saved in `file`, read without keeping the rest of it; `None` when the
/// file is missing, not a session, or too large to open (#892).
fn saved_name(file: &Path) -> Option<String> {
use std::io::Read as _;
#[derive(serde::Deserialize)]
struct Named {
name: String,
}
if std::fs::metadata(file).ok()?.len() > crate::io::session_limit() {
return None;
}
let read = std::fs::File::open(file).ok()?.take(crate::io::session_limit());
serde_json::from_reader::<_, Named>(std::io::BufReader::new(read)).ok().map(|n| n.name)
}
/// Open `left` as an unsaved version of the session it came from, so Save writes it back there,
/// with the takes of a recording that never finished placed as Stop would have. Returns the
/// media that couldn't be loaded.
@@ -699,6 +721,70 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// A test folder, removed when the test ends, passed or not.
struct Gone(PathBuf);
impl Drop for Gone {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn an_autosave_too_large_to_open_writes_nothing_and_keeps_the_last() {
// #892, with the limit lowered for this test: a refused autosave makes no audio copies
// and keeps the last autosave and its copies as they were.
let dir = Gone(folder("autosave-too-large"));
let mut e = Engine::default();
record(&mut e, 0.5);
let mut a = Autosave::create(&dir.0.join("Autosave"), "Song").unwrap();
a.write(e.session(), None, None).unwrap();
let state = |a: &Autosave| {
let mut files: Vec<String> =
std::fs::read_dir(a.dir.join("Audio Files")).unwrap().map(|f| f.unwrap().file_name().to_string_lossy().into_owned()).collect();
files.sort();
let copies: Vec<(SourceId, String)> = a.copies.iter().map(|(id, (_, name))| (*id, name.clone())).collect();
(files, copies, a.next, std::fs::read_to_string(a.dir.join(SESSION)).unwrap())
};
let before = state(&a);
record(&mut e, 0.25);
{
let _limit = crate::io::SessionLimit::set(before.3.len() as u64);
for _ in 0..2 {
let err = a.write(e.session(), None, None).unwrap_err().to_string();
assert!(err.starts_with("the autosave is too large to save"), "{err}");
assert!(state(&a) == before);
}
// The writer thread reports it as it reports any failed autosave.
let failure = Arc::new(Mutex::new(None));
let pending = Some((e.session_arc(), None, None));
let mut w = Writer { root: dir.0.join("Writer"), failure: Arc::clone(&failure), folder: None, pending, last: None, written: Ok(()) };
w.write_pending();
let reported = failure.lock().unwrap().take().unwrap_or_default();
assert!(reported.starts_with("the autosave is too large to save"), "{reported}");
}
a.write(e.session(), None, None).unwrap();
let after = state(&a);
assert_eq!((after.0.len(), after.1.len()), (2, 2));
assert!(after.0.contains(&before.0[0]));
}
#[test]
fn a_leftover_is_named_from_its_session_without_reading_a_huge_one() {
// #892: the name is read on its own, and an autosave too large to open is not read.
let dir = Gone(folder("autosave-names"));
std::fs::create_dir_all(&dir.0).unwrap();
let file = dir.0.join(SESSION);
let s = Session { name: "Song".into(), ..Session::default() };
std::fs::write(&file, s.to_json().unwrap()).unwrap();
assert_eq!(saved_name(&file).as_deref(), Some("Song"));
std::fs::write(&file, r#"{"name": 5}"#).unwrap();
assert_eq!(saved_name(&file), None);
std::fs::File::create(&file).unwrap().set_len(crate::io::MAX_SESSION_BYTES + 1).unwrap();
assert_eq!(saved_name(&file), None);
assert_eq!(saved_name(&dir.0.join("none")), None);
}
#[test]
fn sessions_started_together_get_folders_of_their_own() {
// #579: the folder name has millisecond resolution, so two sessions created back to back
+2
View File
@@ -45,6 +45,8 @@ pub fn specs() -> Vec<CommandSpec> {
s.sources.clear();
s.videos.clear();
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
// A template too large to open is not saved (#892).
crate::io::session_size(file.display(), text.len() as u64, true)?;
if cfg!(target_arch = "wasm32") {
crate::io::write_file(&file.to_string_lossy(), text.as_bytes())?;
} else {
+1 -1
View File
@@ -472,7 +472,7 @@ fn midi_inputs(e: &mut Engine, p: &Value) -> Result<Value> {
fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "file.import_session_data";
let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(id, "`path` required"))?.to_string();
let text_v = crate::io::read_text(&path)?;
let text_v = crate::io::read_session_text(&path)?;
let other = Session::from_json(&text_v).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
let names: Option<Vec<String>> =
p.get("tracks").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
+156 -18
View File
@@ -42,19 +42,122 @@ pub use strip::{StripMode, StripNames, StripScan, bounded_to_track, name_strippe
/// one. Only a regular file qualifies: a device or a pipe named by a session or a command would
/// never end or never answer. Errors name the file.
pub fn read_file(path: impl AsRef<Path>) -> Result<Vec<u8>> {
let path = path.as_ref();
read_file_within(path.as_ref(), false)
}
/// [`read_file`]; a `session` file is refused ([`session_size`]) past [`session_limit`].
fn read_file_within(path: &Path, session: bool) -> Result<Vec<u8>> {
use std::io::Read as _;
crate::file_access::check_read(path).map_err(EngineError::Io)?;
let err = |err: std::io::Error| EngineError::Io(format!("{}: {err}", path.display()));
if !std::fs::metadata(path).map_err(err)?.is_file() {
let meta = std::fs::metadata(path).map_err(err)?;
if !meta.is_file() {
return Err(EngineError::Io(format!("{}: not a regular file", path.display())));
}
std::fs::read(path).map_err(err)
if !session {
return std::fs::read(path).map_err(err);
}
session_size(path.display(), meta.len(), false)?;
// The file may have grown since: never read more than one byte past the limit.
let mut data = Vec::new();
std::fs::File::open(path).map_err(err)?.take(session_limit().saturating_add(1)).read_to_end(&mut data).map_err(err)?;
session_size(path.display(), data.len() as u64, false)?;
Ok(data)
}
/// [`read_file`] as UTF-8 text.
pub fn read_text(path: impl AsRef<Path>) -> Result<String> {
let path = path.as_ref();
String::from_utf8(read_file(path)?).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))
utf8(path, read_file(path)?)
}
fn utf8(path: &Path, bytes: Vec<u8>) -> Result<String> {
String::from_utf8(bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))
}
/// The largest session (`.scraft`) SoundCraft opens or saves. A session is read whole and parsed
/// in one go, so a larger one is refused by its size before it is read, and never written (it
/// could not be opened again); real sessions are far smaller.
pub const MAX_SESSION_BYTES: u64 = 256 << 20;
#[cfg(test)]
thread_local! {
/// A smaller limit than [`MAX_SESSION_BYTES`] for the tests on this thread ([`SessionLimit`]).
static TEST_SESSION_LIMIT: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) };
}
/// [`MAX_SESSION_BYTES`] (in tests, the limit a [`SessionLimit`] set on this thread).
pub(crate) fn session_limit() -> u64 {
#[cfg(test)]
if let Some(limit) = TEST_SESSION_LIMIT.with(std::cell::Cell::get) {
return limit;
}
MAX_SESSION_BYTES
}
/// While it lives, sessions on this thread are opened and saved up to the limit given to
/// [`SessionLimit::set`] instead of [`MAX_SESSION_BYTES`], so tests reach it with small sessions.
#[cfg(test)]
pub(crate) struct SessionLimit;
#[cfg(test)]
impl SessionLimit {
pub(crate) fn set(limit: u64) -> SessionLimit {
TEST_SESSION_LIMIT.with(|l| l.set(Some(limit)));
SessionLimit
}
}
#[cfg(test)]
impl Drop for SessionLimit {
fn drop(&mut self) {
TEST_SESSION_LIMIT.with(|l| l.set(None));
}
}
/// Refuses the session `name` of `len` bytes when it is larger than [`session_limit`]: too large
/// to open, or, when `saving`, to save.
pub(crate) fn session_size(name: impl std::fmt::Display, len: u64, saving: bool) -> Result<()> {
let limit = session_limit();
if len <= limit {
return Ok(());
}
let most = if limit >= 1 << 20 { format!("{} MiB", limit >> 20) } else { format!("{limit} bytes") };
Err(EngineError::Io(if saving {
format!("{name} is too large to save: over {most}, more than SoundCraft opens")
} else {
format!("{name} is too large to open (over {most})")
}))
}
/// [`read_text`] for a session file: refused, unread, past [`session_limit`].
pub(crate) fn read_session_text(path: impl AsRef<Path>) -> Result<String> {
let path = path.as_ref();
utf8(path, read_file_within(path, true)?)
}
/// A bare `.scraft` handed over as bytes (the browser) as text: refused, uncopied, past
/// [`session_limit`].
pub(crate) fn session_bytes_text(name: &str, bytes: &[u8]) -> Result<String> {
session_size(name, bytes.len() as u64, false)?;
String::from_utf8(bytes.to_vec()).map_err(|_| EngineError::Io(format!("{name}: not a SoundCraft session")))
}
/// How many bytes [`Session::to_json`] makes of `s`, counted without keeping them.
fn json_len(s: &Session) -> u64 {
struct Count(u64);
impl std::io::Write for Count {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0 = self.0.saturating_add(buf.len() as u64);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let mut n = Count(0);
// A session that cannot be written counts as nothing here: `to_json` reports that error.
serde_json::to_writer_pretty(&mut n, s).map_or(0, |()| n.0)
}
/// Is there a file at `path` the automation read root lets us see? A path outside the root is
@@ -497,7 +600,6 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
let mut s = session.clone();
let from = s.folder.clone();
let same_dir = from.as_deref().is_some_and(|f| same_path(f, &dir));
let mut written = 0;
// A file can stay put for all the sources that name it, unless their audio differs.
let shared_paths: Vec<bool> = {
let mut audio_by_path: HashMap<&str, HashSet<Option<*const SourceAudio>>> = HashMap::new();
@@ -506,8 +608,11 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
}
s.sources.iter().map(|src| audio_by_path.get(src.path.as_str()).is_some_and(|a| a.len() > 1)).collect()
};
// Files written for audio during this save: sources playing the same audio share one.
// Files for audio in this save: sources playing the same audio share one. The files still to
// write (their stem, audio and the sources naming them) are written once the size is checked.
let mut files: HashMap<*const SourceAudio, String> = HashMap::new();
let mut queued: HashMap<*const SourceAudio, usize> = HashMap::new();
let mut pending: Vec<(String, Arc<SourceAudio>, Vec<usize>)> = Vec::new();
for (i, src) in s.sources.iter_mut().enumerate() {
if !same_dir
&& s.pool.get(src.id).is_none()
@@ -533,7 +638,14 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
src.format = FileFormat::Wav;
continue;
}
make_dir(&audio_dir)?;
if let Some((stem, _, sources)) = queued.get(&Arc::as_ptr(audio)).and_then(|&k| pending.get_mut(k)) {
// As long as any name the file can get, so the size checked is an upper bound.
src.path = format!("Audio Files/{stem}_9999.wav");
src.unsaved = false;
src.format = FileFormat::Wav;
sources.push(i);
continue;
}
let mut stem = sanitize_name(&src.name);
if overwrite {
// One file per source id so autosaves never collide or pile up.
@@ -545,23 +657,46 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
continue;
}
}
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
let (name, new) = store_wav(&audio_dir, &stem, &bytes)?;
src.path = format!("Audio Files/{name}");
src.path = format!("Audio Files/{stem}_9999.wav");
src.unsaved = false;
src.format = FileFormat::Wav;
files.insert(Arc::as_ptr(audio), src.path.clone());
written += usize::from(new);
}
if written > 0 {
sync_dir(&audio_dir);
queued.insert(Arc::as_ptr(audio), pending.len());
pending.push((stem, Arc::clone(audio), vec![i]));
}
s.folder = Some(dir.clone());
if !overwrite && let Some(stem) = p.file_stem().and_then(OsStr::to_str) {
s.name = stem.to_string();
}
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
// Before any audio is written: a session too large to open again is not saved (#892). With no
// audio to write, the text checked is the text saved.
let checked = if pending.is_empty() {
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
session_size(p.display(), text.len() as u64, true)?;
Some(text)
} else {
session_size(p.display(), json_len(&s), true)?;
None
};
let mut written = 0;
for (stem, audio, sources) in &pending {
make_dir(&audio_dir)?;
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
let (name, new) = store_wav(&audio_dir, stem, &bytes)?;
for &i in sources {
if let Some(src) = s.sources.get_mut(i) {
src.path = format!("Audio Files/{name}");
}
}
written += usize::from(new);
}
if written > 0 {
sync_dir(&audio_dir);
}
let text = match checked {
Some(text) => text,
None => s.to_json().map_err(|err| EngineError::Io(err.to_string()))?,
};
make_dir(&dir)?;
// Atomic-ish write: temp file then rename.
let tmp = p.with_extension("scraft.tmp");
@@ -762,10 +897,13 @@ fn read_bundle(bytes: &[u8], limits: BundleLimits) -> Result<(String, std::colle
if total > limits.bytes {
return Err(EngineError::Io("bundle: too large".into()));
}
let is_session = !rel.contains('/') && Path::new(&rel).extension().and_then(OsStr::to_str) == Some(soundcraft_model::SESSION_EXTENSION);
if is_session && text.is_none() {
session_size(format_args!("bundle: {rel}"), size, false)?;
}
let mut data = Vec::new();
// The declared size is hostile too: never read past it.
f.by_ref().take(size).read_to_end(&mut data).map_err(|err| EngineError::Io(format!("bundle: {rel}: {err}")))?;
let is_session = !rel.contains('/') && Path::new(&rel).extension().and_then(OsStr::to_str) == Some(soundcraft_model::SESSION_EXTENSION);
if is_session && text.is_none() {
text = Some(String::from_utf8(data).map_err(|_| EngineError::Io(format!("bundle: {rel} is not text")))?);
} else {
+35 -4
View File
@@ -69,6 +69,8 @@ fn session_bundle_within(session: &Session, limits: BundleLimits) -> Result<(Vec
src.format = FileFormat::Wav;
}
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
// Its session too: one the reader would refuse is not saved (#892).
super::session_size("the session", text.len() as u64, true)?;
fits(text.len())?;
w.start_file(format!("{}.{}", sanitize_name(&s.name), soundcraft_model::SESSION_EXTENSION), opts).map_err(zerr)?;
w.write_all(text.as_bytes()).map_err(ioerr)?;
@@ -100,10 +102,7 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Ve
let (text, mut files) = if bytes.starts_with(b"PK\x03\x04") {
read_bundle(bytes, BUNDLE_LIMITS)?
} else {
(
String::from_utf8(bytes.to_vec()).map_err(|_| EngineError::Io(format!("{name}: not a SoundCraft session")))?,
std::collections::HashMap::new(),
)
(super::session_bytes_text(name, bytes)?, std::collections::HashMap::new())
};
let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
let mut missing = Vec::new();
@@ -149,6 +148,38 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Ve
mod bundle_tests {
use super::*;
#[test]
fn a_session_too_large_to_parse_is_refused_from_bytes() {
// #892: a session the browser hands over as bytes has the cap a session file has. The
// bytes are zeroed on allocation and never read, so they take no memory.
let huge = vec![0u8; usize::try_from(super::super::MAX_SESSION_BYTES).unwrap() + 1];
let err = open_session_bytes(&mut Engine::default(), "Huge.scraft", &huge).unwrap_err().to_string();
assert_eq!(err, "Huge.scraft is too large to open (over 256 MiB)");
}
#[test]
fn bundles_and_bare_sessions_keep_to_the_session_limit_both_ways() {
// #892, with the limit lowered for this test: a bundle is saved only if its session can
// be opened again (#433), and its reader and the bare-bytes reader refuse one past it.
let e = crate::demo::demo_engine();
let (bundle, fresh) = session_bundle(e.session()).unwrap();
let inside = fresh.to_json().unwrap().len() as u64;
let bare = e.session().to_json().unwrap();
{
let _limit = super::super::SessionLimit::set(inside.max(bare.len() as u64));
assert!(session_bundle(e.session()).is_ok());
assert!(open_session_bytes(&mut Engine::default(), "Song.scraftz", &bundle).is_ok());
assert!(open_session_bytes(&mut Engine::default(), "Song.scraft", bare.as_bytes()).is_ok());
}
let _limit = super::super::SessionLimit::set(inside.min(bare.len() as u64) - 1);
let err = session_bundle(e.session()).unwrap_err().to_string();
assert!(err.starts_with("the session is too large to save"), "{err}");
let err = open_session_bytes(&mut Engine::default(), "Song.scraftz", &bundle).unwrap_err().to_string();
assert!(err.starts_with("bundle: ") && err.contains(".scraft is too large to open"), "{err}");
let err = open_session_bytes(&mut Engine::default(), "Song.scraft", bare.as_bytes()).unwrap_err().to_string();
assert!(err.starts_with("Song.scraft is too large to open"), "{err}");
}
#[test]
fn bundle_round_trips_session_and_audio() {
let mut e = crate::demo::demo_engine();
+92 -2
View File
@@ -3,7 +3,7 @@ use super::bundle::save_session_bundle;
use super::import::add_source;
use super::import::{MAX_IMPORT_SAMPLES, at_rate, relink_videos};
use super::{
Arc, Engine, EngineError, OsStr, Path, PathBuf, Result, Session, SourceAudio, file_key, read_file, read_text, stored_path, write_session,
Arc, Engine, EngineError, OsStr, Path, PathBuf, Result, Session, SourceAudio, file_key, read_file, read_session_text, stored_path, write_session,
};
/// Save the session to `path` (a `.scraft` file). Audio that only exists in memory is written
@@ -44,7 +44,7 @@ pub fn open_session(e: &mut Engine, path: impl AsRef<Path>) -> Result<Vec<String
/// [`open_session`], following the media paths in it as if they were stored in the session file
/// `links` (crash recovery: the session written beside the autosave, its paths from the original).
pub(crate) fn open_session_linked(e: &mut Engine, path: &Path, links: &Path) -> Result<Vec<String>> {
let text = read_text(path)?;
let text = read_session_text(path)?;
let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(err.to_string()))?;
let dir = path.parent().map(Path::to_path_buf).unwrap_or_default();
let mut missing = Vec::new();
@@ -98,6 +98,96 @@ mod save_tests {
use super::*;
use serde_json::json;
/// A new, empty scratch folder, removed again when the test ends, passed or not.
struct Scratch(std::path::PathBuf);
impl Scratch {
fn new(tag: &str) -> Scratch {
let dir = std::env::temp_dir().join(format!("soundcraft-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Scratch(dir)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn a_session_file_too_large_to_parse_is_refused_unread() {
// #892: a session file is read whole and parsed; past the cap it is refused by its size.
// The file's length is set, not written, so it takes no time or space to make.
let dir = Scratch::new("huge-session");
let path = dir.0.join("Huge.scraft");
std::fs::File::create(&path).unwrap().set_len(crate::io::MAX_SESSION_BYTES + 1).unwrap();
let mut e = crate::demo::demo_engine();
let err = open_session(&mut e, &path).unwrap_err().to_string();
assert_eq!(err, format!("{} is too large to open (over 256 MiB)", path.display()));
assert_eq!(e.session().tracks.len(), crate::demo::demo_engine().session().tracks.len());
let err = e.execute("file.import_session_data", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string();
assert!(err.contains("too large to open"), "{err}");
}
#[test]
fn a_session_too_large_to_open_is_not_saved_and_leaves_nothing_behind() {
// #892: what SoundCraft saves, it opens again. A refused save writes no audio and no
// session; the limit is lowered for this test so a small session reaches it.
let dir = Scratch::new("refused-save");
let path = |p: &str| dir.0.join(p).to_string_lossy().into_owned();
save_session(&mut with_take(0.5), &path("A/Song.scraft")).unwrap();
let saved = std::fs::metadata(path("A/Song.scraft")).unwrap().len();
let untouched = |sub: &str| assert!(!dir.0.join(sub).exists(), "{sub} was written");
{
let _limit = crate::io::SessionLimit::set(saved - 1);
let mut e = with_take(0.5);
let err = save_session(&mut e, &path("B/Song.scraft")).unwrap_err().to_string();
assert_eq!(err, format!("{} is too large to save: over {} bytes, more than SoundCraft opens", path("B/Song.scraft"), saved - 1));
assert!(e.is_dirty() && e.path.is_none());
let err = e.execute("session.save_copy", &json!({"path": path("C/Song.scraft")})).unwrap_err().to_string();
assert!(err.contains("too large to save"), "{err}");
for sub in ["B", "C"] {
untouched(sub);
}
// An open refuses the session saved before, now over the limit.
let err = open_session(&mut Engine::default(), path("A/Song.scraft")).unwrap_err().to_string();
assert!(err.contains("too large to open"), "{err}");
}
{
// A template holds no audio or clips: a smaller limit refuses it.
let _limit = crate::io::SessionLimit::set(100);
std::fs::create_dir_all(dir.0.join("D")).unwrap();
let err = with_take(0.5).execute("session.save_template", &json!({"path": path("D/Song.scraft")})).unwrap_err().to_string();
assert!(err.contains("too large to save"), "{err}");
untouched("D/Song.scraft");
}
// Checked before the audio file is named, against the longest name it can get: a
// session at the limit plus that margin is saved, and is the same size as before.
let _limit = crate::io::SessionLimit::set(saved + 5);
save_session(&mut with_take(0.5), &path("B/Song.scraft")).unwrap();
assert_eq!(std::fs::metadata(path("B/Song.scraft")).unwrap().len(), saved);
assert_eq!(reopen(&path("B/Song.scraft")).session().sources.len(), 1);
}
#[test]
fn a_saved_session_parses_as_it_did_through_a_tree() {
// #892: parsing the text directly gives the document the `Value` round trip gave.
let mut e = crate::demo::demo_engine();
e.execute("mix.volume", &json!({"track": "Kick", "db": -7.0})).unwrap();
let text = e.session().to_json().unwrap();
let mut old: Session = serde_json::from_value(serde_json::from_str(&text).unwrap()).unwrap();
old.sanitize();
assert_eq!(Session::from_json(&text).unwrap(), old);
let mut r = Engine::default();
let dir = Scratch::new("reparse");
let path = dir.0.join("Song.scraft").to_string_lossy().into_owned();
save_session(&mut e, &path).unwrap();
assert_eq!(open_session(&mut r, &path).unwrap(), Vec::<String>::new());
assert_eq!(r.session().tracks, e.session().tracks);
}
#[test]
fn autosave_copy_leaves_document_untouched_and_reuses_audio() {
let mut e = crate::demo::demo_engine();
+73 -3
View File
@@ -724,13 +724,36 @@ impl Session {
}
/// Parse a session; decoded audio must be attached separately (`pool`).
///
/// The text is parsed straight into the document, never into a `serde_json::Value` tree
/// first (which takes many times the file's size). Only the version is read before that.
/// A field of the document given twice in one object is refused (`duplicate field`), as no
/// writer repeats one. Keys of maps (plugin params, edit values, Auto Rename schemes, MIDI
/// originals) and unknown keys keep the last value given, as before.
pub fn from_json(text: &str) -> Result<Session, SessionError> {
let v: serde_json::Value = serde_json::from_str(text).map_err(|e| SessionError::Format(e.to_string()))?;
let ver = v.get("format_version").and_then(serde_json::Value::as_u64).ok_or_else(|| SessionError::Format("missing format_version".into()))?;
/// The version alone: every other field is skipped, not kept.
#[derive(serde::Deserialize)]
struct Head {
format_version: Option<serde_json::Number>,
}
// `Head` would also take a JSON array as its fields in order: only an object has a version.
let object = text.trim_start_matches([' ', '\t', '\n', '\r']).starts_with('{');
let version = match object.then(|| serde_json::from_str::<Head>(text)) {
Some(Ok(head)) => head.format_version,
// A version given twice is that error, not a missing version.
Some(Err(e)) if e.to_string().starts_with("duplicate field") => return Err(SessionError::Format(e.to_string())),
// Valid JSON of another shape (not an object, or a version that is not a number)
// has no version; anything else is not JSON.
_ => {
serde_json::from_str::<serde::de::IgnoredAny>(text).map_err(|e| SessionError::Format(e.to_string()))?;
None
}
};
let ver = version.and_then(|v| v.as_u64()).ok_or_else(|| SessionError::Format("missing format_version".into()))?;
if ver > u64::from(Self::FORMAT_VERSION) {
return Err(SessionError::Version(u32::try_from(ver).unwrap_or(u32::MAX)));
}
let mut s: Session = serde_json::from_value(v).map_err(|e| SessionError::Format(e.to_string()))?;
let mut s: Session = serde_json::from_str(text).map_err(|e| SessionError::Format(e.to_string()))?;
s.sanitize();
s.validate()?;
Ok(s)
@@ -1140,6 +1163,53 @@ mod tests {
assert!(matches!(Session::from_json(&v.to_string()), Err(SessionError::Version(999))));
}
#[test]
fn the_version_is_checked_as_before() {
// #892: the version is read without building the whole document as a tree; the errors
// stay what they were.
let format = |t: &str| match Session::from_json(t) {
Err(SessionError::Format(m)) => m,
other => panic!("{t}: {other:?}"),
};
for t in [
"{}",
"[]",
"[5]",
" \n[1, {}]",
"\"x\"",
"5",
"null",
r#"{"format_version":"1"}"#,
r#"{"format_version":-1}"#,
r#"{"format_version":1.5}"#,
r#"{"format_version":null}"#,
r#"{"format_version":[0,0,0]}"#,
r#"{"name":"x","other":{"format_version":1}}"#,
] {
assert_eq!(format(t), "missing format_version", "{t}");
}
for t in ["not json", r#"{"format_version":1"#, r#"{"format_version":1} x"#, r#"{"format_version":"1", ]"#] {
let want = serde_json::from_str::<serde_json::Value>(t).unwrap_err().to_string();
assert_eq!(format(t), want, "{t}");
}
let mut v = serde_json::to_value(Session::default()).unwrap();
for (ver, want) in [(serde_json::json!(2), 2), (serde_json::json!(1u64 << 40), u32::MAX)] {
v["format_version"] = ver;
assert_eq!(Session::from_json(&v.to_string()), Err(SessionError::Version(want)));
}
// A field given twice is refused by name, the version too.
let twice = |key: &str| format(&format!(r#"{{"format_version":1,"{key}":"a","{key}":"b"}}"#));
assert!(twice("name").starts_with("duplicate field `name` at line 1 column "), "{}", twice("name"));
assert!(twice("format_version").starts_with("duplicate field `format_version` at line 1 column "), "{}", twice("format_version"));
// A newer version is refused even when the rest would not parse.
assert_eq!(Session::from_json(r#"{"format_version":7,"tracks":5}"#), Err(SessionError::Version(7)));
// An older or the current version loads as it did.
for ver in [0, 1] {
v["format_version"] = serde_json::json!(ver);
assert_eq!(Session::from_json(&v.to_string()).map(|s| s.format_version), Ok(ver));
}
}
#[test]
fn duplicate_ids_are_rejected_on_load() {
let mut s = Session::default();
+86 -37
View File
@@ -83,8 +83,7 @@ impl TrackKind {
}
/// Track heights (View › Track Height presets).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
#[serde(from = "TrackHeightRepr")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize)]
pub enum TrackHeight {
/// Any height from Micro to Extreme, in points.
Custom(u16),
@@ -100,49 +99,69 @@ pub enum TrackHeight {
Fit(u16),
}
/// What a saved track height may look like: the current form, or the bare `"Fit"` string older sessions used.
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum TrackHeightRepr {
Current(TrackHeightCurrent),
LegacyFit(LegacyFit),
/// A saved track height: `"Micro"` … `"Extreme"` (or `{"Micro": null}` …), `{"Custom": points}`,
/// `{"Fit": points}`, or the bare `"Fit"` older sessions used. Read as it comes, never buffered (as an
/// untagged enum would), so anything else in a session file is refused at its first token.
impl<'de> serde::Deserialize<'de> for TrackHeight {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
d.deserialize_any(HeightVisitor)
}
}
#[derive(serde::Deserialize)]
enum LegacyFit {
Fit,
const HEIGHT_VARIANTS: &[&str] = &["Custom", "Micro", "Mini", "Small", "Medium", "Large", "Jumbo", "Extreme", "Fit"];
struct HeightVisitor;
impl HeightVisitor {
/// The height a variant without a size names.
fn preset(name: &str) -> Option<TrackHeight> {
Some(match name {
"Micro" => TrackHeight::Micro,
"Mini" => TrackHeight::Mini,
"Small" => TrackHeight::Small,
"Medium" => TrackHeight::Medium,
"Large" => TrackHeight::Large,
"Jumbo" => TrackHeight::Jumbo,
"Extreme" => TrackHeight::Extreme,
_ => return None,
})
}
}
/// Mirror of `TrackHeight` that derives the current serialized form.
#[derive(serde::Deserialize)]
enum TrackHeightCurrent {
Custom(u16),
Micro,
Mini,
Small,
Medium,
Large,
Jumbo,
Extreme,
Fit(u16),
}
impl<'de> serde::de::Visitor<'de> for HeightVisitor {
type Value = TrackHeight;
impl From<TrackHeightRepr> for TrackHeight {
fn from(repr: TrackHeightRepr) -> Self {
match repr {
TrackHeightRepr::Current(TrackHeightCurrent::Custom(p)) => TrackHeight::Custom(p),
TrackHeightRepr::Current(TrackHeightCurrent::Micro) => TrackHeight::Micro,
TrackHeightRepr::Current(TrackHeightCurrent::Mini) => TrackHeight::Mini,
TrackHeightRepr::Current(TrackHeightCurrent::Small) => TrackHeight::Small,
TrackHeightRepr::Current(TrackHeightCurrent::Medium) => TrackHeight::Medium,
TrackHeightRepr::Current(TrackHeightCurrent::Large) => TrackHeight::Large,
TrackHeightRepr::Current(TrackHeightCurrent::Jumbo) => TrackHeight::Jumbo,
TrackHeightRepr::Current(TrackHeightCurrent::Extreme) => TrackHeight::Extreme,
TrackHeightRepr::Current(TrackHeightCurrent::Fit(p)) => TrackHeight::Fit(p),
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a track height")
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<TrackHeight, E> {
match v {
// Older sessions stored Fit with no size, drawn 88 points tall: the default Medium height.
TrackHeightRepr::LegacyFit(LegacyFit::Fit) => TrackHeight::Medium,
"Fit" => Ok(TrackHeight::Medium),
"Custom" => Err(E::invalid_type(serde::de::Unexpected::UnitVariant, &"a height in points")),
other => Self::preset(other).ok_or_else(|| E::unknown_variant(other, HEIGHT_VARIANTS)),
}
}
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut map: A) -> Result<TrackHeight, A::Error> {
use serde::de::Error as _;
let Some(key) = map.next_key::<String>()? else { return Err(A::Error::invalid_length(0, &"one variant")) };
let h = match key.as_str() {
"Custom" => TrackHeight::Custom(map.next_value()?),
// `{"Fit": null}` is the old size-less Fit too, as above.
"Fit" => map.next_value::<Option<u16>>()?.map_or(TrackHeight::Medium, TrackHeight::Fit),
other => {
let h = Self::preset(other).ok_or_else(|| A::Error::unknown_variant(other, HEIGHT_VARIANTS))?;
map.next_value::<()>()?;
h
}
};
if map.next_key::<serde::de::IgnoredAny>()?.is_some() {
return Err(A::Error::invalid_length(2, &"one variant"));
}
Ok(h)
}
}
impl TrackHeight {
@@ -383,6 +402,36 @@ mod tests {
assert_eq!(serde_json::from_str::<TrackHeight>("{\"Custom\":50}").unwrap(), TrackHeight::Custom(50));
}
#[test]
fn every_saved_height_form_loads_and_nothing_else() {
for h in TrackHeight::ALL.into_iter().chain([TrackHeight::Custom(50), TrackHeight::Fit(120)]) {
assert_eq!(serde_json::from_str::<TrackHeight>(&serde_json::to_string(&h).unwrap()).unwrap(), h);
assert_eq!(serde_json::from_value::<TrackHeight>(serde_json::to_value(h).unwrap()).unwrap(), h);
}
for h in TrackHeight::ALL {
let null = format!("{{{}:null}}", serde_json::to_string(&h).unwrap());
assert_eq!(serde_json::from_str::<TrackHeight>(&null).unwrap(), h, "{null}");
}
assert_eq!(serde_json::from_str::<TrackHeight>(r#"{"Fit":null}"#).unwrap(), TrackHeight::Medium);
assert!(serde_json::from_str::<TrackHeight>(r#"{"Custom":null}"#).is_err());
for bad in
[r#""Custom""#, r#""Huge""#, "7", "[]", "null", "{}", r#"{"Fit":"x"}"#, r#"{"Fit":70000}"#, r#"{"Large":1}"#, r#"{"Fit":1,"Fit":2}"#]
{
assert!(serde_json::from_str::<TrackHeight>(bad).is_err(), "{bad}");
}
}
#[test]
fn a_huge_height_is_refused_at_its_first_token() {
// #892: the height was read through an untagged enum, which buffers the whole value
// first; a session could hold a huge array there. It is refused where it starts.
for (open, rest) in [("[", "0,".repeat(100_000) + "0]"), (r#"{"Fit":["#, "0,".repeat(100_000) + "0]}")] {
let text = format!("{open}{rest}");
let err = serde_json::from_str::<TrackHeight>(&text).unwrap_err();
assert!(err.is_data() && err.line() == 1 && err.column() <= open.len() + 1, "{open}: {err}");
}
}
#[test]
fn new_track_defaults() {
let t = Track::new(TrackId(1), "Gtr", TrackKind::Audio, ChannelFormat::Stereo, TRACK_COLORS[0]);