Compare commits

..
8 changed files with 24 additions and 367 deletions
Generated
+18 -19
View File
@@ -4331,7 +4331,7 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.19.1"
version = "0.17.0"
dependencies = [
"chrono",
"clap",
@@ -4355,7 +4355,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.19.1"
version = "0.17.0"
dependencies = [
"log",
"serde",
@@ -4365,7 +4365,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
version = "0.19.1"
version = "0.17.0"
dependencies = [
"log",
"proptest",
@@ -4377,7 +4377,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
version = "0.19.1"
version = "0.17.0"
dependencies = [
"clap",
"log",
@@ -4390,7 +4390,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.19.1"
version = "0.17.0"
dependencies = [
"clap-sys",
"libloading 0.9.0",
@@ -4403,7 +4403,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.19.1"
version = "0.17.0"
dependencies = [
"clap",
"clap_complete",
@@ -4421,7 +4421,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.19.1"
version = "0.17.0"
dependencies = [
"log",
"proptest",
@@ -4432,7 +4432,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.19.1"
version = "0.17.0"
dependencies = [
"craft-docpath",
"log",
@@ -4450,13 +4450,12 @@ dependencies = [
"soundcraft-video",
"soundcraft-vst3-host",
"thiserror 2.0.21",
"winsafe",
"zip",
]
[[package]]
name = "soundcraft-midi"
version = "0.19.1"
version = "0.17.0"
dependencies = [
"proptest",
"serde",
@@ -4466,7 +4465,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
version = "0.19.1"
version = "0.17.0"
dependencies = [
"log",
"rayon",
@@ -4482,7 +4481,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.19.1"
version = "0.17.0"
dependencies = [
"proptest",
"serde",
@@ -4495,7 +4494,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.19.1"
version = "0.17.0"
dependencies = [
"cpal",
"log",
@@ -4510,7 +4509,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.19.1"
version = "0.17.0"
dependencies = [
"proptest",
"serde",
@@ -4520,7 +4519,7 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.19.1"
version = "0.17.0"
dependencies = [
"craft-ui",
"craft-update",
@@ -4550,7 +4549,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.19.1"
version = "0.17.0"
dependencies = [
"craft-media",
"image",
@@ -4562,7 +4561,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.19.1"
version = "0.17.0"
dependencies = [
"libloading 0.9.0",
"log",
@@ -4578,7 +4577,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.19.1"
version = "0.17.0"
dependencies = [
"eframe",
"egui",
@@ -6226,7 +6225,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.19.1"
version = "0.17.0"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.19.1"
version = "0.17.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -310,36 +310,6 @@ fn links_out_of_the_roots_are_refused() {
ok(&mut via_alias, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))}));
}
#[cfg(any(unix, windows))]
#[test]
fn exports_refuse_hard_links_without_changing_either_name() {
let f = fixture("hard-links");
let midi = f.write.join("linked.mid");
let preset = f.write.join("linked.json");
std::fs::hard_link(f.outside.join("secret.mid"), &midi).unwrap();
std::fs::hard_link(f.outside.join("secret.json"), &preset).unwrap();
let mut srv = demo_server(Some(&f.read), Some(&f.write));
let before = listing(&f.write);
// Refusal is the safe alternative allowed by #1359: neither the outside inode nor its
// in-root alias may be truncated, and no replacement or temporary file may be left behind.
refused(&mut srv, "file.export_midi", json!({"path": s(&midi)}), "hard-linked");
refused(&mut srv, "track.save_preset", json!({"path": s(&preset), "tracks": ["Kick"]}), "hard-linked");
for path in [&midi, &preset, &f.outside.join("secret.mid"), &f.outside.join("secret.json")] {
assert_eq!(std::fs::read(path).unwrap(), SECRET.as_bytes());
}
assert_eq!(listing(&f.write), before);
// The error offers a new export path; both commands must still work at ordinary paths.
let fresh_midi = f.write.join("fresh.mid");
ok(&mut srv, "file.export_midi", json!({"path": s(&fresh_midi)}));
assert!(std::fs::read(fresh_midi).unwrap().starts_with(b"MThd"));
let fresh_preset = f.write.join("fresh.json");
ok(&mut srv, "track.save_preset", json!({"path": s(&fresh_preset), "tracks": ["Kick"]}));
let preset_json: Value = serde_json::from_slice(&std::fs::read(fresh_preset).unwrap()).unwrap();
assert_eq!(preset_json["name"], "Kick");
}
/// A session file is hostile input too: media it names outside the read root is not loaded,
/// and the refusal is listed with the other missing media.
#[test]
-3
View File
@@ -25,9 +25,6 @@ log.workspace = true
zip.workspace = true
craft-docpath.workspace = true
[target.'cfg(windows)'.dependencies]
winsafe = { version = "0.0.29", features = ["kernel"] }
[dev-dependencies]
proptest.workspace = true
-3
View File
@@ -154,7 +154,6 @@ fn insert_time(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
let ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect();
for t in ids {
crate::edit::separate_all_at(s, t, r.start);
crate::edit::insert_silence(s, t, r);
if let Some(tr) = s.track_mut(t) {
for l in &mut tr.automation {
@@ -186,8 +185,6 @@ fn cut_time(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
let ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect();
for t in ids {
crate::edit::separate_all_at(s, t, r.start);
crate::edit::separate_all_at(s, t, r.end);
crate::edit::clear_range(s, t, r, false);
crate::edit::shift_from(s, t, r.end, -r.len());
if let Some(tr) = s.track_mut(t) {
+4 -15
View File
@@ -78,9 +78,9 @@ fn rebase_midi_start(clip: &mut Clip, old_start: Samples, t: TimeCtx<'_>) {
}
}
/// Split every clip on a track's active playlist that spans `at`, except time- or edit-locked ones.
/// The piece before `at` keeps the clip's name. The piece after it is a new clip, and takes the next
/// name of the clip's Auto Rename scheme when it has one.
/// Split every clip on a track's active playlist that spans `at`. The piece before `at` keeps the
/// clip's name. The piece after it is a new clip, and takes the next name of the clip's Auto Rename
/// scheme when it has one.
pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
separate_at_if(s, track, at, |_| true)
}
@@ -88,21 +88,11 @@ pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
/// [`separate_at`], giving the piece after `at` the next Auto Rename name only when `auto_name`
/// returns true for it. Any other piece keeps the clip's name, and its scheme does not count up.
pub fn separate_at_if(s: &mut Session, track: TrackId, at: Samples, auto_name: impl Fn(&Clip) -> bool) -> usize {
split_at(s, track, at, auto_name, true)
}
/// [`separate_at`], splitting time- and edit-locked clips too. For the Event time operations, which
/// move the whole timeline, locked clips included.
pub fn separate_all_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
split_at(s, track, at, |_| true, false)
}
fn split_at(s: &mut Session, track: TrackId, at: Samples, auto_name: impl Fn(&Clip) -> bool, skip_locked: bool) -> usize {
let tc_tempo = s.tempo.clone();
let sr = s.sample_rate;
let t = TimeCtx { tempo: &tc_tempo, sr };
let mut ids = Vec::new();
let n_split = s.track(track).map_or(0, |tr| tr.clips().iter().filter(|c| at > c.start && at < c.end() && !(skip_locked && locked(c))).count());
let n_split = s.track(track).map_or(0, |tr| tr.clips().iter().filter(|c| at > c.start && at < c.end()).count());
for _ in 0..n_split {
ids.push(s.new_clip_id());
}
@@ -116,7 +106,6 @@ fn split_at(s: &mut Session, track: TrackId, at: Samples, auto_name: impl Fn(&Cl
for c in pl.clips.drain(..) {
if at > c.start
&& at < c.end()
&& !(skip_locked && locked(&c))
&& let Some(id) = ids.pop()
&& let Some((l, mut r)) = split_clip(&c, at, id, t)
{
+1 -251
View File
@@ -891,99 +891,7 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
return deliver(name, bytes).map_err(EngineError::Io);
}
crate::file_access::check_write(path).map_err(EngineError::Io)?;
// Follow existing symlinks, and refuse dangling links even without automation roots.
let named = Path::new(path);
let path = match std::fs::symlink_metadata(named) {
Ok(m) if m.file_type().is_symlink() => std::fs::canonicalize(named).map_err(|err| EngineError::Io(format!("{path}: {err}")))?,
Ok(_) => named.to_path_buf(),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => named.to_path_buf(),
Err(err) => return Err(EngineError::Io(format!("{path}: {err}"))),
};
crate::file_access::check_write(&path).map_err(EngineError::Io)?;
let metadata = match std::fs::metadata(&path) {
Ok(m) if m.is_file() => Some(m),
Ok(_) => return Err(EngineError::Io(format!("{}: not a regular file", path.display()))),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
Err(err) => return Err(EngineError::Io(format!("{}: {err}", path.display()))),
};
let err = |err: std::io::Error| EngineError::Io(format!("{}: {err}", path.display()));
if metadata.as_ref().is_some_and(|m| m.permissions().readonly()) {
return Err(err(std::io::ErrorKind::PermissionDenied.into()));
}
write_export_checked(&path, bytes).map_err(err)
}
/// Keep the original file and its security metadata. Refuse hard links before truncation: a
/// link inside an automation root can name a file outside it (#1359).
#[cfg(unix)]
fn write_export_checked(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write as _;
use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _};
let mut file = std::fs::OpenOptions::new().write(true).create(true).truncate(false).mode(0o600).open(path)?;
let opened = file.metadata()?;
if !opened.is_file() {
return Err(std::io::Error::other("not a regular file"));
}
if opened.nlink() > 1 {
return Err(hard_link_export_error());
}
file.set_len(0)?;
file.write_all(bytes)?;
file.sync_all()
}
#[cfg(any(unix, windows))]
fn hard_link_export_error() -> std::io::Error {
std::io::Error::other("refusing to overwrite a hard-linked file; choose a new export path")
}
#[cfg(not(any(unix, windows)))]
fn write_export_checked(_path: &Path, _bytes: &[u8]) -> std::io::Result<()> {
Err(std::io::ErrorKind::Unsupported.into())
}
/// Windows' stable std metadata API does not expose the link count. Use a safe kernel wrapper
/// and the same handle for the check and write; no replacement means the destination DACL stays.
#[cfg(windows)]
fn write_export_checked(path: &Path, mut bytes: &[u8]) -> std::io::Result<()> {
use winsafe::{HFILE, co};
// Let std retain its Windows filename normalization and create missing destinations without
// truncation. Canonicalization then supplies a verbatim path, including long and UNC paths,
// to the raw Win32 wrapper. The handle below is checked before any existing bytes change.
let _opened = std::fs::OpenOptions::new().write(true).create(true).truncate(false).open(path)?;
let absolute = std::fs::canonicalize(path)?;
let path = absolute.to_str().filter(|p| !p.contains('\0')).ok_or(std::io::ErrorKind::InvalidInput)?;
let io_error = |err: winsafe::co::ERROR| std::io::Error::other(err.to_string());
let (file, _) = HFILE::CreateFile(
path,
co::GENERIC::WRITE,
Some(co::FILE_SHARE::READ | co::FILE_SHARE::WRITE | co::FILE_SHARE::DELETE),
None,
co::DISPOSITION::OPEN_EXISTING,
co::FILE_ATTRIBUTE::NORMAL,
Some(co::FILE_FLAG::WRITE_THROUGH),
None,
None,
)
.map_err(io_error)?;
if file.GetFileType().map_err(io_error)? != co::FILE_TYPE::DISK {
return Err(std::io::Error::other("not a regular file"));
}
match file.GetFileInformationByHandle().map_err(io_error)?.nNumberOfLinks {
1 => {}
0 => return Err(std::io::ErrorKind::Unsupported.into()),
_ => return Err(hard_link_export_error()),
}
file.SetEndOfFile().map_err(io_error)?;
while !bytes.is_empty() {
// Win32 takes a DWORD length. Small bounded writes also handle short writes correctly.
let count = file.WriteFile(&bytes[..bytes.len().min(64 * 1024)]).map_err(io_error)? as usize;
if count == 0 {
return Err(std::io::ErrorKind::WriteZero.into());
}
bytes = &bytes[count..];
}
Ok(())
std::fs::write(path, bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))
}
/// Unpack a bundle: the one `.scraft` at its top level and the files beside it, by relative path.
@@ -1111,164 +1019,6 @@ mod stem_tests {
let _ = std::fs::remove_dir_all(&root);
}
}
#[cfg(test)]
mod export_replacement_tests {
use super::*;
#[cfg(unix)]
use std::os::unix::fs::{PermissionsExt, symlink};
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("soundcraft-export-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn export_refuses_hard_links_before_truncating_and_refuses_nonfiles() {
let dir = scratch("hard-links");
let original = dir.join("original.mid");
let write_root = dir.join("write");
std::fs::create_dir(&write_root).unwrap();
let destination = write_root.join("export.mid");
std::fs::write(&original, b"old").unwrap();
std::fs::hard_link(&original, &destination).unwrap();
for confined in [false, true] {
let roots = if confined { crate::file_access::AutomationRoots::new(&[], &[&write_root]).unwrap() } else { None };
let result = crate::file_access::confine(roots.as_ref(), || write_file(destination.to_str().unwrap(), b"new"));
assert!(result.unwrap_err().to_string().contains("hard-linked"));
}
assert_eq!(std::fs::read(&original).unwrap(), b"old");
assert_eq!(std::fs::read(&destination).unwrap(), b"old");
let folder = dir.join("folder");
std::fs::create_dir(&folder).unwrap();
assert!(write_file(folder.to_str().unwrap(), b"new").is_err());
assert!(folder.is_dir());
assert!(!std::fs::read_dir(&dir).unwrap().any(|e| e.unwrap().file_name().to_string_lossy().starts_with(".soundcraft-")));
let _ = std::fs::remove_dir_all(dir);
}
#[cfg(windows)]
#[test]
fn export_supports_paths_longer_than_max_path() {
let root = scratch("long-path");
let mut dir = root.clone();
while dir.as_os_str().len() < 280 {
dir.push("long-folder-component");
}
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("export.mid");
write_file(path.to_str().unwrap(), b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
write_file(path.to_str().unwrap(), b"replacement").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"replacement");
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn export_writes_all_chunks_and_truncates_an_existing_file() {
let dir = scratch("chunks");
let path = dir.join("export.mid");
let bytes = vec![0x5a; 128 * 1024 + 17];
write_file(path.to_str().unwrap(), &bytes).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), bytes);
write_file(path.to_str().unwrap(), b"").unwrap();
assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn export_refuses_readonly_files_without_changing_their_bytes() {
let dir = scratch("readonly");
let path = dir.join("readonly.mid");
std::fs::write(&path, b"old contents").unwrap();
let original = std::fs::metadata(&path).unwrap().permissions();
let mut readonly = original.clone();
readonly.set_readonly(true);
std::fs::set_permissions(&path, readonly).unwrap();
let result = write_file(path.to_str().unwrap(), b"new");
let bytes = std::fs::read(&path).unwrap();
std::fs::set_permissions(&path, original).unwrap();
assert!(result.is_err());
assert_eq!(bytes, b"old contents");
std::fs::remove_dir_all(dir).unwrap();
}
#[cfg(any(unix, windows))]
#[test]
fn export_preserves_the_existing_file_identity_and_security_metadata() {
let dir = scratch("identity");
let path = dir.join("existing.mid");
std::fs::write(&path, b"long old contents").unwrap();
#[cfg(unix)]
let identity = |p: &Path| {
use std::os::unix::fs::MetadataExt as _;
let m = std::fs::metadata(p).unwrap();
(m.dev(), m.ino(), m.uid(), m.gid())
};
#[cfg(windows)]
let identity = |p: &Path| {
use winsafe::{HFILE, co};
let (file, _) = HFILE::CreateFile(
p.to_str().unwrap(),
co::GENERIC::READ,
None,
None,
co::DISPOSITION::OPEN_EXISTING,
co::FILE_ATTRIBUTE::NORMAL,
None,
None,
None,
)
.unwrap();
let i = file.GetFileInformationByHandle().unwrap();
(i.dwVolumeSerialNumber, i.nFileIndex())
};
let before = identity(&path);
write_file(path.to_str().unwrap(), b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
// No inode/file-record replacement: owner, group and access-control lists stay attached
// to the same file rather than being inherited anew from the parent directory.
assert_eq!(identity(&path), before);
std::fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn export_replacement_preserves_existing_permissions_and_new_files_are_private() {
let dir = scratch("permissions");
for mode in [0o600, 0o640] {
let path = dir.join(format!("{mode:o}.mid"));
std::fs::write(&path, b"old").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
write_file(path.to_str().unwrap(), b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, mode);
}
let path = dir.join("new.mid");
write_file(path.to_str().unwrap(), b"new").unwrap();
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o077, 0);
let _ = std::fs::remove_dir_all(dir);
}
#[cfg(unix)]
#[test]
fn export_replacement_preserves_a_symlink_and_refuses_a_dangling_one() {
let dir = scratch("symlinks");
let target = dir.join("target.mid");
let link = dir.join("link.mid");
std::fs::write(&target, b"old").unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap();
symlink("target.mid", &link).unwrap();
for confined in [false, true] {
let roots = if confined { crate::file_access::AutomationRoots::new(&[], &[&dir]).unwrap() } else { None };
crate::file_access::confine(roots.as_ref(), || write_file(link.to_str().unwrap(), b"new")).unwrap();
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
assert_eq!(std::fs::read(&target).unwrap(), b"new");
assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600);
}
let missing = dir.join("missing.mid");
let dangling = dir.join("dangling.mid");
symlink(&missing, &dangling).unwrap();
assert!(write_file(dangling.to_str().unwrap(), b"new").is_err());
assert!(std::fs::symlink_metadata(&dangling).unwrap().file_type().is_symlink());
assert!(!missing.exists());
assert!(!std::fs::read_dir(&dir).unwrap().any(|e| e.unwrap().file_name().to_string_lossy().starts_with(".soundcraft-")));
let _ = std::fs::remove_dir_all(dir);
}
}
#[cfg(test)]
mod clip_tests {
-45
View File
@@ -579,48 +579,3 @@ fn moving_a_group_left_past_zero_keeps_its_spacing() {
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "by": -1000}));
assert_eq!(clips(&e, t), [(0, 100), (1500, 100)]);
}
#[test]
fn separate_and_range_edits_leave_locked_clips_whole() {
let edits = [
("edit.separate", json!({"at": 24_000})),
("edit.separate", json!({"start": 12_000, "end": 36_000})),
("edit.separate_on_grid", json!({"start": 12_000, "end": 36_000})),
("edit.clear", json!({"start": 12_000, "end": 36_000})),
("edit.insert_silence", json!({"start": 24_000, "end": 25_000})),
];
for lock in [None, Some("clip.edit_lock"), Some("clip.time_lock")] {
for (cmd, p) in &edits {
let (mut e, t, ids) = long("slip", &[(0, 48_000, 0)]);
if let Some(lock) = lock {
run(&mut e, lock, json!({"clips": [ids[0].0]}));
}
let mut p = p.clone();
p["tracks"] = json!([t.0]);
run(&mut e, cmd, p);
if lock.is_some() {
assert_eq!(clips(&e, t), vec![(0, 48_000)], "{lock:?} {cmd}");
} else {
assert_ne!(clips(&e, t), vec![(0, 48_000)], "unlocked control {cmd}");
}
}
}
}
/// Event time edits move the entire timeline, including clips protected from ordinary edits.
#[test]
fn event_time_edits_split_and_move_locked_clips() {
for lock in ["clip.edit_lock", "clip.time_lock"] {
for (command, expected) in [
("event.insert_time", vec![(0, 12_000, 0), (36_000, 36_000, 12_000)]),
("event.cut_time", vec![(0, 12_000, 0), (12_000, 12_000, 36_000)]),
] {
let (mut e, t, ids) = long("slip", &[(0, 48_000, 0)]);
run(&mut e, lock, json!({"clips": [ids[0].0]}));
run(&mut e, command, json!({"start": 12_000, "end": 36_000}));
let actual: Vec<_> = e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.length, c.source_offset())).collect();
assert_eq!(actual, expected, "{lock} {command}");
assert!(e.session().track(t).unwrap().clips().iter().all(soundcraft_engine::edit::locked));
}
}
}