mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-12 00:44:57 -06:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fcab7fa62 | ||
|
|
2486218f67 |
Generated
+18
-19
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user