Compare commits

..
Author SHA1 Message Date
FredApps b1e0e034ee Release: SoundCraft v0.20.0 on main 2026-11-11 23:12:00 +01:00
FredApps eeb5d653a5 CI: nightly release dry run (#1508)
release.yml also runs nightly on main with craft-common-actions' dry-run: every release build
except macOS, with nothing signed, pushed or drafted. Breakage that only the release build hits
(riscv64, Flatpak, FreeBSD, packaging) then shows up the next morning instead of on release
day, as it did on 11 Nov 2026.
2026-11-11 23:04:40 +01:00
Dylan M. Taylor c25b12ba46 Engine I/O: exclusive output files, read-checked probes, resolved clip-group paths (#1328)
* fix(engine): create output files exclusively and check read access before probing

* address review: canonicalize through the checked io gateway, treat any occupied export name as taken

* address review: read group audio through the validated file handle

* address review: document that a write-only root writes a new numbered WAV per save

* address review: clip-group audio is opened without waiting for a FIFO writer
2026-11-11 16:15:08 -05:00
Dylan M. Taylor 296bfbb2e1 Strip Silence: cut and remove pieces in one pass over the playlist (#1349)
* Strip Silence: cut and remove pieces in one pass over the playlist

* address review: document the sorted-points order and test stretched audio offsets
2026-11-11 16:14:42 -05:00
Dylan M. Taylor f7a5adb474 ui: read-check movie paths, regular files only, private take folder (#1337)
* fix(ui): check movie read access and file type; private take folder without settings

* address review: make the fallback take folder exclusively under an unguessable name; qualify the owner-only claim to Unix

* address review: unique folder prefix must be one name; no fallback take folder for a saved session

* add tests for the take folder and the movie open checks
2026-11-11 16:14:17 -05:00
Dylan M. Taylor 23242338a6 ui: bound MIDI grid lines and Video Universe thumbnails per repaint (#1336)
* fix(ui): bound MIDI grid lines and Video Universe thumbnails per repaint

* address review: grid fallback stays on snap positions and bars; thumbnail positions in f64

* address review: no thumbnails for an empty shown span
2026-11-11 16:13:52 -05:00
Dylan M. Taylor e01ea00cdd audio-io: cap decoder packet samples and MP4 audio timing runs (#1332)
* fix(audio-io): bound packet samples and MP4 audio timing runs

* address review: decide MP4 audio timing bound from the stsd entry and bound PCM packets by their byte size

* address review: the last stsd in a stbl decides whether the stts bound applies
2026-11-11 16:13:28 -05:00
Dylan M. Taylor 659ec7a8e2 Video: check MJPEG frame size before decoding; budget each H.264 frame request (#1326)
* fix(video): check MJPEG frame size from its header and budget H.264 frame requests

* address review: check the sample budget before reading, keep the decoder from skipping a refused sample, stop on a failed read

* address review: restore lenient MJPEG sizing and unreadable-sample skipping; per-request sample cap 2 GiB

* address review: an unreadable sample counts toward the no-picture budget
2026-11-11 16:13:03 -05:00
FredApps 90e069f626 Merge pull request #1492 from istefp/fix/io-menu-search
I/O menus: the search finds the hardware inputs, the main output and no route, not just buses
2026-11-11 21:06:43 +01:00
FredApps 7a4f9c911d Merge pull request #1490 from istefp/fix/long-menu-scroll
Main menu: a menu taller than the window scrolls
2026-11-11 21:06:25 +01:00
Dylan M. Taylor 60f97a99a5 chore: bump version to 0.19.1 for the Flatpak update fix (#1507) 2026-11-11 14:56:05 -05:00
FredApps d255ca4fe9 Merge pull request #1436 from Silvertree2010/fix/1433-separate-splits-locked-clips-edi
Keep time- and edit-locked clips whole on separate and range edits
2026-11-11 20:37:20 +01:00
FredApps 1fe1d06073 test: cover Event time edits on locked clips 2026-11-11 20:24:33 +01:00
Dylan M. Taylor 10e4bc7b87 Fix Flatpak permissions for update checks and self-updates (#1504)
* fix(flatpak): allow release checks and self-update helpers

* test: keep Flatpak permission parsing within strict Clippy rules
2026-11-11 14:19:10 -05:00
1d696bb3ef Replace hard-linked export destinations (#1360)
* Replace hard-linked export destinations

Fixes #1359

Co-Authored-By: GPT-6-Luna <noreply@openai.com>

* fix(engine): preserve export links and private permissions

* fix(io): preserve export security by refusing hard-linked destinations

* test(automation): require safe hard-link export refusal on Windows and Unix

---------

Co-authored-by: GPT-6-Luna <noreply@openai.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-11 20:04:44 +01:00
istefpandPatrick Istefanidis f780dcab37 Main menu test: scroll to the Options menu's last entry, not its last submenu (#1482)
Co-Authored-By: Patrick Istefanidis <smallroomproductionsfl@gmail.com>
2026-11-11 14:37:44 +00:00
istefpandPatrick Istefanidis 2c042c599f I/O menus: the search finds the hardware inputs, the main output and no route, not just buses (#1491)
Co-Authored-By: Patrick Istefanidis <smallroomproductionsfl@gmail.com>
2026-11-11 05:21:27 +00:00
istefpandPatrick Istefanidis e94a7875d3 Main menu: a menu taller than the window scrolls (#1482)
Co-Authored-By: Patrick Istefanidis <smallroomproductionsfl@gmail.com>
2026-11-11 05:06:26 +00:00
NetterSilverandClaude Opus 5.5 654deabd54 Keep time- and edit-locked clips whole on separate and range edits
Fixes #1433

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:41:54 +00:00
29 changed files with 1346 additions and 116 deletions
+5
View File
@@ -1,6 +1,10 @@
name: Release
on:
# A nightly dry run on main: every release build except macOS, nothing signed or released
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
schedule:
- cron: "36 2 * * *"
push:
branches: [release]
workflow_dispatch:
@@ -30,6 +34,7 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
dry-run: ${{ github.event_name == 'schedule' }}
app-name: soundcraft
app-title: SoundCraft
version: ${{ inputs.version }}
Generated
+19 -18
View File
@@ -4331,7 +4331,7 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"chrono",
"clap",
@@ -4355,7 +4355,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"log",
"serde",
@@ -4365,7 +4365,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"log",
"proptest",
@@ -4377,7 +4377,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"clap",
"log",
@@ -4390,7 +4390,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"clap-sys",
"libloading 0.9.0",
@@ -4403,7 +4403,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"clap",
"clap_complete",
@@ -4421,7 +4421,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"log",
"proptest",
@@ -4432,7 +4432,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"craft-docpath",
"log",
@@ -4450,12 +4450,13 @@ dependencies = [
"soundcraft-video",
"soundcraft-vst3-host",
"thiserror 2.0.21",
"winsafe",
"zip",
]
[[package]]
name = "soundcraft-midi"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"proptest",
"serde",
@@ -4465,7 +4466,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"log",
"rayon",
@@ -4481,7 +4482,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"proptest",
"serde",
@@ -4494,7 +4495,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"cpal",
"log",
@@ -4509,7 +4510,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"proptest",
"serde",
@@ -4519,7 +4520,7 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"craft-ui",
"craft-update",
@@ -4549,7 +4550,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"craft-media",
"image",
@@ -4561,7 +4562,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"libloading 0.9.0",
"log",
@@ -4577,7 +4578,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"eframe",
"egui",
@@ -6225,7 +6226,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.17.0"
version = "0.20.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.17.0"
version = "0.20.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
+55 -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,38 @@ 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. Symphonia keeps the last
/// `stsd` it reads, so the last one decides.
fn entry_is_audio(b: &[u8], mut pos: usize, end: usize) -> bool {
let mut audio = false;
while pos < end {
let (Some(size), Some(kind)) = (be_u32(b, pos), b.get(pos + 4..pos + 8)) else { return audio };
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.
audio = 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 audio,
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 audio,
}
}
audio
}
/// 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)) => {
+58 -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,63 @@ 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 the_last_sample_entry_decides_whether_the_timing_bound_applies() {
let pcm = sample_entry(b"sowt", 0, &[], &[]);
let video = sample_entry(b"avc1", 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());
let audio_stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], &pcm].concat());
let frames = (1 << 14) + 1;
// A video `stsd` first and an audio one after it: symphonia keeps the last, so it decodes audio.
let f = mp4(frames, &video, |k, a| if k == b"stts" { [audio_stsd.clone(), runs(frames)].concat() } else { a });
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("timing table")), "the later audio entry counts");
}
#[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.
@@ -310,6 +310,36 @@ 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,6 +25,9 @@ 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
+45
View File
@@ -454,6 +454,32 @@ pub fn create_folder(dir: &Path) -> std::io::Result<()> {
builder.create(dir)
}
/// A new folder directly in `parent`, named `{prefix}-` and 32 random hex digits. It is made
/// exclusively (it is created by this call or the call fails), so a folder or link another account
/// put there first is never used, and the name cannot be guessed ahead. On Unix it is owner-only
/// (`0o700`); on other systems it has the mode `parent` gives it.
pub fn create_unique_folder(parent: &Path, prefix: &str) -> std::io::Result<PathBuf> {
use std::hash::{BuildHasher, Hasher};
// One plain name: a prefix like `../x` would make a sibling of `parent`, not a child.
let mut parts = Path::new(prefix).components();
if !matches!((parts.next(), parts.next()), (Some(std::path::Component::Normal(_)), None)) || prefix.contains(['/', '\\']) {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "the folder prefix must be a single name"));
}
for _ in 0..16 {
let random = |_| std::collections::hash_map::RandomState::new().build_hasher().finish();
let dir = parent.join(format!("{prefix}-{:016x}{:016x}", random(0), random(1)));
let mut builder = std::fs::DirBuilder::new();
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
match builder.create(&dir) {
Ok(()) => return Ok(dir),
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(err) => return Err(err),
}
}
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no unused folder name"))
}
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut f = std::fs::File::create(path)?;
f.write_all(bytes)?;
@@ -476,6 +502,25 @@ fn io_error(path: &Path, err: std::io::Error) -> EngineError {
mod tests {
use super::*;
use crate::io::open_session;
#[test]
fn unique_folders_are_new_and_owner_only() {
let parent = std::env::temp_dir().join(format!("soundcraft-unique-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&parent);
std::fs::create_dir_all(&parent).unwrap();
let (a, b) = (create_unique_folder(&parent, "T").unwrap(), create_unique_folder(&parent, "T").unwrap());
assert_ne!(a, b);
assert!(a.is_dir() && a.parent() == Some(parent.as_path()));
assert_eq!(a.file_name().unwrap().len(), "T-".len() + 32);
#[cfg(unix)]
assert_eq!(std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&a).unwrap().permissions()) & 0o777, 0o700);
assert!(create_unique_folder(&parent.join("missing"), "T").is_err(), "no parents are made");
for bad in ["../outside", "a/b", "a\\b", "..", ".", "", "/abs"] {
assert!(create_unique_folder(&parent, bad).is_err(), "{bad:?} is not a single name");
}
let _ = std::fs::remove_dir_all(&parent);
}
use serde_json::json;
use soundcraft_audio_io::AudioBuffer;
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
+60 -3
View File
@@ -87,20 +87,27 @@ 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(EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}")));
return Err(escapes(rel));
}
let key = crate::io::canonical_path(&full)?;
if !key.starts_with(&root) {
return Err(escapes(rel));
}
let key = crate::io::file_key(&full);
let n = match index.get(&key) {
Some(n) => *n,
None => {
let bytes = crate::io::read_file(&full)?;
let bytes = crate::io::read_file_inside(&full, &root)?;
let (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?;
let buf = crate::io::at_rate(buf, rate, crate::io::MAX_IMPORT_SAMPLES)
.map_err(|r| EngineError::Io(format!("{rel}: {}", r.message("too large to import"))))?;
@@ -268,9 +275,59 @@ 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()));
+3
View File
@@ -154,6 +154,7 @@ 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 {
@@ -185,6 +186,8 @@ 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) {
+54 -4
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`. 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`, 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.
pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
separate_at_if(s, track, at, |_| true)
}
@@ -88,11 +88,21 @@ 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()).count());
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());
for _ in 0..n_split {
ids.push(s.new_clip_id());
}
@@ -106,6 +116,7 @@ pub fn separate_at_if(s: &mut Session, track: TrackId, at: Samples, auto_name: i
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)
{
@@ -125,6 +136,45 @@ pub fn separate_at_if(s: &mut Session, track: TrackId, at: Samples, auto_name: i
count
}
/// Split every clip on a track's active playlist at each of `points`, in one pass over the
/// playlist: [`separate_at_if`] once per point with `|_| false` (no piece takes an Auto Rename
/// name here), without rebuilding the playlist per point. Returns the number of splits.
/// `points` are sorted and deduplicated first, so the source offsets of stretched audio can differ
/// from per-point calls given in another order (each trim rounds its own adjustment).
pub fn separate_at_points(s: &mut Session, track: TrackId, points: &[Samples]) -> usize {
let mut pts = points.to_vec();
pts.sort_unstable();
pts.dedup();
let tc_tempo = s.tempo.clone();
let t = TimeCtx { tempo: &tc_tempo, sr: s.sample_rate };
// The points strictly inside a clip.
let within = |c: &Clip| {
let (lo, hi) = (pts.partition_point(|p| *p <= c.start), pts.partition_point(|p| *p < c.end()));
lo..hi.max(lo)
};
let n_split: usize = s.track(track).map_or(0, |tr| tr.clips().iter().map(|c| within(c).len()).sum());
let mut ids: Vec<ClipId> = (0..n_split).map(|_| s.new_clip_id()).collect();
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
let mut out = Vec::with_capacity(pl.clips.len() + n_split);
for c in pl.clips.drain(..) {
let mut rest = c;
for p in &pts[within(&rest)] {
let Some(id) = ids.pop() else { break };
match split_clip(&rest, *p, id, t) {
Some((l, r)) => {
out.push(l);
rest = r;
}
None => break,
}
}
out.push(rest);
}
pl.clips = out;
pl.sort();
n_split
}
/// Gives `c` the next name of its Auto Rename scheme, when it has one, and counts the scheme up.
fn take_auto_name(schemes: &mut std::collections::BTreeMap<u64, soundcraft_model::AutoName>, c: &mut Clip) {
if let Some(scheme) = c.auto_name.and_then(|k| schemes.get_mut(&k)) {
+457 -10
View File
@@ -65,6 +65,69 @@ fn read_file_within(path: &Path, session: bool) -> Result<Vec<u8>> {
Ok(data)
}
/// `O_NONBLOCK` of the platforms that have a FIFO to wait on (std has no name for it, and this crate has no unsafe
/// code or libc); 0 elsewhere, where a FIFO open still waits for a writer.
#[cfg(unix)]
const O_NONBLOCK: i32 = if cfg!(all(
any(target_os = "linux", target_os = "android"),
not(any(
target_arch = "mips",
target_arch = "mips32r6",
target_arch = "mips64",
target_arch = "mips64r6",
target_arch = "sparc",
target_arch = "sparc64"
))
)) {
0x800
} else if cfg!(any(
target_os = "macos",
target_os = "ios",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd",
target_os = "dragonfly"
)) {
0x4
} else {
0
};
/// [`read_file`] for a file that must lie inside `root` (a canonical folder): the file is opened
/// once, and the bytes come from that handle only after the handle is shown to be the file the
/// path resolves to inside `root`. A file swapped in for the path after a check by name is
/// refused instead of read. Where the handle's identity can't be read (non-Unix targets) the
/// resolved path is checked against `root` alone.
pub(crate) fn read_file_inside(path: &Path, root: &Path) -> Result<Vec<u8>> {
use std::io::Read;
crate::file_access::check_read(path).map_err(EngineError::Io)?;
let err = |err: std::io::Error| EngineError::Io(format!("{}: {err}", path.display()));
// opened without waiting for a FIFO's writer (the type is checked on the opened handle, below)
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::custom_flags(&mut options, O_NONBLOCK);
let mut file = options.open(path).map_err(err)?;
if !file.metadata().map_err(err)?.is_file() {
return Err(EngineError::Io(format!("{}: not a regular file", path.display())));
}
let key = canonical_path(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let (a, b) = (file.metadata().map_err(err)?, std::fs::metadata(&key).map_err(err)?);
if (a.dev(), a.ino()) != (b.dev(), b.ino()) {
return Err(EngineError::Io(format!("{}: changed while it was opened", path.display())));
}
}
if !key.starts_with(root) {
return Err(EngineError::Io(format!("{}: outside the folder it must stay in", path.display())));
}
let mut bytes = Vec::new();
file.read_to_end(&mut bytes).map_err(err)?;
Ok(bytes)
}
/// [`read_file`] as UTF-8 text.
pub fn read_text(path: impl AsRef<Path>) -> Result<String> {
let path = path.as_ref();
@@ -192,6 +255,17 @@ 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();
@@ -446,7 +520,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, std::fs::File::create(part)?)))
Ok(Out::File(std::io::BufWriter::with_capacity(1 << 20, create_new(part)?)))
}
/// Puts the finished file at `path` and returns its size: the part file is synced and renamed
@@ -578,8 +652,28 @@ pub fn export_clips(e: &Engine, ids: &[soundcraft_model::ClipId], dir: &str) ->
let audio = soundcraft_mix::render_clips(s, t, c.range());
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio };
let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).map_err(|err| EngineError::Io(err.to_string()))?;
let path = clip_file(dir, &sanitize_name(&c.name), &mut names, Path::exists)?;
write_file(&path.to_string_lossy(), &bytes)?;
let stem = sanitize_name(&c.name);
if cfg!(target_arch = "wasm32") {
let path = clip_file(dir, &stem, &mut names, Path::exists)?;
write_file(&path.to_string_lossy(), &bytes)?;
continue;
}
// 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. 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.
let (mut file, path) = loop {
let path = clip_file(dir, &stem, &mut names, |p| std::fs::symlink_metadata(p).is_ok())?;
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, path),
// taken since it was looked at: `names` has it now, so the next name is tried
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(err) => return Err(EngineError::Io(format!("{}: {err}", path.display()))),
}
};
std::io::Write::write_all(&mut file, &bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?;
}
Ok(names.taken.len())
}
@@ -644,13 +738,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() && dir.join(&src.path).exists();
let exists_rel = same_dir && !Path::new(&src.path).is_absolute() && file_found(dir.join(&src.path));
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 && Path::new(&src.path).is_file()) {
if let Some(f) = in_folder.filter(|_| !src.unsaved && file_found(&src.path)) {
src.path = format!("Audio Files/{f}");
continue;
}
@@ -672,8 +766,11 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
let mut stem = sanitize_name(&src.name);
if overwrite {
// One file per source id so autosaves never collide or pile up.
// (With only an automation write root, `file_found` cannot read an existing file, so each save
// writes a new numbered WAV: reuse never reads a file the roots do not allow, which keeps a save
// from revealing whether an unreadable file matches the audio.)
stem = format!("{stem}_{}", src.id.0);
if audio_dir.join(format!("{stem}.wav")).exists() {
if file_found(audio_dir.join(format!("{stem}.wav"))) {
src.path = format!("Audio Files/{stem}.wav");
src.unsaved = false;
files.insert(Arc::as_ptr(audio), src.path.clone());
@@ -756,11 +853,22 @@ fn sync_dir(dir: &Path) {
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut f = std::fs::File::create(path)?;
let mut f = create_new(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)?;
@@ -840,7 +948,8 @@ fn same_path(a: &Path, b: &Path) -> bool {
/// Store `bytes` as the first of `<stem>.wav`, `<stem>_01.wav`, … in `dir` that is free or already
/// holds exactly these bytes, so saving never replaces audio; returns the name and whether it was
/// written.
/// written. Where the automation roots allow writing in `dir` but not reading (only a write root),
/// an existing file is never compared, so each save writes a new numbered WAV.
fn store_wav(dir: &Path, stem: &str, bytes: &[u8]) -> Result<(String, bool)> {
use std::io::Write;
for k in 0..10_000u32 {
@@ -855,7 +964,12 @@ 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 => {
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) {
// 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)
{
return Ok((name, false));
}
}
@@ -891,7 +1005,99 @@ 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)?;
std::fs::write(path, bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))
// 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(())
}
/// Unpack a bundle: the one `.scraft` at its top level and the files beside it, by relative path.
@@ -962,6 +1168,89 @@ 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 a_file_read_within_a_root_must_resolve_inside_it() {
let dir = scratch("within");
let (inside, outside) = (dir.join("group"), dir.join("elsewhere"));
std::fs::create_dir_all(&inside).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(inside.join("a.wav"), b"a").unwrap();
std::fs::write(outside.join("b.wav"), b"b").unwrap();
std::os::unix::fs::symlink(outside.join("b.wav"), inside.join("link.wav")).unwrap();
let root = canonical_path(&inside).unwrap();
assert_eq!(read_file_inside(&inside.join("a.wav"), &root).unwrap(), b"a");
assert!(read_file_inside(&inside.join("link.wav"), &root).is_err());
// a FIFO with no writer is refused, not waited on (Linux: the coreutils tool makes it)
#[cfg(target_os = "linux")]
if std::process::Command::new("mkfifo").arg(inside.join("pipe.wav")).status().is_ok_and(|s| s.success()) {
let err = read_file_inside(&inside.join("pipe.wav"), &root).unwrap_err().to_string();
assert!(err.contains("not a regular file"), "{err}");
}
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]
@@ -1019,6 +1308,164 @@ 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 {
+68 -5
View File
@@ -117,9 +117,7 @@ pub fn strip_silence(
}
cut_points.push(r.end);
let old: std::collections::HashSet<_> = s.track(t).map(|x| x.clips().iter().map(|c| c.id).collect()).unwrap_or_default();
for p in &cut_points {
crate::edit::separate_at_if(s, t, *p, |_| false);
}
crate::edit::separate_at_points(s, t, &cut_points);
let kept = strip_pieces(s, t, r, &keep, mode);
crate::edit::auto_name_new_clips(s, t, &old, |c| !(named && c.is_audio() && c.start >= r.start && c.end() <= r.end));
Ok(kept)
@@ -144,10 +142,24 @@ fn strip_pieces(s: &mut Session, t: TrackId, r: Range, keep: &[(Samples, Samples
let audio: Vec<Range> = keep.iter().map(|(a, b)| Range::new(*a, *b)).collect();
let remove = if mode == StripMode::Extract { &audio } else { &gaps };
let inside = |c: &Clip, g: &Range| c.start >= g.start && c.end() <= g.end;
// The range of `rs` holding `c`: by binary search when `rs` is ascending and disjoint (the
// usual case, checked once by the caller), by scan otherwise.
let holder = |rs: &[Range], sorted: bool, c: &Clip| {
if sorted {
let i = rs.partition_point(|g| g.end < c.end());
rs.get(i).filter(|g| inside(c, g)).map(|_| i)
} else {
rs.iter().position(|g| inside(c, g))
}
};
let ordered = |rs: &[Range]| rs.windows(2).all(|w| w[0].end <= w[1].start);
let Some(pl) = s.track_mut(t).and_then(|x| x.playlist_mut()) else { return if mode == StripMode::Extract { 0 } else { keep.len() } };
pl.clips.retain(|c| !remove.iter().any(|g| inside(c, g)));
let sorted = ordered(remove);
pl.clips.retain(|c| holder(remove, sorted, c).is_none());
if mode == StripMode::Extract {
return gaps.iter().filter(|g| pl.clips.iter().any(|c| inside(c, g))).count();
let sorted = ordered(&gaps);
let held: std::collections::HashSet<usize> = pl.clips.iter().filter_map(|c| holder(&gaps, sorted, c)).collect();
return held.len();
}
keep.len()
}
@@ -294,6 +306,57 @@ mod strip_tests {
(r, e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.end())).collect())
}
#[test]
fn cutting_at_many_points_in_one_pass_equals_cutting_at_each() {
let spans = |e: &Engine, t: TrackId| {
let mut v: Vec<_> = e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.end())).collect();
v.sort_unstable();
v
};
let points = [30_000, 10, 5_000, 30_000, 120_000, 0, 20_000, 1_000_000];
let (mut one, t) = tones(&[0, 20_000, 100_000], 40_000);
let (mut each, _) = tones(&[0, 20_000, 100_000], 40_000);
crate::edit::separate_at_points(one.session_mut(), t, &points);
for p in points {
crate::edit::separate_at_if(each.session_mut(), t, p, |_| false);
}
assert_eq!(spans(&one, t), spans(&each, t));
assert!(spans(&one, t).len() > 3);
let ids: std::collections::HashSet<_> = one.session().track(t).unwrap().clips().iter().map(|c| c.id).collect();
assert_eq!(ids.len(), spans(&one, t).len(), "new pieces have distinct ids");
}
#[test]
fn cutting_stretched_audio_at_ascending_points_matches_cutting_at_each() {
let pieces = |e: &Engine, t: TrackId| {
let mut v: Vec<_> = e
.session()
.track(t)
.unwrap()
.clips()
.iter()
.map(|c| (c.start, c.end(), if let soundcraft_model::ClipContent::Audio { offset, .. } = c.content { offset } else { -1 }))
.collect();
v.sort_unstable();
v
};
let stretched = || {
let (mut e, t) = tones(&[0], 40_000);
for c in &mut e.session_mut().track_mut(t).unwrap().playlist_mut().unwrap().clips {
c.stretch = 2.0;
}
(e, t)
};
let points = [1, 2, 3_001, 12_345];
let ((mut one, t), (mut each, _)) = (stretched(), stretched());
crate::edit::separate_at_points(one.session_mut(), t, &points);
for p in points {
crate::edit::separate_at_if(each.session_mut(), t, p, |_| false);
}
assert_eq!(pieces(&one, t), pieces(&each, t));
assert_eq!(pieces(&one, t).len(), points.len() + 1);
}
#[test]
fn strip_keeps_the_audio_and_removes_the_silence() {
let (r, clips) = strip_mode("strip");
+45
View File
@@ -579,3 +579,48 @@ 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));
}
}
}
+1
View File
@@ -46,6 +46,7 @@ impl SoundApp {
services,
native_menu_bar: false,
autosave_dir: None,
fallback_take_dir: None,
autosaver: None,
autosaved_rev: 0,
leftovers: Vec::new(),
+40 -4
View File
@@ -155,15 +155,23 @@ impl SoundApp {
/// autosave folder. Returns the takes it records.
fn arm_recorder(&mut self) -> Result<Vec<soundcraft_engine::io::Take>, String> {
let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name);
let unsaved_dir = self.autosave_dir.as_ref().map_or_else(std::env::temp_dir, |d| d.join(name));
// Without a settings folder the takes go under the temporary folder, in a folder of this
// run's own that nobody else could have made first.
let unsaved_dir = match &self.autosave_dir {
Some(d) => d.join(name),
// A saved session records beside its file: `plan_takes` never uses this folder.
None if self.engine.path.is_some() => std::path::PathBuf::new(),
None => self.fallback_take_dir()?,
};
let Some(r) = &mut self.recorder else { return Err("no audio input device".into()) };
let plan = soundcraft_engine::io::plan_takes(&self.engine, &unsaved_dir, r.channels);
if plan.is_empty() {
return Err("no audio track is record-enabled".into());
}
// The takes are unsaved work: the folder they go into under the autosave root is
// owner-only, like the autosaves, before the recorder makes anything in it.
if self.engine.path.is_none() && self.autosave_dir.is_some() {
// The takes are unsaved work: on Unix the folder they go into is owner-only, like the
// autosaves, before the recorder makes anything in it (other systems get the folder's
// inherited access: no ACLs are set, so the temporary folder's own per-user access applies).
if self.engine.path.is_none() {
for dir in plan.iter().filter_map(|(_, _, path)| path.parent()) {
soundcraft_engine::autosave::create_folder(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
@@ -176,6 +184,19 @@ impl SoundApp {
Ok(takes)
}
/// The folder for the takes of a session never saved when there is no autosave folder, made on
/// first use: exclusively and under a name nobody can guess, so another account cannot have
/// made it first, with open permissions or as a link.
fn fallback_take_dir(&mut self) -> Result<std::path::PathBuf, String> {
if let Some(dir) = &self.fallback_take_dir {
return Ok(dir.clone());
}
let dir = soundcraft_engine::autosave::create_unique_folder(&std::env::temp_dir(), "SoundCraft")
.map_err(|e| format!("temporary take folder: {e}"))?;
self.fallback_take_dir = Some(dir.clone());
Ok(dir)
}
fn finish_recording(&mut self) {
self.engine.transport.recording = false;
if let Some(p) = &self.player {
@@ -453,3 +474,18 @@ impl SoundApp {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_fallback_take_folder_is_made_once_under_the_temporary_folder() {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, crate::Services::default());
let a = app.fallback_take_dir().unwrap();
assert_eq!(a.parent(), Some(std::env::temp_dir().as_path()));
assert!(a.is_dir());
assert_eq!(app.fallback_take_dir().unwrap(), a, "the same folder for the rest of the run");
let _ = std::fs::remove_dir_all(&a);
}
}
+2
View File
@@ -291,6 +291,8 @@ pub struct SoundApp {
pub native_menu_bar: bool,
/// Folder for crash-recovery autosaves (native apps set it).
pub autosave_dir: Option<std::path::PathBuf>,
/// Where the takes of a session never saved go without an autosave folder: made once per run, under the temporary folder.
fallback_take_dir: Option<std::path::PathBuf>,
/// Writes the open session's autosaves in the background, once there is something to write.
autosaver: Option<soundcraft_engine::autosave::Autosaver>,
/// The document revision last handed to the autosaver (0 once that autosave is stale).
+36 -26
View File
@@ -298,32 +298,34 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
let extra = ui_aliases();
for root in tree() {
ui.menu_button(&root.label, |ui| {
ui.add_enabled_ui(menu_entries_enabled, |ui| {
for c in &root.children {
menu_node(app, ui, c, &extra);
if c.path == "File > Open" {
crate::recent::menu(app, ui);
scrolling(ui, |ui| {
ui.add_enabled_ui(menu_entries_enabled, |ui| {
for c in &root.children {
menu_node(app, ui, c, &extra);
if c.path == "File > Open" {
crate::recent::menu(app, ui);
}
if c.path == "Track > New..." {
instrument_menu(app, ui);
}
}
if c.path == "Track > New..." {
instrument_menu(app, ui);
if root.path == "AudioSuite" {
ui.separator();
ui.label(egui::RichText::new("Processes selected clips offline").small().color(t.text_dim));
}
}
if root.path == "AudioSuite" {
ui.separator();
ui.label(egui::RichText::new("Processes selected clips offline").small().color(t.text_dim));
}
if root.path == "Window" {
ui.separator();
let mut separate = app.ui.mix_separate;
if ui
.checkbox(&mut separate, "Mix in Separate Window")
.on_hover_text("Show Edit and Mix at once, e.g. one per monitor")
.changed()
{
let _ = run_ui_command(app, "window.mix_separate", &json!({"value": separate}));
ui.close();
if root.path == "Window" {
ui.separator();
let mut separate = app.ui.mix_separate;
if ui
.checkbox(&mut separate, "Mix in Separate Window")
.on_hover_text("Show Edit and Mix at once, e.g. one per monitor")
.changed()
{
let _ = run_ui_command(app, "window.mix_separate", &json!({"value": separate}));
ui.close();
}
}
}
})
});
});
}
@@ -341,6 +343,12 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
);
}
/// A menu taller than the window scrolls instead of running off its bottom edge (#1482).
fn scrolling<R>(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
let max_h = (ui.ctx().content_rect().height() - 40.0).max(120.0);
egui::ScrollArea::vertical().max_height(max_h).show(ui, add).inner
}
/// Track › Instrument: the instrument picker for the selected instrument track (#374).
fn instrument_menu(app: &mut SoundApp, ui: &mut egui::Ui) {
match instrument_track(app.engine.session()) {
@@ -361,9 +369,11 @@ fn instrument_track(s: &soundcraft_model::Session) -> Option<soundcraft_model::T
fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str, &str)]) {
if !n.children.is_empty() {
ui.menu_button(&n.label, |ui| {
for c in &n.children {
menu_node(app, ui, c, extra);
}
scrolling(ui, |ui| {
for c in &n.children {
menu_node(app, ui, c, extra);
}
});
});
return;
}
+30 -19
View File
@@ -25,34 +25,45 @@ pub(crate) fn route_popup(app: &mut SoundApp, resp: &egui::Response, id: TrackId
egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| route_menu(app, ui, id, input));
}
/// Input or output menu: no route and buses on both, plus the main output (outputs) or inputs 1-8 (inputs).
/// Input or output menu: no route and buses on both, plus the main output (outputs) or inputs 1-8
/// (inputs). Its search field takes typing at once and finds any of them, not just the buses.
fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) {
let key = if input { "input" } else { "output" };
let cmd = if input { "track.input" } else { "track.output" };
if ui.button(if input { "no input" } else { "no output" }).clicked() {
let _ = app.run(cmd, json!({"track": id.0, key: "none"}));
ui.close();
}
let hint = if input { "Search inputs…" } else { "Search outputs…" };
let search = MenuSearch::show(ui, egui::Id::unique(("route-search", id.0, input)), hint);
let busses: Vec<String> = app.engine.session().busses.iter().map(|b| b.name.clone()).collect();
let main_name = app.engine.session().outputs.first().map_or_else(|| "Out 1-2".to_string(), |o| o.name.clone());
if !input && ui.button(main_name).clicked() {
let _ = app.run(cmd, json!({"track": id.0, key: "main"}));
ui.close();
}
// (label, the route the command is given)
let mut fixed = vec![(if input { "no input" } else { "no output" }.to_string(), "none".to_string())];
if input {
for i in 1..=8 {
if ui.button(format!("In {i}")).clicked() {
let _ = app.run(cmd, json!({"track": id.0, key: format!("In {i}")}));
ui.close();
}
fixed.extend((1..=8).map(|i| (format!("In {i}"), format!("In {i}"))));
} else {
fixed.push((main_name, "main".to_string()));
}
if search.typing() {
let visible: Vec<(&String, &String)> =
fixed.iter().map(|(l, v)| (l, v)).chain(busses.iter().map(|b| (b, b))).filter(|(l, _)| search.matches(l)).collect();
let picked = search.rows(ui, visible.len(), 420.0, |ui, i, highlighted| visible.get(i).is_some_and(|(l, _)| menu_row(ui, highlighted, l)));
if let Some((_, value)) = picked.and_then(|i| visible.get(i)) {
let _ = app.run(cmd, json!({"track": id.0, key: value}));
ui.close();
}
return;
}
for (label, value) in &fixed {
if ui.button(label).clicked() {
let _ = app.run(cmd, json!({"track": id.0, key: value}));
ui.close();
}
}
ui.separator();
let busses: Vec<String> = app.engine.session().busses.iter().map(|b| b.name.clone()).collect();
ui.strong("Bus");
let search = MenuSearch::show(ui, egui::Id::unique(("route-bus-search", id.0, input)), "Search buses…");
let visible: Vec<&String> = busses.iter().filter(|b| search.matches(b)).collect();
if let Some(i) = search.rows(ui, visible.len(), 420.0, |ui, i, highlighted| menu_row(ui, highlighted, visible[i])) {
let _ = app.run(cmd, json!({"track": id.0, key: visible[i]}));
if let Some(bus) = search
.rows(ui, busses.len(), 420.0, |ui, i, highlighted| busses.get(i).is_some_and(|b| menu_row(ui, highlighted, b)))
.and_then(|i| busses.get(i))
{
let _ = app.run(cmd, json!({"track": id.0, key: bus}));
ui.close();
}
if ui.button("new bus...").clicked() {
+47 -7
View File
@@ -265,20 +265,42 @@ fn draw_roll_background(painter: &egui::Painter, g: &Roll<'_>) {
}
}
// Grid.
let step = grid_stride(grid_ticks, px_per_tick);
let mut tick = 0;
while tick <= len_ticks {
let bar = tick % (TICKS_PER_QUARTER * 4) == 0;
let x = g.x_of(tick);
if grid_ticks as f32 * px_per_tick > 4.0 || bar {
painter.line_segment(
[pos2(x, roll.min.y), pos2(x, roll.max.y)],
Stroke::new(1.0, if bar { Color32::from_rgb(80, 80, 86) } else { Color32::from_rgb(50, 50, 54) }),
);
}
tick += grid_ticks;
painter.line_segment(
[pos2(x, roll.min.y), pos2(x, roll.max.y)],
Stroke::new(1.0, if bar { Color32::from_rgb(80, 80, 86) } else { Color32::from_rgb(50, 50, 54) }),
);
tick = tick.saturating_add(step);
}
}
/// Closest lines the grid draws, in points.
const MIN_GRID_PX: f32 = 4.0;
/// Ticks between grid lines: the grid when its lines are more than [`MIN_GRID_PX`] apart, else
/// as few common multiples of the grid and a bar as keep that spacing (so every line is a snap
/// position and falls on a bar). The lines drawn are then bounded by the roll's width, however long
/// the clip, instead of by its length.
fn grid_stride(grid_ticks: i64, px_per_tick: f32) -> i64 {
let bar_ticks = TICKS_PER_QUARTER * 4;
if grid_ticks as f32 * px_per_tick > MIN_GRID_PX {
return grid_ticks.max(1);
}
let grid = grid_ticks.max(1);
let (mut a, mut b) = (grid, bar_ticks);
while b != 0 {
(a, b) = (b, a % b);
}
let common = (grid / a).saturating_mul(bar_ticks);
// `as` saturates a huge or NaN quotient; a zero `px_per_tick` gives one common multiple.
let n = (MIN_GRID_PX / (common as f32 * px_per_tick)).ceil().max(1.0) as i64;
common.saturating_mul(n)
}
/// The notes, with a drag in progress applied.
fn draw_notes(app: &SoundApp, painter: &egui::Painter, g: &Roll<'_>, base: Color32) {
let Roll { sequence, grid_ticks, .. } = *g;
@@ -478,6 +500,24 @@ fn velocity_lane(app: &mut SoundApp, ui: &mut Ui, g: &Roll<'_>, cid: ClipId, bas
mod tests {
use super::*;
#[test]
fn grid_lines_are_bounded_by_the_roll_width_not_the_clip_length() {
let sixteenth = TICKS_PER_QUARTER / 4;
// A 1000 pt roll showing a 4-bar clip: every sixteenth is 16 pt apart and drawn.
assert_eq!(grid_stride(sixteenth, 1000.0 / (16.0 * TICKS_PER_QUARTER as f32)), sixteenth);
// The same roll over a clip a billion ticks long: at most a line every 4 pt.
let (len, px_per_tick) = (1_000_000_000i64, 1000.0 / 1_000_000_000.0f32);
let step = grid_stride(sixteenth, px_per_tick);
assert_eq!(step % (TICKS_PER_QUARTER * 4), 0, "lines fall on bars");
assert!((len / step) <= 251, "{} lines", len / step);
// a dotted quarter grid (1440 ticks): every line is also a grid position
let dotted = TICKS_PER_QUARTER * 3 / 2;
let step = grid_stride(dotted, px_per_tick);
assert_eq!((step % dotted, step % (TICKS_PER_QUARTER * 4)), (0, 0), "lines are snap positions on bars: {step}");
assert!((len / step) <= 251, "{} lines", len / step);
assert!(grid_stride(0, 0.0) >= TICKS_PER_QUARTER * 4, "a zero grid or width still advances");
}
#[test]
fn switching_clip_drops_selection_and_gestures() {
let mut st = MidiEditorState { selected: vec![0, 2], clip: Some(ClipId(1)), band: Some(pos2(1.0, 2.0)), ..Default::default() };
+35 -4
View File
@@ -115,6 +115,22 @@ fn first_movie_aspect(s: &soundcraft_model::Session) -> Option<f32> {
s.videos.first().map(|v| v.aspect() as f32)
}
/// Most thumbnails one clip draws in the Video Universe strip: 8 pt each, so more than the
/// widest strip holds.
const MAX_UNIVERSE_THUMBS: usize = 1 << 12;
/// Left edges of the thumbnails of a clip starting at `clip_x` (each `tw` wide) that lie in
/// `shown_min..shown_max`. The index and position are worked out in `f64`: an `f32` this far from
/// the origin (a clip starting a billion points left) cannot step by a thumbnail's width.
fn thumb_xs(clip_x: f32, shown_min: f32, shown_max: f32, tw: f32) -> impl Iterator<Item = f32> {
let (origin, tw) = (f64::from(clip_x), f64::from(tw));
// Nothing of the clip is in view when the shown span is empty (or inverted).
let empty = shown_max <= shown_min;
let first = ((f64::from(shown_min) - origin) / tw).floor().max(0.0);
let count = if empty { 0 } else { ((f64::from(shown_max) - origin) / tw - first).ceil().max(0.0) as usize };
(0..count.min(MAX_UNIVERSE_THUMBS)).map(move |i| (origin + (first + i as f64) * tw) as f32)
}
fn universe(app: &mut SoundApp, ctx: &egui::Context) {
app.video.poll(ctx);
let s = app.engine.session_arc();
@@ -145,8 +161,10 @@ fn universe(app: &mut SoundApp, ctx: &egui::Context) {
}
let tw = (cr.height() * v.aspect() as f32).max(8.0);
let hpx = ((cr.height() * ppp / 16.0).ceil() * 16.0).clamp(16.0, 256.0) as u32;
let mut x = cr.min.x;
while x < cr.max.x {
// Only the thumbnails inside the strip, counted rather than stepped: a clip starting
// far left of it has an edge so large that adding a thumbnail's width wouldn't move it.
let shown = cr.intersect(r);
for x in thumb_xs(cr.min.x, shown.min.x, shown.max.x, tw) {
let secs = movie_secs(&s, c.start, offset, strip_position(x, r.min.x, r.width(), end, c.start)).max(0.0);
if let Some(tex) = app.video.thumb(ctx, key, &v.path, secs, hpx) {
let tr = Rect::from_min_size(pos2(x, cr.min.y), vec2(tw, cr.height()));
@@ -154,7 +172,6 @@ fn universe(app: &mut SoundApp, ctx: &egui::Context) {
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2((vis.width() / tw).clamp(0.0, 1.0), 1.0));
painter.image(tex.id(), vis, uv, Color32::WHITE);
}
x += tw;
}
}
// The Edit window's visible range and the playhead.
@@ -182,7 +199,21 @@ fn universe(app: &mut SoundApp, ctx: &egui::Context) {
#[cfg(test)]
mod tests {
use super::strip_position;
use super::{strip_position, thumb_xs};
#[test]
fn thumbnails_of_a_clip_far_left_of_the_strip_are_evenly_spaced() {
// 2^30 points left of the strip with 8 pt thumbnails: f32 steps of `first + i` here are 16 apart
let xs: Vec<f32> = thumb_xs(-1_073_741_824.0, 0.0, 80.0, 8.0).collect();
assert_eq!(xs.len(), 10);
assert!(xs.windows(2).all(|w| w[1] - w[0] == 8.0), "{xs:?}");
assert_eq!(xs[0], 0.0);
// a clip inside the strip starts at its own edge
assert_eq!(thumb_xs(10.0, 10.0, 34.0, 8.0).collect::<Vec<_>>(), vec![10.0, 18.0, 26.0]);
// a clip ending left of the strip (the shown span is empty or inverted) draws none
assert_eq!(thumb_xs(-10.0, 0.0, -1.0, 8.0).count(), 0);
assert_eq!(thumb_xs(-10.0, 0.0, 0.0, 8.0).count(), 0);
}
// A 1024 px strip over 1_024_000 samples: 1000 samples per point, exact in f32.
const LEFT: f32 = 100.0;
+28
View File
@@ -90,6 +90,13 @@ impl Worker {
}
fn open_movie(path: &str) -> Result<Movie, String> {
// The last check before the file system: the path came from a session file, so the read
// roots apply, and only a regular file is opened (a pipe would hold the video worker
// forever).
soundcraft_engine::file_access::check_read(path)?;
if !std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?.is_file() {
return Err(format!("{path}: not a regular file"));
}
#[cfg(not(target_arch = "wasm32"))]
{
let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
@@ -491,3 +498,24 @@ pub fn draw_lane(app: &mut SoundApp, painter: &Painter, s: &Session, track: &Tra
}
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::open_movie;
#[test]
fn only_a_regular_movie_file_is_opened() {
let dir = std::env::temp_dir().join(format!("soundcraft-open-movie-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let (folder, missing, junk, movie) = (dir.to_string_lossy().to_string(), dir.join("none.mp4"), dir.join("junk.mp4"), dir.join("a.mp4"));
assert!(open_movie(&folder).err().unwrap_or_default().contains("not a regular file"));
assert!(open_movie(&missing.to_string_lossy()).is_err());
std::fs::write(&junk, b"not a movie").unwrap();
let err = open_movie(&junk.to_string_lossy()).err().unwrap_or_default();
assert!(!err.is_empty() && !err.contains("not a regular file"), "{err}");
std::fs::write(&movie, soundcraft_video::synth::h264_mp4(2, 2).unwrap()).unwrap();
assert!(open_movie(&movie.to_string_lossy()).is_ok());
let _ = std::fs::remove_dir_all(&dir);
}
}
@@ -151,19 +151,19 @@ fn the_views_menu_stays_open_until_a_click_elsewhere() {
}
#[test]
fn the_io_columns_route_menus_stay_open_while_searching_buses() {
fn the_io_columns_route_menus_stay_open_while_searching() {
let (mut h, _, row) = harness("Kick", "medium", "view.edit_io");
click(&mut h, pos2(row[0] + HEADER_W + 40.0, row[1] + 29.5));
let bus = h.get_by_label("Bus").rect();
let first = h.get_by_label("no output").rect();
let search = h
.query_all_by_role(egui::accesskit::Role::TextInput)
.map(|node| node.rect())
.filter(|r| r.min.y >= bus.max.y)
.min_by(|a, b| a.min.y.total_cmp(&b.min.y))
.filter(|r| r.max.y <= first.min.y && r.x_range().contains(first.center().x))
.max_by(|a, b| a.min.y.total_cmp(&b.min.y))
.unwrap()
.center();
click(&mut h, search);
assert!(h.query_by_label("no output").is_some(), "a click in the bus search closed the Output menu");
assert!(h.query_by_label("no output").is_some(), "a click in the menu's search closed the Output menu");
}
#[test]
@@ -0,0 +1,64 @@
//! A main menu taller than the window scrolls, so its last items can be reached (#1482).
use egui::{Event, Modifiers, MouseWheelUnit, PointerButton, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use soundcraft_ui_egui::{Services, SoundApp, menus};
const SIZE: egui::Vec2 = vec2(1200.0, 420.0);
fn step(h: &mut Harness<'_, SoundApp>) {
let mut raw = std::mem::take(h.input_mut());
let ctx = h.ctx.clone();
h.state_mut().raw_input_hook(&ctx, &mut raw);
*h.input_mut() = raw;
h.step();
}
#[test]
fn a_long_main_menu_scrolls_to_its_last_item() {
let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let mut h = Harness::builder().with_size(SIZE).build_ui_state(
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
for _ in 0..4 {
step(&mut h);
}
let options = menus::tree().into_iter().find(|r| r.path == "Options").unwrap();
// The menu must scroll all the way to its last entry, a leaf like "Low Latency Monitoring".
let last = options.children.last().unwrap().label.clone();
let at = h.get_by_label("Options").rect().center();
let s = &mut h.state_mut().synthetic;
s.push(Event::PointerMoved(at));
s.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
s.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
for _ in 0..6 {
step(&mut h);
}
let bottom = |h: &Harness<'_, SoundApp>| h.get_by_label_contains(&last).rect().bottom();
assert!(bottom(&h) > SIZE.y, "the Options menu is taller than the window, so `{last}` starts out of view");
// Over the top of the open menu, which drops down below its title.
h.state_mut().synthetic.push(Event::PointerMoved(at + vec2(80.0, 120.0)));
step(&mut h);
for _ in 0..40 {
h.state_mut().synthetic.push(Event::MouseWheel {
unit: MouseWheelUnit::Line,
delta: vec2(0.0, -3.0),
modifiers: Modifiers::NONE,
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Wheel,
});
step(&mut h);
}
for _ in 0..10 {
step(&mut h);
}
assert!(bottom(&h) <= SIZE.y, "scrolling the menu brings `{last}` into view (bottom at {})", bottom(&h));
}
+1
View File
@@ -22,6 +22,7 @@ mod group_dialog;
mod hide_floating;
mod idle;
mod key_families;
mod long_menu_scroll;
mod marker_rename;
mod menu_search;
mod midi_double_click;
+22
View File
@@ -169,6 +169,28 @@ fn send_and_output_menus_search_buses_as_soon_as_they_open() {
assert_eq!(h.state().engine.session().tracks[0].mixer.output, Route::Bus(bus(&h, "Verb")));
}
/// The I/O menus' search finds the hardware inputs, the main output and "no input/output", not just buses.
#[test]
fn io_menus_search_every_route_not_just_buses() {
let mut h = harness(vox());
open(&mut h, "Input for Vox");
type_text(&mut h, "in 3");
assert!(h.query_by_label("In 4").is_none(), "only the matches are listed");
press(&mut h, Key::Enter);
assert_eq!(h.state().engine.session().tracks[0].mixer.input, Route::Hardware("In 3".into()));
open(&mut h, "Output for Vox");
type_text(&mut h, "no out");
press(&mut h, Key::Enter);
assert_eq!(h.state().engine.session().tracks[0].mixer.output, Route::None);
let main = h.state().engine.session().outputs.first().map_or_else(|| "Out 1-2".to_string(), |o| o.name.clone());
open(&mut h, "Output for Vox");
type_text(&mut h, &main.to_lowercase());
press(&mut h, Key::Enter);
assert_eq!(h.state().engine.session().tracks[0].mixer.output, Route::Main);
}
/// Scripted typing and Enter reach a menu search even when the host window is unfocused.
#[test]
fn scripted_keys_reach_the_search_with_the_window_in_the_background() {
+41
View File
@@ -380,6 +380,11 @@ impl Movie {
.collect()
}
/// Size the sample table declares for `sample` (0 when there is none).
fn declared_size(&self, sample: usize) -> usize {
self.mp4.tracks.get(self.track).and_then(|t| t.samples.get(sample)).map_or(0, |s| s.size as usize)
}
fn read(&self, sample: usize) -> Result<Vec<u8>> {
let t = self.mp4.tracks.get(self.track).ok_or(VideoError::NoVideoTrack)?;
let size = t.samples.get(sample).map_or(0, |s| s.size);
@@ -415,6 +420,13 @@ impl Movie {
Ok(Frame { width: f.width, height: f.height, rgba, index, time })
}
Codec::Jpeg => {
// The frame's own size, from its header, before a pixel is decoded: a frame may differ from
// the track's size (as before), but not exceed `MAX_PIXELS`.
let reader = image::ImageReader::with_format(std::io::Cursor::new(&data), image::ImageFormat::Jpeg);
let (w, h) = reader.into_dimensions().map_err(|e| VideoError::Decode(e.to_string()))?;
if u64::from(w) * u64::from(h) > MAX_PIXELS {
return Err(VideoError::Decode("picture too large".into()));
}
let img = image::load_from_memory_with_format(&data, image::ImageFormat::Jpeg).map_err(|e| VideoError::Decode(e.to_string()))?;
let rgba = img.to_rgba8();
let (width, height) = rgba.dimensions();
@@ -457,6 +469,14 @@ impl Movie {
Ok(self.h264_to_frame(p, index))
}
/// Most sample bytes one frame request feeds the decoder (eight samples at the 256 MiB cap):
/// a request decodes from the previous keyframe, and a sample table whose entries all name
/// one large payload would otherwise read and parse it once per sample.
const MAX_REQUEST_BYTES: usize = 2 << 30;
/// Most samples one frame request feeds without a picture coming out: the reorder depth is
/// at most 16, so a stream that produces nothing for this long will produce nothing.
const MAX_SAMPLES_WITHOUT_PICTURE: usize = 256;
fn decode_h264(&mut self, index: usize) -> Result<Arc<Frame>> {
let Codec::H264 { avcc } = &self.codec else { return Err(VideoError::Unsupported("not H.264".into())) };
let target = self.order.get(index).copied().ok_or(VideoError::OutOfRange)?;
@@ -471,6 +491,7 @@ impl Movie {
}
let total = self.order.len();
let mut last_err: Option<String> = None;
let (mut fed, mut without_picture) = (0usize, 0usize);
// Bounded: each iteration feeds one sample, or flushes once at the end.
for _ in 0..=total.saturating_add(1) {
let Some(st) = self.h264.as_mut() else { break };
@@ -485,13 +506,29 @@ impl Movie {
let s = st.next;
st.next += 1;
let pts = self.rank.get(s).copied().unwrap_or(0) as i64;
// The declared size is checked before the read allocates it. An error leaves no decoder
// behind that has stepped past this sample: the next request starts from its keyframe.
if fed.saturating_add(self.declared_size(s)) > Self::MAX_REQUEST_BYTES {
self.h264 = None;
return Err(VideoError::Decode(format!("frame {index} needs more than {} MiB of samples", Self::MAX_REQUEST_BYTES >> 20)));
}
let data = match self.read(s) {
Ok(d) => d,
// an unreadable sample is skipped, as before, and counts as a sample without a picture: the
// request's byte and no-picture budgets bound the walk
Err(e) => {
last_err = Some(e.to_string());
without_picture += 1;
if without_picture > Self::MAX_SAMPLES_WITHOUT_PICTURE {
return Err(VideoError::Decode(format!(
"no picture in {} samples before frame {index}",
Self::MAX_SAMPLES_WITHOUT_PICTURE
)));
}
continue;
}
};
fed = fed.saturating_add(data.len());
let Some(st) = self.h264.as_mut() else { break };
match st.dec.decode(&data, pts) {
Ok(p) => p,
@@ -506,6 +543,10 @@ impl Movie {
st.dec.flush()
};
let Some(st) = self.h264.as_mut() else { break };
without_picture = if pics.is_empty() { without_picture + 1 } else { 0 };
if without_picture > Self::MAX_SAMPLES_WITHOUT_PICTURE {
return Err(VideoError::Decode(format!("no picture in {} samples before frame {index}", Self::MAX_SAMPLES_WITHOUT_PICTURE)));
}
for p in pics {
let q = usize::try_from(p.pts).unwrap_or(usize::MAX);
st.last_out = Some(q);
+76 -2
View File
@@ -121,7 +121,7 @@ fn prores_frame_size_is_checked_before_decoding() {
}
/// In-memory movie bytes, zero-padded up to `len` without storing the padding. Records the
/// largest read asked of it and refuses reads over 1 MiB (the test movies are much smaller).
/// largest read asked of it and refuses reads over 32 MiB (the test movies are much smaller).
struct PaddedSource {
bytes: Vec<u8>,
len: u64,
@@ -134,7 +134,7 @@ impl ByteSource for PaddedSource {
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> {
self.largest.fetch_max(buf.len(), Ordering::Relaxed);
if buf.len() > 1 << 20 {
if buf.len() > 32 << 20 {
return Err(std::io::Error::other("read too large for the test source"));
}
let start = usize::try_from(offset).unwrap_or(usize::MAX);
@@ -161,6 +161,80 @@ fn h264_sample_reads_are_capped() {
assert!(largest <= 256 << 20, "a {largest}-byte read reached the source");
}
#[test]
fn one_frame_request_reads_a_bounded_amount_of_samples() {
// 65 frames whose `stsz` entries each claim 32 MiB: under the per-sample cap, but together
// past the 2 GiB one request may feed the decoder.
let mut bytes = h264_mp4(65, 65).unwrap();
let at = bytes.windows(4).position(|w| w == b"stsz").unwrap();
if bytes[at + 8..at + 12] == [0; 4] {
// one size per sample
for i in 0..65 {
let field = at + 16 + 4 * i;
bytes[field..field + 4].copy_from_slice(&(32u32 << 20).to_be_bytes());
}
} else {
// the writer stored one size for all the (equally large) samples
bytes[at + 8..at + 12].copy_from_slice(&(32u32 << 20).to_be_bytes());
}
let src = Arc::new(PaddedSource { bytes, len: 3 << 30, largest: AtomicUsize::new(0) });
let mut m = Movie::open_source(src).unwrap();
let r = m.frame(64);
assert!(matches!(&r, Err(VideoError::Decode(e)) if e.contains("needs more than 2048 MiB of samples")), "{r:?}");
// the sample that was refused is not skipped: asking again fails the same way instead of decoding past it
let again = m.frame(64);
assert!(matches!(&again, Err(VideoError::Decode(e)) if e.contains("needs more than 2048 MiB of samples")), "{again:?}");
}
/// A source that, once `failing` is set, cannot be read at all.
struct UnreadableSamples {
bytes: Vec<u8>,
failing: std::sync::atomic::AtomicBool,
}
impl ByteSource for UnreadableSamples {
fn len(&self) -> u64 {
self.bytes.len() as u64
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> {
let start = usize::try_from(offset).unwrap_or(usize::MAX);
if self.failing.load(Ordering::Relaxed) || start.saturating_add(buf.len()) > self.bytes.len() {
return Err(std::io::Error::other("unreadable"));
}
buf.copy_from_slice(&self.bytes[start..start + buf.len()]);
Ok(())
}
}
#[test]
fn a_request_over_unreadable_samples_stops_at_the_no_picture_budget() {
// 400 frames of one GOP, none of whose samples can be read once the movie is open: the walk gives up after 256 skipped samples
let bytes = h264_mp4(400, 400).unwrap();
let src = Arc::new(UnreadableSamples { bytes, failing: false.into() });
let mut m = Movie::open_source(src.clone()).unwrap();
src.failing.store(true, Ordering::Relaxed);
let r = m.frame(399);
assert!(matches!(&r, Err(VideoError::Decode(e)) if e.contains("no picture in 256 samples")), "{r:?}");
}
#[test]
fn mjpeg_frame_size_is_capped_from_its_header_but_may_differ_from_the_track() {
// A frame whose header differs from the track's size still decodes (within the dimension cap, as before)...
let mut bytes = mjpeg_mov(2).unwrap();
let sof = bytes.windows(2).position(|w| w == [0xFF, 0xC0]).unwrap();
bytes[sof + 5..sof + 9].copy_from_slice(&[0, 8, 0, 8]);
let mut m = Movie::open(bytes).unwrap();
assert!(m.frame(0).is_ok());
// ...but one claiming 16 000 x 16 000 is refused from the header, not decoded.
let mut bytes = mjpeg_mov(2).unwrap();
let sof = bytes.windows(2).position(|w| w == [0xFF, 0xC0]).unwrap();
bytes[sof + 5..sof + 9].copy_from_slice(&[0x3E, 0x80, 0x3E, 0x80]);
let mut m = Movie::open(bytes).unwrap();
let r = m.frame(0);
assert!(matches!(&r, Err(VideoError::Decode(e)) if e.contains("picture too large")), "{r:?}");
assert!(m.frame(1).is_ok(), "the untouched frame still decodes");
}
#[test]
fn unsupported_codec_opens_but_does_not_decode() {
let mut mw = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();