diff --git a/.github/workflows/project-persistence-windows.yml b/.github/workflows/project-persistence-windows.yml new file mode 100644 index 000000000..4d451d3a6 --- /dev/null +++ b/.github/workflows/project-persistence-windows.yml @@ -0,0 +1,67 @@ +name: project-persistence-windows + +on: + pull_request: + branches: + - develop + - main + paths: + - "apps/desktop/core/Cargo.toml" + - "apps/desktop/core/src/lib.rs" + - "apps/desktop/core/src/crate_root.rs" + - "apps/desktop/core/src/project_format.rs" + - "apps/desktop/core/tests/project_persistence*.rs" + - "apps/desktop/core/tests/project_format*.rs" + - "apps/desktop/core/testdata/project-*.json" + - "apps/desktop/src-tauri/Cargo.toml" + - "apps/desktop/src-tauri/Cargo.lock" + - "apps/desktop/src-tauri/src/main.rs" + - "apps/desktop/src-tauri/src/project_persistence.rs" + - "apps/desktop/src-tauri/tests/project_persistence*.rs" + - ".github/workflows/project-persistence-windows.yml" + push: + branches: + - develop + - main + paths: + - "apps/desktop/core/Cargo.toml" + - "apps/desktop/core/src/lib.rs" + - "apps/desktop/core/src/crate_root.rs" + - "apps/desktop/core/src/project_format.rs" + - "apps/desktop/core/tests/project_persistence*.rs" + - "apps/desktop/core/tests/project_format*.rs" + - "apps/desktop/core/testdata/project-*.json" + - "apps/desktop/src-tauri/Cargo.toml" + - "apps/desktop/src-tauri/Cargo.lock" + - "apps/desktop/src-tauri/src/main.rs" + - "apps/desktop/src-tauri/src/project_persistence.rs" + - "apps/desktop/src-tauri/tests/project_persistence*.rs" + - ".github/workflows/project-persistence-windows.yml" + +permissions: + contents: read + +env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: develop + +jobs: + windows-recovery-cleanup: + name: test / project-persistence / windows + runs-on: windows-2025 + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Install Rust 1.97.1 + run: rustup toolchain install 1.97.1 --profile minimal + - name: Prepare compile-only frontendDist fixture + shell: pwsh + run: | + New-Item -ItemType Directory -Force apps/desktop/dist | Out-Null + Set-Content -Path apps/desktop/dist/index.html -Value 'BandScope test fixture' -NoNewline + - name: Run Windows recovery-cleanup regression + run: cargo +1.97.1 test --manifest-path apps/desktop/src-tauri/Cargo.toml --no-default-features --tests diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..751068872 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # ARCHITECTURE.md -Last updated: 2026-03-11 +Last updated: 2026-09-06 ## Brand source @@ -111,8 +111,9 @@ Last updated: 2026-03-11 - Shared contracts live in `packages/shared-types` so the UI can evolve without importing Python internals. - Shared contracts should ultimately model section, role, cue, confidence, and export artifacts explicitly enough that desktop UI and analysis outputs do not invent their own parallel schemas. - The current shared-types baseline includes a rehearsal-domain fixture that exercises section, role, cue, confidence, provenance, and export-summary fields in the desktop shell before the full analysis pipeline lands. +- Current Project Persistence writes a strict `projectFormatVersion: 3` envelope around the validated rehearsal song, closed Active Player preference, and optional path-free app-owned audio `sourceReference`; legacy raw-song, v1, and v2 inputs remain readable through ordered migration. Resource Admission materializes the admitted local source as the fixed app-owned `source.` artifact, verifies publication byte identity, and retains a path-free native identity. Project Persistence injects that identity into Save and re-admits the exact size and SHA-256 on restart; production analysis revalidates the retained identity and decodes a verified private byte snapshot. Source/derived/decision/handoff expansion, autosave/recovery UX, and fresh Active Player audible authority remain follow-up work under #962/#961 rather than parallel stores. - Local analysis orchestration uses typed Tauri IPC commands and a Python subprocess over stdin/stdout rather than a loopback HTTP listener. -- Local audio intake bootstraps a project by validating a user-selected file in Rust, creating app-owned temp/cache/project roots, and referencing the original source file rather than copying it in this phase. +- Local audio intake validates an OS-selected source in Rust, enforces the canonical resource policy, publishes a no-clobber app-owned `source.` copy under the minted project aggregate, verifies the published bytes, and exposes only bounded bootstrap/path-free identity evidence to downstream Project Persistence and analysis consumers. - Those bootstrap roots should resolve from app-owned Tauri data/cache paths instead of the shared system temp namespace. - Product and UX decisions should prefer rehearsal-first simplicity while still maintaining high analytical accuracy. - Security decisions should prefer allowlisted narrow capabilities over generic convenience APIs. diff --git a/CHANGELOG.md b/CHANGELOG.md index 34331fb86..3bca8e7d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. +- Evolve local project writes to `projectFormatVersion: 3`: retain deterministic legacy/v1/v2 migration, persist the closed Active Player source preference, and optionally store a path-free app-owned audio `sourceReference` with bounded byte evidence and canonical SHA-256 content identity for process-restart re-admission. ### Changed @@ -15,7 +16,27 @@ ### Fixed +- Preserve a reopened v3 project's native source selector and stored playback-source preference across an Open Project → Save Project round trip, so resaving cannot silently drop `sourceReference` or reset a non-default stem intent to Full mix. +- Enforce one canonical local-audio resource policy across native local-file/YouTube bootstrap intake, the desktop bridge, Python request preflight, temporal decoding, and stem separation so oversized, overlong, malformed, wrong-rate, or non-finite input fails before bootstrap storage or expensive analysis/model work. +- Preflight source-container duration, sample rate, and channel count from the already-open audio handle before temporal, stem, or bass-transcription decoders resample, downmix, or truncate it; successful metadata probes rewind the handle and malformed probes fail closed. +- Bound the admitted canonical decoded mono buffer to 317,520,000 bytes as well as the existing 39,690,000-sample ceiling, so decoder dtype expansion cannot stay within the sample count while exceeding the explicit in-memory audio budget. +- Fail closed on malformed known YouTube duration metadata before `download=True`; Boolean, non-numeric, non-finite, zero, negative, and non-canonical numeric-subtype duration evidence can no longer authorize a media download through Python numeric coercion or subclass semantics. +- Align YouTube download admission with that same 100 MiB encoded-byte ceiling: abort in-flight with yt-dlp `max_filesize` and a progress hook, reject announced oversize before `download=True`, delete owned `.part` / `.ytdl` / `-Frag*` siblings from that import directory on abort, reject a completed path that resolves outside the current import cache before post-download validation, cleanup, or success, and delete owned post-download artifacts that still exceed the policy. A 60 MiB import that the old 50 MB check rejected is now accepted; a file one byte over 100 MiB is not. +- Bound native stored-score PDF reads to the 25 MiB product limit before heap allocation and revalidate PDF magic on the same opened descriptor, preventing an attached score that later grows from bypassing the local resource boundary. +- Treat every zero-element NumPy layout as empty chord input, including shapes whose first dimension is non-zero, before feature extraction. - Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance. +- Stage and sync new project saves before non-clobbering publication, and enforce the existing 5 MiB project limit during the file read itself so a selected project cannot grow past a metadata preflight into an unbounded load allocation. +- Reject directly selected project symlinks before reading so a chosen `.bscope` path cannot silently redirect the loader to different file content. +- Reject a symlinked/reparse-point save parent before staging so a selected project path cannot redirect new project publication into a different directory. +- Fail closed when a selected `.bscope` path changes file identity between preflight and handle acquisition; Windows opens reparse points without following them and compares native volume serial plus file-index identity across the acquisition boundary. +- Refuse last-component symlink following during Linux/macOS project handle acquisition and make that acquisition non-blocking so a preflight-to-open path swap cannot redirect the loader or stall it on a special file. +- Preserve first-save crash safety on filesystems without hard-link support by publishing the fully synced staging file with an OS-native atomic no-replace rename, so a crash cannot leave an empty reserved final path. +- Reject a stale existing-project replacement when the selected target changes file identity while replacement bytes are staged; native exchange/backup publication restores the competing target instead of clobbering it. +- Recover an interrupted existing-project replacement from a bounded, same-directory identity journal when the target is selected again, while leaving mismatched files untouched. +- Keep renderer project admission passive and path-free: custom prototypes, enumeration/descriptor traps, accessors, runtime playback authorities, unknown fields, invalid app-owned source references, unsafe byte-size values, and missing/non-canonical SHA-256 source identity fail closed before persistence IPC. +- Keep local Demucs loading offline and bounded by resolving a private snapshot copied from the verified cache descriptor through `LocalRepo`; reject missing, modified, empty, non-regular, over-128-MiB, or descriptor-size-racing checkpoint state before model deserialization so mutable cache replacement, post-preflight growth/shrink, and oversized local artifacts cannot alter or exhaust one analysis load. +- Bound PyTorch 2.6+ weights-only checkpoint incompatibility at the admitted local-model boundary instead of leaking serialized class details or silently enabling legacy pickle loading; incompatible technical cache state now returns the existing local-model-unavailable diagnostic and remains a Distribution serialization/provenance decision. +- Reject PyTorch runtime-environment widening at the local Demucs boundary: `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD` cannot turn the upstream implicit checkpoint load back into unrestricted pickle deserialization, and `TORCH_DEVICE_BACKEND_AUTOLOAD=1` cannot auto-import out-of-tree backend extensions before model admission. ## [0.1.3] - 2026-04-29 @@ -75,4 +96,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). +- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file diff --git a/apps/desktop/core/Cargo.toml b/apps/desktop/core/Cargo.toml index b01a537dc..44f482e73 100644 --- a/apps/desktop/core/Cargo.toml +++ b/apps/desktop/core/Cargo.toml @@ -7,7 +7,7 @@ publish = false [lib] name = "bandscope_desktop_core" -path = "src/lib.rs" +path = "src/root.rs" [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage)'] } diff --git a/apps/desktop/core/src/audio_resource.rs b/apps/desktop/core/src/audio_resource.rs new file mode 100644 index 000000000..a8383067b --- /dev/null +++ b/apps/desktop/core/src/audio_resource.rs @@ -0,0 +1,325 @@ +use crate::content_sha256::StreamingSha256; +use std::io::{ErrorKind, Read, Write}; + +/// Maximum encoded local-audio file size accepted by the desktop bootstrap boundary. +pub const MAX_LOCAL_AUDIO_FILE_BYTES: u64 = 100 * 1024 * 1024; + +const LOCAL_AUDIO_READ_ERROR: &str = "Could not read the selected audio file."; +const LOCAL_AUDIO_WRITE_ERROR: &str = "Could not prepare the local project workspace."; +const LOCAL_AUDIO_TOO_LARGE_ERROR: &str = + "Choose a shorter or smaller song file to start analysis."; + +/// Immutable identity evidence for one successfully staged local-audio byte stream. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LocalAudioCopyReceipt { + /// Exact number of bytes written successfully to the staging writer. + pub file_size_bytes: u64, + /// SHA-256 of exactly the bytes written successfully, encoded as lowercase hexadecimal. + pub content_sha256: String, +} + +/// Validate a native local-audio file length before storing bootstrap metadata. +/// +/// The caller must obtain this length from the native filesystem descriptor or +/// metadata boundary rather than from renderer-controlled JSON. The function +/// intentionally returns only bounded product messages and never includes a +/// local path or payload content. +pub fn validate_local_audio_file_size(file_size_bytes: u64) -> Result { + if file_size_bytes == 0 { + return Err(LOCAL_AUDIO_READ_ERROR.to_string()); + } + if file_size_bytes > MAX_LOCAL_AUDIO_FILE_BYTES { + return Err(LOCAL_AUDIO_TOO_LARGE_ERROR.to_string()); + } + Ok(file_size_bytes) +} + +fn read_retrying_interrupted(reader: &mut impl Read, buffer: &mut [u8]) -> Result { + loop { + match reader.read(buffer) { + Ok(read) => return Ok(read), + Err(error) if error.kind() == ErrorKind::Interrupted => continue, + Err(_) => return Err(LOCAL_AUDIO_READ_ERROR.to_string()), + } + } +} + +fn copy_bounded_local_audio_with_limit( + mut reader: R, + writer: &mut W, + max_bytes: u64, +) -> Result { + let mut copied = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + let mut content_digest = StreamingSha256::default(); + + loop { + if copied == max_bytes { + let mut overflow_probe = [0_u8; 1]; + let read = read_retrying_interrupted(&mut reader, &mut overflow_probe)?; + if read == 0 { + break; + } + return Err(LOCAL_AUDIO_TOO_LARGE_ERROR.to_string()); + } + + let remaining = (max_bytes - copied).min(buffer.len() as u64) as usize; + let read = read_retrying_interrupted(&mut reader, &mut buffer[..remaining])?; + if read == 0 { + break; + } + writer + .write_all(&buffer[..read]) + .map_err(|_| LOCAL_AUDIO_WRITE_ERROR.to_string())?; + content_digest + .update(&buffer[..read]) + .map_err(|_| LOCAL_AUDIO_READ_ERROR.to_string())?; + copied += read as u64; + } + + if copied == 0 { + return Err(LOCAL_AUDIO_READ_ERROR.to_string()); + } + let content_sha256 = content_digest + .finalize_hex() + .map_err(|_| LOCAL_AUDIO_READ_ERROR.to_string())?; + Ok(LocalAudioCopyReceipt { + file_size_bytes: copied, + content_sha256, + }) +} + +/// Copy one admitted local-audio stream into a staging writer and return native content identity. +/// +/// Security Notes: callers must pass an already-open, OS-authorized source +/// descriptor and a private app-owned staging writer. The helper writes no more +/// than the 100 MiB ceiling, hashes exactly the bytes whose writes succeeded, +/// and, after reaching the ceiling exactly, reads only one probe byte to detect +/// source growth. Source-read and destination-write failures use distinct +/// bounded product errors so storage failures are not misdiagnosed as bad media. +/// The caller must discard the staging artifact on error, synchronize it before +/// publication, and bind the returned receipt only to the artifact that was +/// actually published. +pub fn copy_bounded_local_audio_with_receipt( + reader: R, + writer: &mut W, +) -> Result { + copy_bounded_local_audio_with_limit(reader, writer, MAX_LOCAL_AUDIO_FILE_BYTES) +} + +/// Re-read a published app-owned source and prove that it matches its staging receipt. +/// +/// Security Notes: the caller must pass an already-open descriptor for the +/// synchronized, published `source.` object. This helper opens no +/// path and grants no filesystem authority. The staging receipt is native +/// evidence from the prior bounded copy, so its byte length becomes the tighter +/// publication-read ceiling: the verifier hashes at most that many bytes and +/// reads one additional probe byte to reject growth. It then requires both size +/// and digest to equal the staging receipt. Any invalid expected length, read, +/// growth, truncation, or content mismatch is reported as a bounded +/// project-workspace failure because the selected source already passed +/// admission before publication. +pub fn verify_local_audio_publication_receipt( + reader: R, + expected: &LocalAudioCopyReceipt, +) -> Result { + if expected.file_size_bytes == 0 || expected.file_size_bytes > MAX_LOCAL_AUDIO_FILE_BYTES { + return Err(LOCAL_AUDIO_WRITE_ERROR.to_string()); + } + + let mut sink = std::io::sink(); + let actual = copy_bounded_local_audio_with_limit(reader, &mut sink, expected.file_size_bytes) + .map_err(|_| LOCAL_AUDIO_WRITE_ERROR.to_string())?; + if actual != *expected { + return Err(LOCAL_AUDIO_WRITE_ERROR.to_string()); + } + Ok(actual) +} + +/// Copy one admitted local-audio stream into a staging writer and return its byte count. +/// +/// This compatibility adapter preserves the existing desktop call boundary while +/// callers migrate to `copy_bounded_local_audio_with_receipt`. It uses the same +/// bounded copy and content-hash path and discards only the returned digest. +pub fn copy_bounded_local_audio(reader: R, writer: &mut W) -> Result { + copy_bounded_local_audio_with_receipt(reader, writer).map(|receipt| receipt.file_size_bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Cursor, Error}; + + struct FailingWriter; + + impl Write for FailingWriter { + fn write(&mut self, _buffer: &[u8]) -> std::io::Result { + Err(Error::new(ErrorKind::Other, "simulated destination failure")) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + struct FailingReader; + + impl Read for FailingReader { + fn read(&mut self, _buffer: &mut [u8]) -> std::io::Result { + Err(Error::new(ErrorKind::Other, "simulated source failure")) + } + } + + struct InterruptedThenReader { + bytes: Cursor>, + interrupted: bool, + } + + impl Read for InterruptedThenReader { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + if !self.interrupted { + self.interrupted = true; + return Err(Error::from(ErrorKind::Interrupted)); + } + self.bytes.read(buffer) + } + } + + struct CountingReader { + bytes: Cursor>, + bytes_read: usize, + } + + impl Read for CountingReader { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + let read = self.bytes.read(buffer)?; + self.bytes_read += read; + Ok(read) + } + } + + #[test] + fn bounded_copy_rejects_stream_growth_without_staging_bytes_past_the_limit() { + let input = Cursor::new(vec![1_u8, 2, 3, 4, 5]); + let mut staged = Vec::new(); + + let error = copy_bounded_local_audio_with_limit(input, &mut staged, 4) + .expect_err("a source that grows beyond the admitted byte limit must fail closed"); + + assert_eq!(error, LOCAL_AUDIO_TOO_LARGE_ERROR); + assert_eq!(staged, vec![1_u8, 2, 3, 4]); + } + + #[test] + fn bounded_copy_accepts_the_exact_limit_and_reports_content_identity() { + let input = Cursor::new(vec![1_u8, 2, 3, 4]); + let mut staged = Vec::new(); + + let receipt = copy_bounded_local_audio_with_limit(input, &mut staged, 4) + .expect("the exact encoded-byte limit remains admissible"); + + assert_eq!(receipt.file_size_bytes, 4); + assert_eq!( + receipt.content_sha256, + "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a" + ); + assert_eq!(staged, vec![1_u8, 2, 3, 4]); + } + + #[test] + fn bounded_copy_reports_destination_failure_as_workspace_failure() { + let input = Cursor::new(vec![1_u8, 2, 3, 4]); + let mut staged = FailingWriter; + + let error = copy_bounded_local_audio_with_limit(input, &mut staged, 4) + .expect_err("a staging write failure must not be reported as a source read failure"); + + assert_eq!(error, LOCAL_AUDIO_WRITE_ERROR); + } + + #[test] + fn bounded_copy_keeps_source_failure_distinct_from_workspace_failure() { + let input = FailingReader; + let mut staged = Vec::new(); + + let error = copy_bounded_local_audio_with_limit(input, &mut staged, 4) + .expect_err("a source read failure must retain the media-read diagnosis"); + + assert_eq!(error, LOCAL_AUDIO_READ_ERROR); + assert!(staged.is_empty()); + } + + #[test] + fn bounded_copy_retries_interrupted_source_reads_without_changing_identity() { + let input = InterruptedThenReader { + bytes: Cursor::new(vec![1_u8, 2, 3, 4]), + interrupted: false, + }; + let mut staged = Vec::new(); + + let receipt = copy_bounded_local_audio_with_limit(input, &mut staged, 4) + .expect("an interrupted source read should be retried"); + + assert_eq!(receipt.file_size_bytes, 4); + assert_eq!( + receipt.content_sha256, + "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a" + ); + assert_eq!(staged, vec![1_u8, 2, 3, 4]); + } + + #[test] + fn publication_verification_maps_read_failure_to_workspace_failure() { + let expected = LocalAudioCopyReceipt { + file_size_bytes: 4, + content_sha256: + "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a" + .to_string(), + }; + + let error = verify_local_audio_publication_receipt(FailingReader, &expected) + .expect_err("published artifact read failure must be a workspace failure"); + + assert_eq!(error, LOCAL_AUDIO_WRITE_ERROR); + } + + #[test] + fn publication_verification_stops_after_expected_size_plus_one_probe_byte() { + let expected = LocalAudioCopyReceipt { + file_size_bytes: 4, + content_sha256: + "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a" + .to_string(), + }; + let mut published = CountingReader { + bytes: Cursor::new(vec![1_u8, 2, 3, 4, 5, 6, 7, 8]), + bytes_read: 0, + }; + + let error = verify_local_audio_publication_receipt(&mut published, &expected) + .expect_err("a grown published artifact must fail without scanning unrelated tail bytes"); + + assert_eq!(error, LOCAL_AUDIO_WRITE_ERROR); + assert_eq!(published.bytes_read, 5); + } + + #[test] + fn publication_verification_rejects_impossible_expected_lengths_without_reading() { + for file_size_bytes in [0, MAX_LOCAL_AUDIO_FILE_BYTES + 1] { + let expected = LocalAudioCopyReceipt { + file_size_bytes, + content_sha256: "00".repeat(32), + }; + let mut published = CountingReader { + bytes: Cursor::new(vec![1_u8, 2, 3, 4]), + bytes_read: 0, + }; + + let error = verify_local_audio_publication_receipt(&mut published, &expected) + .expect_err("an impossible native receipt length must fail before reading"); + + assert_eq!(error, LOCAL_AUDIO_WRITE_ERROR); + assert_eq!(published.bytes_read, 0); + } + } +} diff --git a/apps/desktop/core/src/content_sha256.rs b/apps/desktop/core/src/content_sha256.rs new file mode 100644 index 000000000..dbb109a49 --- /dev/null +++ b/apps/desktop/core/src/content_sha256.rs @@ -0,0 +1,293 @@ +//! Streaming SHA-256 for local content-identity receipts. +//! +//! The operations and constants follow NIST FIPS 180-4 SHA-256. The known-answer +//! tests below are correctness checks, not CAVP validation or a FIPS 140 claim. + +use std::io::{self, ErrorKind, Read}; + +const BLOCK_BYTES: usize = 64; +const DIGEST_BYTES: usize = 32; +const INITIAL_STATE: [u32; 8] = [ + 0x6a09_e667, + 0xbb67_ae85, + 0x3c6e_f372, + 0xa54f_f53a, + 0x510e_527f, + 0x9b05_688c, + 0x1f83_d9ab, + 0x5be0_cd19, +]; +const ROUND_CONSTANTS: [u32; 64] = [ + 0x428a_2f98, 0x7137_4491, 0xb5c0_fbcf, 0xe9b5_dba5, 0x3956_c25b, 0x59f1_11f1, + 0x923f_82a4, 0xab1c_5ed5, 0xd807_aa98, 0x1283_5b01, 0x2431_85be, 0x550c_7dc3, + 0x72be_5d74, 0x80de_b1fe, 0x9bdc_06a7, 0xc19b_f174, 0xe49b_69c1, 0xefbe_4786, + 0x0fc1_9dc6, 0x240c_a1cc, 0x2de9_2c6f, 0x4a74_84aa, 0x5cb0_a9dc, 0x76f9_88da, + 0x983e_5152, 0xa831_c66d, 0xb003_27c8, 0xbf59_7fc7, 0xc6e0_0bf3, 0xd5a7_9147, + 0x06ca_6351, 0x1429_2967, 0x27b7_0a85, 0x2e1b_2138, 0x4d2c_6dfc, 0x5338_0d13, + 0x650a_7354, 0x766a_0abb, 0x81c2_c92e, 0x9272_2c85, 0xa2bf_e8a1, 0xa81a_664b, + 0xc24b_8b70, 0xc76c_51a3, 0xd192_e819, 0xd699_0624, 0xf40e_3585, 0x106a_a070, + 0x19a4_c116, 0x1e37_6c08, 0x2748_774c, 0x34b0_bcb5, 0x391c_0cb3, 0x4ed8_aa4a, + 0x5b9c_ca4f, 0x682e_6ff3, 0x748f_82ee, 0x78a5_636f, 0x84c8_7814, 0x8cc7_0208, + 0x90be_fffa, 0xa450_6ceb, 0xbef9_a3f7, 0xc671_78f2, +]; + +#[derive(Clone)] +pub(crate) struct StreamingSha256 { + words: [u32; 8], + buffer: [u8; BLOCK_BYTES], + buffer_len: usize, + message_len_bytes: u64, +} + +impl Default for StreamingSha256 { + fn default() -> Self { + Self { + words: INITIAL_STATE, + buffer: [0; BLOCK_BYTES], + buffer_len: 0, + message_len_bytes: 0, + } + } +} + +impl StreamingSha256 { + /// Add the next contiguous admitted byte slice to this digest state. + pub(crate) fn update(&mut self, mut bytes: &[u8]) -> Result<(), ()> { + self.message_len_bytes = self + .message_len_bytes + .checked_add(bytes.len() as u64) + .ok_or(())?; + + if self.buffer_len != 0 { + let copied = (BLOCK_BYTES - self.buffer_len).min(bytes.len()); + self.buffer[self.buffer_len..self.buffer_len + copied] + .copy_from_slice(&bytes[..copied]); + self.buffer_len += copied; + bytes = &bytes[copied..]; + if self.buffer_len == BLOCK_BYTES { + let block = self.buffer; + self.compress(&block); + self.buffer_len = 0; + } + } + + while bytes.len() >= BLOCK_BYTES { + let block: &[u8; BLOCK_BYTES] = bytes[..BLOCK_BYTES].try_into().map_err(|_| ())?; + self.compress(block); + bytes = &bytes[BLOCK_BYTES..]; + } + + if !bytes.is_empty() { + self.buffer[..bytes.len()].copy_from_slice(bytes); + self.buffer_len = bytes.len(); + } + Ok(()) + } + + /// Finalize the digest as canonical lowercase hexadecimal. + pub(crate) fn finalize_hex(mut self) -> Result { + let message_len_bits = self.message_len_bytes.checked_mul(8).ok_or(())?; + + self.buffer[self.buffer_len] = 0x80; + self.buffer_len += 1; + if self.buffer_len > 56 { + self.buffer[self.buffer_len..].fill(0); + let block = self.buffer; + self.compress(&block); + self.buffer = [0; BLOCK_BYTES]; + self.buffer_len = 0; + } + self.buffer[self.buffer_len..56].fill(0); + self.buffer[56..].copy_from_slice(&message_len_bits.to_be_bytes()); + let block = self.buffer; + self.compress(&block); + + let mut digest = [0_u8; DIGEST_BYTES]; + for (index, word) in self.words.into_iter().enumerate() { + digest[index * 4..index * 4 + 4].copy_from_slice(&word.to_be_bytes()); + } + + let mut encoded = String::with_capacity(DIGEST_BYTES * 2); + const HEX: &[u8; 16] = b"0123456789abcdef"; + for byte in digest { + encoded.push(HEX[(byte >> 4) as usize] as char); + encoded.push(HEX[(byte & 0x0f) as usize] as char); + } + Ok(encoded) + } + + fn compress(&mut self, block: &[u8; BLOCK_BYTES]) { + let mut schedule = [0_u32; 64]; + for (index, chunk) in block.chunks_exact(4).enumerate() { + schedule[index] = u32::from_be_bytes( + chunk + .try_into() + .expect("SHA-256 message word always contains four bytes"), + ); + } + for index in 16..64 { + let small_sigma0 = schedule[index - 15].rotate_right(7) + ^ schedule[index - 15].rotate_right(18) + ^ (schedule[index - 15] >> 3); + let small_sigma1 = schedule[index - 2].rotate_right(17) + ^ schedule[index - 2].rotate_right(19) + ^ (schedule[index - 2] >> 10); + schedule[index] = schedule[index - 16] + .wrapping_add(small_sigma0) + .wrapping_add(schedule[index - 7]) + .wrapping_add(small_sigma1); + } + + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = self.words; + for index in 0..64 { + let big_sigma1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let choose = (e & f) ^ ((!e) & g); + let temporary1 = h + .wrapping_add(big_sigma1) + .wrapping_add(choose) + .wrapping_add(ROUND_CONSTANTS[index]) + .wrapping_add(schedule[index]); + let big_sigma0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let majority = (a & b) ^ (a & c) ^ (b & c); + let temporary2 = big_sigma0.wrapping_add(majority); + + h = g; + g = f; + f = e; + e = d.wrapping_add(temporary1); + d = c; + c = b; + b = a; + a = temporary1.wrapping_add(temporary2); + } + + self.words[0] = self.words[0].wrapping_add(a); + self.words[1] = self.words[1].wrapping_add(b); + self.words[2] = self.words[2].wrapping_add(c); + self.words[3] = self.words[3].wrapping_add(d); + self.words[4] = self.words[4].wrapping_add(e); + self.words[5] = self.words[5].wrapping_add(f); + self.words[6] = self.words[6].wrapping_add(g); + self.words[7] = self.words[7].wrapping_add(h); + } +} + +/// Hash a caller-owned byte stream as canonical lowercase SHA-256. +/// +/// Security Notes: this helper never opens a path, logs bytes, or grants filesystem +/// authority. The caller must supply an already-authorized reader and decide how +/// the resulting digest is bound to a concrete artifact. `Interrupted` reads are +/// retried; other reader failures are returned unchanged. This is content identity, +/// not an authenticity primitive or a FIPS module-validation claim. +pub fn sha256_hex_reader(mut reader: impl Read) -> io::Result { + let mut digest = StreamingSha256::default(); + let mut chunk = [0_u8; 64 * 1024]; + loop { + match reader.read(&mut chunk) { + Ok(0) => break, + Ok(read_bytes) => digest + .update(&chunk[..read_bytes]) + .map_err(|_| io::Error::new(ErrorKind::InvalidData, "SHA-256 input too large"))?, + Err(error) if error.kind() == ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + } + } + digest + .finalize_hex() + .map_err(|_| io::Error::new(ErrorKind::InvalidData, "SHA-256 input too large")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Cursor, Error}; + + fn digest_in_chunks(bytes: &[u8], chunk_size: usize) -> String { + let mut digest = StreamingSha256::default(); + for chunk in bytes.chunks(chunk_size) { + digest.update(chunk).expect("test vector length must fit SHA-256"); + } + digest + .finalize_hex() + .expect("test vector bit length must fit SHA-256") + } + + struct InterruptedShortReader { + bytes: Vec, + cursor: usize, + interrupted: bool, + } + + impl Read for InterruptedShortReader { + fn read(&mut self, output: &mut [u8]) -> io::Result { + if !self.interrupted { + self.interrupted = true; + return Err(Error::from(ErrorKind::Interrupted)); + } + if self.cursor == self.bytes.len() { + return Ok(0); + } + let copied = 7.min(output.len()).min(self.bytes.len() - self.cursor); + output[..copied].copy_from_slice(&self.bytes[self.cursor..self.cursor + copied]); + self.cursor += copied; + Ok(copied) + } + } + + struct FailingReader; + + impl Read for FailingReader { + fn read(&mut self, _output: &mut [u8]) -> io::Result { + Err(Error::new(ErrorKind::Other, "fixture read failure")) + } + } + + #[test] + fn matches_sha256_known_answer_vectors() { + for (message, expected) in [ + ( + &b""[..], + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ), + ( + &b"abc"[..], + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ), + ( + &b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"[..], + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1", + ), + ] { + assert_eq!(digest_in_chunks(message, 7), expected); + } + } + + #[test] + fn shared_reader_retries_interrupted_short_reads() { + let bytes = (0..131_111) + .map(|index| (index % 251) as u8) + .collect::>(); + let expected = sha256_hex_reader(Cursor::new(&bytes)).expect("reference hash should succeed"); + let actual = sha256_hex_reader(InterruptedShortReader { + bytes, + cursor: 0, + interrupted: false, + }) + .expect("interrupted short reads should be retried"); + assert_eq!(actual, expected); + } + + #[test] + fn shared_reader_propagates_non_interrupted_failure() { + let error = sha256_hex_reader(FailingReader).expect_err("reader failure must propagate"); + assert_eq!(error.kind(), ErrorKind::Other); + } + + #[test] + fn matches_the_million_a_vector() { + assert_eq!( + digest_in_chunks(&vec![b'a'; 1_000_000], 64 * 1024), + "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0" + ); + } +} diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index 200726570..aaf2fc812 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -122,12 +122,149 @@ pub enum AnalysisCacheStatus { pub struct RehearsalSongPayload { id: String, title: String, + #[serde( + default, + deserialize_with = "deserialize_project_tempo", + skip_serializing_if = "Option::is_none" + )] + tempo: Option, sections: Vec, export_summary: ExportSummaryPayload, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_present_optional", + skip_serializing_if = "Option::is_none" + )] + collaboration: Option, + #[serde( + default, + deserialize_with = "deserialize_present_optional", + skip_serializing_if = "Option::is_none" + )] score_attachments: Option>, } +fn deserialize_project_tempo<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Value::deserialize(deserializer)?; + match value { + Value::Number(number) => match number.as_f64() { + Some(tempo) if tempo.is_finite() && tempo > 0.0 => Ok(Some(tempo)), + _ => Err(serde::de::Error::custom( + "project tempo must be a finite positive number", + )), + }, + _ => Err(serde::de::Error::custom( + "project tempo must be a finite positive number", + )), + } +} + +fn deserialize_present_optional<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + T::deserialize(deserializer).map(Some) +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RehearsalCollaborationSyncModePayload { + LocalOnly, + PlannedCloud, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RehearsalAssignmentStatusPayload { + Todo, + InProgress, + Ready, + Blocked, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RehearsalCommentStatusPayload { + Open, + Resolved, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RehearsalApprovalStatusPayload { + Pending, + Approved, + ChangesRequested, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RehearsalAssignmentPayload { + id: String, + assignee: String, + summary: String, + section_id: String, + #[serde( + default, + deserialize_with = "deserialize_present_optional", + skip_serializing_if = "Option::is_none" + )] + role_id: Option, + status: RehearsalAssignmentStatusPayload, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RehearsalCommentPayload { + id: String, + author: String, + body: String, + section_id: String, + #[serde( + default, + deserialize_with = "deserialize_present_optional", + skip_serializing_if = "Option::is_none" + )] + role_id: Option, + status: RehearsalCommentStatusPayload, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RehearsalApprovalPayload { + id: String, + scope: String, + owner: String, + status: RehearsalApprovalStatusPayload, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RehearsalCollaborationPayload { + sync_mode: RehearsalCollaborationSyncModePayload, + sync_note: String, + assignments: Vec, + comments: Vec, + approvals: Vec, +} + +/// Current on-disk project format version, independent of the app version. +pub const CURRENT_PROJECT_FORMAT_VERSION: u16 = 1; + +/// Versioned project envelope. The song remains the compatibility view until +/// source, derived, decision, handoff, preference, and runtime fields are +/// promoted into typed sections in a later format version. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProjectFilePayload { + project_format_version: u16, + song: RehearsalSongPayload, +} + /// Score attachment metadata persisted inside the song payload. Only the /// locally minted score id and the display file name cross the IPC boundary; /// the PDF bytes stay in the app-owned scores directory keyed by that id. @@ -138,18 +275,41 @@ pub struct ScoreAttachmentMetadataPayload { file_name: String, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ConfidenceLevelPayload { + Low, + Medium, + High, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ProvenanceSourcePayload { + Model, + User, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ConfidencePayload { - level: String, - source: String, + level: ConfidenceLevelPayload, + source: ProvenanceSourcePayload, notes: String, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CueKindPayload { + Lyric, + Count, + Transition, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct CuePayload { - kind: String, + kind: CueKindPayload, value: String, } @@ -165,15 +325,78 @@ pub struct RangePayload { pub struct HarmonyPayload { chord: String, function_label: String, - source: String, + source: ProvenanceSourcePayload, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ManualOverrideFieldPayload { + Harmony, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ManualOverrideSourcePayload { + User, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ManualOverrideHarmonyPayload { + chord: String, + function_label: String, + source: ManualOverrideSourcePayload, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ManualOverridePayload { - field: String, - value: HarmonyPayload, - source: String, + field: ManualOverrideFieldPayload, + value: ManualOverrideHarmonyPayload, + source: ManualOverrideSourcePayload, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TranscriptionNotePayload { + pitch: String, + onset: f64, + offset: f64, + velocity: f64, +} + +fn deserialize_practice_progress<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Value::deserialize(deserializer)?; + match value { + Value::Number(number) => match number.as_u64() { + Some(progress) if progress <= 100 => Ok(Some(progress as u8)), + _ => Err(serde::de::Error::custom( + "practiceProgress must be an integer from 0 through 100", + )), + }, + _ => Err(serde::de::Error::custom( + "practiceProgress must be an integer from 0 through 100", + )), + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RehearsalRoleTypePayload { + Instrument, + Vocal, + Hand, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RehearsalPriorityPayload { + Low, + Medium, + High, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -181,16 +404,40 @@ pub struct ManualOverridePayload { pub struct RehearsalRolePayload { id: String, name: String, - role_type: String, + role_type: RehearsalRoleTypePayload, harmony: HarmonyPayload, + #[serde( + default, + deserialize_with = "deserialize_present_optional", + skip_serializing_if = "Option::is_none" + )] + harmonic_explanation: Option, cue: CuePayload, range: RangePayload, confidence: ConfidencePayload, - rehearsal_priority: String, + rehearsal_priority: RehearsalPriorityPayload, simplification: String, setup_note: String, + #[serde( + default, + deserialize_with = "deserialize_present_optional", + skip_serializing_if = "Option::is_none" + )] + transposition_plan: Option, manual_overrides: Vec, overlap_warnings: Vec, + #[serde( + default, + deserialize_with = "deserialize_present_optional", + skip_serializing_if = "Option::is_none" + )] + transcription: Option>, + #[serde( + default, + deserialize_with = "deserialize_practice_progress", + skip_serializing_if = "Option::is_none" + )] + practice_progress: Option, } #[derive(Clone, Debug, Serialize)] @@ -235,11 +482,26 @@ pub struct PartGraphNodePayload { handoff_from: Vec, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SectionFormLabelPayload { + Intro, + Verse, + PreChorus, + Chorus, + Bridge, + Outro, + Tag, + Pickup, + Stop, + Handoff, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RehearsalSectionPayload { id: String, - label: String, + label: SectionFormLabelPayload, groove: String, time_range: SectionTimeRangePayload, confidence: ConfidencePayload, @@ -247,10 +509,17 @@ pub struct RehearsalSectionPayload { part_graph: Vec, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ExportFormatPayload { + CueSheet, + ChartSummary, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExportSummaryPayload { - format: String, + format: ExportFormatPayload, headline: String, focus_sections: Vec, } @@ -528,12 +797,25 @@ pub fn is_youtube_video_id(value: &str) -> bool { } pub fn project_payload_from_content(content: &str) -> Result { - if let Ok(parsed) = serde_json::from_str::(content) { + let payload = serde_json::from_str::(content) + .map_err(|_| "Invalid project file format".to_string())?; + + if let Some(version_value) = payload.get("projectFormatVersion") { + let version = version_value + .as_u64() + .ok_or_else(|| "Invalid project file format".to_string())?; + if version != u64::from(CURRENT_PROJECT_FORMAT_VERSION) { + return Err(format!("Unsupported project format version: {version}")); + } + let envelope = serde_json::from_value::(payload) + .map_err(|_| "Invalid project file format".to_string())?; + return Ok(envelope.song); + } + + if let Ok(parsed) = serde_json::from_value::(payload.clone()) { return Ok(parsed); } - let payload = serde_json::from_str::(content) - .map_err(|_| "Invalid project file format".to_string())?; if let Some(sections) = payload.get("sections").and_then(Value::as_array) { for (section_index, section) in sections.iter().enumerate() { if section @@ -550,6 +832,15 @@ pub fn project_payload_from_content(content: &str) -> Result Result { + serde_json::to_string_pretty(&ProjectFilePayload { + project_format_version: CURRENT_PROJECT_FORMAT_VERSION, + song: payload.clone(), + }) + .map_err(|_| "Failed to serialize project file format".to_string()) +} + #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ScoreAttachmentPayload { @@ -869,6 +1160,95 @@ mod tests { assert_eq!(parsed.title, "Late Night Set"); } + #[test] + fn project_format_v1_round_trips_the_song_and_tempo() { + let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + payload["tempo"] = json!(120.0); + let song = serde_json::from_value::(payload) + .expect("song payload should deserialize"); + + let content = project_content_for_payload(&song).expect("v1 project should serialize"); + let encoded: Value = serde_json::from_str(&content).expect("v1 project should be JSON"); + assert_eq!( + encoded["projectFormatVersion"], + json!(CURRENT_PROJECT_FORMAT_VERSION) + ); + assert_eq!(encoded["song"]["tempo"], json!(120.0)); + + let parsed = project_payload_from_content(&content).expect("v1 project should load"); + assert_eq!(parsed.title, "Late Night Set"); + assert_eq!(parsed.tempo, Some(120.0)); + } + + #[test] + fn project_format_v1_fixture_is_loadable() { + let parsed = project_payload_from_content(include_str!("../testdata/project-v1.json")) + .expect("the checked-in v1 fixture should load"); + + assert_eq!(parsed.id, "fixture-song"); + assert_eq!(parsed.tempo, Some(96.0)); + } + + #[test] + fn project_format_rejects_unknown_fields_and_unsupported_versions() { + let payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + let mut envelope = json!({ + "projectFormatVersion": CURRENT_PROJECT_FORMAT_VERSION, + "song": payload + }); + envelope["unexpected"] = json!(true); + assert_eq!( + project_payload_from_content(&envelope.to_string()) + .expect_err("unknown fields fail closed"), + "Invalid project file format" + ); + + let supported_payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + let supported_envelope = json!({ + "projectFormatVersion": CURRENT_PROJECT_FORMAT_VERSION + 1, + "song": supported_payload + }); + assert_eq!( + project_payload_from_content(&supported_envelope.to_string()) + .expect_err("unsupported version should be explicit"), + "Unsupported project format version: 2" + ); + + let future_envelope = json!({ + "projectFormatVersion": CURRENT_PROJECT_FORMAT_VERSION + 1, + "futureEnvelopeField": true, + "song": { "futureSongField": "new schema" } + }); + assert_eq!( + project_payload_from_content(&future_envelope.to_string()) + .expect_err("future schema should report its unsupported version"), + "Unsupported project format version: 2" + ); + } + + #[test] + fn project_format_rejects_invalid_tempo_values() { + for invalid_tempo in [json!(null), json!(0), json!(-10), json!("120")] { + let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + payload["tempo"] = invalid_tempo; + assert!( + serde_json::from_value::(payload).is_err(), + "invalid tempo should fail closed" + ); + } + + assert!( + project_payload_from_content( + &format!( + r#"{{"projectFormatVersion":{},"song":{{"id":"song","title":"Song","tempo":1e999,"sections":[],"exportSummary":{{}}}}}}"#, + CURRENT_PROJECT_FORMAT_VERSION + ) + ) + .is_err(), + "non-finite JSON numbers should fail closed" + ); + } + #[test] fn project_payload_from_content_rejects_malformed_or_incomplete_payloads() { assert_eq!( diff --git a/apps/desktop/core/src/project_format.rs b/apps/desktop/core/src/project_format.rs new file mode 100644 index 000000000..444ad5e15 --- /dev/null +++ b/apps/desktop/core/src/project_format.rs @@ -0,0 +1,306 @@ +//! Versioned local project document and migration boundary. +//! +//! Version 2 introduced durable project preferences without serializing a +//! revocable runtime playback URL. Version 3 adds an app-owned audio source +//! reference that contains no user filesystem path. The existing v1/legacy +//! song parser remains the migration authority for historical inputs; this +//! module owns the current envelope presented to external crate consumers. + +use crate::{ + audio_resource::MAX_LOCAL_AUDIO_FILE_BYTES, + core::{ + is_valid_project_id, project_payload_from_content as project_v1_payload_from_content, + RehearsalSongPayload, AUDIO_EXTENSIONS, + }, + publication_identity::LocalAudioPublicationIdentity, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Current on-disk project format version, independent of the app version. +pub const CURRENT_PROJECT_FORMAT_VERSION: u16 = 3; + +/// Stable playback-source identity stored in project preferences. +/// +/// These values describe rehearsal intent. They are resolved against current +/// native availability after reopen and must never contain a +/// `bandscope-playback` authority or filesystem path. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SelectedPlaybackSourcePayload { + /// Use the admitted full mix. + FullMix, + /// Prefer the currently admitted vocal stem. + Vocals, + /// Prefer the currently admitted bass stem. + Bass, + /// Prefer the currently admitted drum stem. + Drums, + /// Prefer the currently admitted residual/other-instruments stem. + Other, +} + +/// Durable UI preferences that belong to the project rather than a WebView +/// session or localStorage authority. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ProjectPreferencesPayload { + /// Stable playback-source semantic to resolve on reopen. + pub selected_playback_source: SelectedPlaybackSourcePayload, +} + +impl Default for ProjectPreferencesPayload { + fn default() -> Self { + Self { + selected_playback_source: SelectedPlaybackSourcePayload::FullMix, + } + } +} + +/// Durable handle for the app-owned full-mix artifact needed after process +/// restart. +/// +/// The reference deliberately stores no absolute/relative user path. Native +/// Resource Admission derives the artifact location from `project_id` and the +/// fixed `source.` artifact name, then re-validates byte length and +/// SHA-256 content identity before issuing any fresh runtime authority. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ProjectSourceReferencePayload { + /// Opaque app-owned project namespace identifier. + pub project_id: String, + /// Fixed app-owned artifact basename, for example `source.wav`. + pub artifact_name: String, + /// Closed audio extension admitted by BandScope. + pub extension: String, + /// Expected non-zero byte length used as bounded re-admission evidence. + pub file_size_bytes: u64, + /// Canonical lowercase SHA-256 digest of the admitted app-owned audio bytes. + pub content_sha256: String, +} + +/// Current typed project document after historical migration. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ProjectDocumentPayload { + /// Validated rehearsal song compatibility view. + pub song: RehearsalSongPayload, + /// Durable project preferences that are safe to persist. + pub preferences: ProjectPreferencesPayload, + /// Optional app-owned source reference. Historical projects migrate with + /// this absent rather than inventing source authority. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_reference: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProjectFileV2Payload { + project_format_version: u16, + song: RehearsalSongPayload, + preferences: ProjectPreferencesPayload, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProjectFileV3Payload { + project_format_version: u16, + song: RehearsalSongPayload, + preferences: ProjectPreferencesPayload, + #[serde(default, skip_serializing_if = "Option::is_none")] + source_reference: Option, +} + +fn unsupported_version(version: u64) -> String { + format!("Unsupported project format version: {version}") +} + +fn sha256_hex_is_canonical(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn source_reference_is_valid(reference: &ProjectSourceReferencePayload) -> bool { + if !is_valid_project_id(&reference.project_id) + || reference.file_size_bytes == 0 + || reference.file_size_bytes > MAX_LOCAL_AUDIO_FILE_BYTES + || !AUDIO_EXTENSIONS.contains(&reference.extension.as_str()) + || !sha256_hex_is_canonical(&reference.content_sha256) + { + return false; + } + + let expected_artifact_name = format!("source.{}", reference.extension); + reference.artifact_name == expected_artifact_name +} + +/// Project verified Resource Admission evidence into the durable Project Persistence schema. +/// +/// Security Notes: this is the anti-corruption layer between the two bounded +/// contexts. It copies only the path-free identity fields and re-validates the +/// resulting Project Persistence reference before serialization. This matters +/// even for a typed input because internal callers or deserialization can still +/// construct a `LocalAudioPublicationIdentity` without going through the +/// Resource Admission builder. User filesystem paths and playback capabilities +/// therefore cannot cross this handoff. +pub fn project_source_reference_from_publication_identity( + identity: &LocalAudioPublicationIdentity, +) -> Result { + let reference = ProjectSourceReferencePayload { + project_id: identity.project_id.clone(), + artifact_name: identity.artifact_name.clone(), + extension: identity.extension.clone(), + file_size_bytes: identity.file_size_bytes, + content_sha256: identity.content_sha256.clone(), + }; + if !source_reference_is_valid(&reference) { + return Err("Invalid project document payload".to_string()); + } + Ok(reference) +} + +fn validate_document(document: ProjectDocumentPayload) -> Result { + if document + .source_reference + .as_ref() + .is_some_and(|reference| !source_reference_is_valid(reference)) + { + return Err("Invalid project document payload".to_string()); + } + Ok(document) +} + +/// Admit a renderer-supplied current project document before publication. +/// +/// Security Notes: renderer IPC values are untrusted. The document, nested +/// preferences, stable playback-source enum, and rehearsal-song DTO use typed +/// allowlists/`deny_unknown_fields`. Renderer-supplied `sourceReference` is +/// rejected even when structurally valid because filesystem byte identity and +/// digest evidence must come from native Resource Admission state. Native +/// persistence code may inject that verified identity through the typed ACL +/// above before serialization; renderer JSON never authors filesystem paths, +/// artifact identity, byte evidence, or playback authority. +pub fn project_document_from_value(value: Value) -> Result { + let document = serde_json::from_value::(value) + .map_err(|_| "Invalid project document payload".to_string())?; + if document.source_reference.is_some() { + return Err("Invalid project document payload".to_string()); + } + validate_document(document) +} + +/// Parse a current, v2, v1, or legacy project into the current typed document. +/// +/// Security Notes: `.bscope` bytes are untrusted input. Versions 2 and 3 use +/// `deny_unknown_fields` envelopes and closed playback-source semantics. +/// Version 3 additionally validates the app-owned source reference without +/// accepting any user filesystem path and requires canonical SHA-256 content +/// identity plus a byte length within Resource Admission's ceiling so byte +/// length alone can never be treated as sufficient re-admission evidence. +/// Version 1 and legacy raw-song inputs are delegated to the existing strict +/// parser and migrated in memory with the explicit `full_mix` default and no +/// invented source reference. Unsupported versions fail before their body is +/// interpreted as current truth. +pub fn project_document_from_content(content: &str) -> Result { + let root = serde_json::from_str::(content) + .map_err(|_| "Invalid project file format".to_string())?; + + let Some(version_value) = root.get("projectFormatVersion") else { + let song = project_v1_payload_from_content(content)?; + return Ok(ProjectDocumentPayload { + song, + preferences: ProjectPreferencesPayload::default(), + source_reference: None, + }); + }; + + let version = version_value + .as_u64() + .ok_or_else(|| "Invalid project file format".to_string())?; + + match version { + 1 => { + let song = project_v1_payload_from_content(content)?; + Ok(ProjectDocumentPayload { + song, + preferences: ProjectPreferencesPayload::default(), + source_reference: None, + }) + } + 2 => { + let envelope = serde_json::from_value::(root) + .map_err(|_| "Invalid project file format".to_string())?; + if envelope.project_format_version != 2 { + return Err(unsupported_version(u64::from( + envelope.project_format_version, + ))); + } + Ok(ProjectDocumentPayload { + song: envelope.song, + preferences: envelope.preferences, + source_reference: None, + }) + } + 3 => { + let envelope = serde_json::from_value::(root) + .map_err(|_| "Invalid project file format".to_string())?; + if envelope.project_format_version != CURRENT_PROJECT_FORMAT_VERSION { + return Err(unsupported_version(u64::from( + envelope.project_format_version, + ))); + } + validate_document(ProjectDocumentPayload { + song: envelope.song, + preferences: envelope.preferences, + source_reference: envelope.source_reference, + }) + .map_err(|_| "Invalid project file format".to_string()) + } + _ => Err(unsupported_version(version)), + } +} + +/// Compatibility view for callers that currently consume only the song. +/// +/// The current reader still accepts v1, v2, and legacy projects through the +/// ordered migration above, while current preferences/source references remain +/// available through `project_document_from_content` for the Project +/// Persistence/UI bridge. +pub fn project_payload_from_content(content: &str) -> Result { + project_document_from_content(content).map(|document| document.song) +} + +/// Serialize a typed current document as a strict version-3 project envelope. +pub fn project_content_for_document(payload: &ProjectDocumentPayload) -> Result { + if payload + .source_reference + .as_ref() + .is_some_and(|reference| !source_reference_is_valid(reference)) + { + return Err("Invalid project document payload".to_string()); + } + + serde_json::to_string_pretty(&ProjectFileV3Payload { + project_format_version: CURRENT_PROJECT_FORMAT_VERSION, + song: payload.song.clone(), + preferences: payload.preferences.clone(), + source_reference: payload.source_reference.clone(), + }) + .map_err(|_| "Failed to serialize project file format".to_string()) +} + +/// Compatibility writer for callers that currently submit only a song. +/// +/// Existing Tauri save callers therefore advance to v3 without inventing a +/// source choice or source reference: their deterministic migration default is +/// `full_mix` and an absent source reference until Resource Admission supplies +/// an explicit app-owned artifact identity. +pub fn project_content_for_payload(payload: &RehearsalSongPayload) -> Result { + project_content_for_document(&ProjectDocumentPayload { + song: payload.clone(), + preferences: ProjectPreferencesPayload::default(), + source_reference: None, + }) +} diff --git a/apps/desktop/core/src/publication_identity.rs b/apps/desktop/core/src/publication_identity.rs new file mode 100644 index 000000000..0d984b2a0 --- /dev/null +++ b/apps/desktop/core/src/publication_identity.rs @@ -0,0 +1,83 @@ +use crate::{ + audio_resource::{LocalAudioCopyReceipt, MAX_LOCAL_AUDIO_FILE_BYTES}, + runtime_core::{is_valid_project_id, AUDIO_EXTENSIONS}, +}; +use serde::{Deserialize, Serialize}; + +const LOCAL_AUDIO_PUBLICATION_IDENTITY_ERROR: &str = + "Could not prepare the local project workspace."; + +/// Path-free native identity for one verified app-owned local-audio publication. +/// +/// This value is suitable for Project Persistence handoff because it names only +/// a BandScope-owned artifact and carries the exact native size/digest evidence +/// produced by Resource Admission. It never contains an external or absolute +/// filesystem path. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LocalAudioPublicationIdentity { + /// Locally minted BandScope project id that owns the publication. + pub project_id: String, + /// Deterministic app-owned artifact name within that project. + pub artifact_name: String, + /// Canonical lowercase admitted audio extension. + pub extension: String, + /// Exact number of bytes in the verified publication. + pub file_size_bytes: u64, + /// Lowercase SHA-256 of the exact verified publication bytes. + pub content_sha256: String, +} + +fn is_lowercase_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +/// Build the durable path-free identity for verified local-audio publication evidence. +/// +/// Security Notes: callers must supply a project id minted under BandScope's +/// existing project-id grammar and the canonical lowercase extension that was +/// admitted by Resource Admission. The receipt must come from the verified +/// publication path, not renderer input. Invalid ids, extensions, sizes, or +/// digest encodings fail closed with the bounded project-workspace diagnosis. +pub fn build_local_audio_publication_identity( + project_id: &str, + extension: &str, + receipt: &LocalAudioCopyReceipt, +) -> Result { + if !is_valid_project_id(project_id) + || !AUDIO_EXTENSIONS.contains(&extension) + || extension.bytes().any(|byte| byte.is_ascii_uppercase()) + || receipt.file_size_bytes == 0 + || receipt.file_size_bytes > MAX_LOCAL_AUDIO_FILE_BYTES + || !is_lowercase_sha256(&receipt.content_sha256) + { + return Err(LOCAL_AUDIO_PUBLICATION_IDENTITY_ERROR.to_string()); + } + + Ok(LocalAudioPublicationIdentity { + project_id: project_id.to_string(), + artifact_name: format!("source.{extension}"), + extension: extension.to_string(), + file_size_bytes: receipt.file_size_bytes, + content_sha256: receipt.content_sha256.clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lowercase_sha256_requires_exact_canonical_encoding() { + assert!(is_lowercase_sha256( + "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a" + )); + assert!(!is_lowercase_sha256(&"a".repeat(63))); + assert!(!is_lowercase_sha256(&"a".repeat(65))); + assert!(!is_lowercase_sha256(&"A".repeat(64))); + assert!(!is_lowercase_sha256(&"g".repeat(64))); + } +} diff --git a/apps/desktop/core/src/root.rs b/apps/desktop/core/src/root.rs new file mode 100644 index 000000000..ce70d9b66 --- /dev/null +++ b/apps/desktop/core/src/root.rs @@ -0,0 +1,42 @@ +//! Pure, GUI-independent logic for the BandScope desktop application. +//! +//! The historical desktop-core implementation remains in `lib.rs` as the +//! compatibility module while bounded resource and persistence boundaries are +//! isolated in auditable modules. Public symbols are re-exported so downstream +//! callers keep one canonical crate-root API. + +#[path = "lib.rs"] +pub(crate) mod runtime_core; +// Project Persistence still imports `crate::core`; keep that name as a +// crate-private alias to the same compatibility module instead of restoring a +// second crate root or copying Resource Admission ownership. +pub(crate) use runtime_core as core; +mod audio_resource; +mod content_sha256; +mod project_format; +mod publication_identity; +mod score_pdf; +mod source_readmission; + +pub use audio_resource::{ + copy_bounded_local_audio, copy_bounded_local_audio_with_receipt, + validate_local_audio_file_size, verify_local_audio_publication_receipt, + LocalAudioCopyReceipt, MAX_LOCAL_AUDIO_FILE_BYTES, +}; +pub use content_sha256::sha256_hex_reader; +pub use project_format::{ + project_content_for_document, project_content_for_payload, project_document_from_content, + project_document_from_value, project_payload_from_content, + project_source_reference_from_publication_identity, ProjectDocumentPayload, + ProjectPreferencesPayload, ProjectSourceReferencePayload, SelectedPlaybackSourcePayload, + CURRENT_PROJECT_FORMAT_VERSION, +}; +pub use publication_identity::{ + build_local_audio_publication_identity, LocalAudioPublicationIdentity, +}; +pub use runtime_core::*; +pub use score_pdf::read_validated_score_pdf; +pub use source_readmission::{ + re_admit_local_audio_publication, re_admit_local_audio_publication_from_project_root, + ReAdmittedLocalAudioPublication, +}; diff --git a/apps/desktop/core/src/score_pdf.rs b/apps/desktop/core/src/score_pdf.rs new file mode 100644 index 000000000..2b26744cc --- /dev/null +++ b/apps/desktop/core/src/score_pdf.rs @@ -0,0 +1,82 @@ +use crate::{MAX_SCORE_PDF_BYTES, PDF_MAGIC}; +use std::{fs::File, io::Read, path::Path}; + +const SCORE_READ_ERROR: &str = "Could not read the score PDF."; +const SCORE_TOO_LARGE_ERROR: &str = "Score PDF is too large (exceeds 25MB limit)."; +const SCORE_INVALID_PDF_ERROR: &str = "Stored score is not a valid PDF."; + +fn read_validated_pdf_stream( + reader: &mut impl Read, + expected_len: u64, +) -> Result, String> { + if expected_len > MAX_SCORE_PDF_BYTES { + return Err(SCORE_TOO_LARGE_ERROR.to_string()); + } + + // MAX_SCORE_PDF_BYTES is 25 MiB, which fits every supported Rust `usize`. + let mut bytes = vec![0_u8; expected_len as usize]; + reader + .read_exact(&mut bytes) + .map_err(|_| SCORE_READ_ERROR.to_string())?; + + let mut growth_probe = [0_u8; 1]; + if reader + .read(&mut growth_probe) + .map_err(|_| SCORE_READ_ERROR.to_string())? + != 0 + { + return Err(SCORE_TOO_LARGE_ERROR.to_string()); + } + + if !bytes.starts_with(PDF_MAGIC) { + return Err(SCORE_INVALID_PDF_ERROR.to_string()); + } + + Ok(bytes) +} + +/// Read one already-authorized stored score without allocating beyond the PDF limit. +/// +/// The caller remains responsible for path authority and containment. This helper +/// opens that resolved path once, snapshots the descriptor length, allocates only +/// that bounded size, reads exactly that many bytes, and then probes one additional +/// byte on the same descriptor. A file that was already oversized is rejected +/// before heap allocation; a file that grows after metadata inspection is rejected +/// by the one-byte probe without extending the heap buffer beyond the product cap. +/// Errors intentionally omit the local path and file content. +pub fn read_validated_score_pdf(path: &Path) -> Result, String> { + let mut file = File::open(path).map_err(|_| SCORE_READ_ERROR.to_string())?; + let metadata = file + .metadata() + .map_err(|_| SCORE_READ_ERROR.to_string())?; + if !metadata.is_file() { + return Err(SCORE_READ_ERROR.to_string()); + } + read_validated_pdf_stream(&mut file, metadata.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn stream_rejects_growth_after_the_metadata_length_snapshot() { + let mut reader = Cursor::new(b"%PDF-extra".to_vec()); + + let error = read_validated_pdf_stream(&mut reader, PDF_MAGIC.len() as u64) + .expect_err("bytes beyond the metadata snapshot must fail closed"); + + assert_eq!(error, SCORE_TOO_LARGE_ERROR); + } + + #[test] + fn stream_rejects_truncation_after_the_metadata_length_snapshot() { + let mut reader = Cursor::new(PDF_MAGIC.to_vec()); + + let error = read_validated_pdf_stream(&mut reader, (PDF_MAGIC.len() + 1) as u64) + .expect_err("truncation after the metadata snapshot must fail closed"); + + assert_eq!(error, SCORE_READ_ERROR); + } +} diff --git a/apps/desktop/core/src/source_readmission.rs b/apps/desktop/core/src/source_readmission.rs new file mode 100644 index 000000000..7e796f0f7 --- /dev/null +++ b/apps/desktop/core/src/source_readmission.rs @@ -0,0 +1,112 @@ +use crate::{ + audio_resource::{verify_local_audio_publication_receipt, LocalAudioCopyReceipt}, + project_format::ProjectSourceReferencePayload, + publication_identity::{build_local_audio_publication_identity, LocalAudioPublicationIdentity}, +}; +use std::{ + ffi::OsStr, + io::Read, + path::{Path, PathBuf}, +}; + +const LOCAL_AUDIO_RE_ADMISSION_ERROR: &str = "Could not prepare the local project workspace."; + +/// Fresh runtime evidence recovered from one persisted app-owned audio publication. +/// +/// `source_path` is transient native authority only. It is derived from the +/// validated BandScope project root plus the fixed Resource Admission artifact +/// name and must never be serialized back into a `.bscope` document. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReAdmittedLocalAudioPublication { + /// Exact app-owned source path that the native opener authorized. + pub source_path: PathBuf, + /// Re-established path-free content identity for native state. + pub identity: LocalAudioPublicationIdentity, +} + +fn expected_publication_identity( + reference: &ProjectSourceReferencePayload, +) -> Result { + let expected_receipt = LocalAudioCopyReceipt { + file_size_bytes: reference.file_size_bytes, + content_sha256: reference.content_sha256.clone(), + }; + let expected_identity = build_local_audio_publication_identity( + &reference.project_id, + &reference.extension, + &expected_receipt, + ) + .map_err(|_| LOCAL_AUDIO_RE_ADMISSION_ERROR.to_string())?; + if expected_identity.artifact_name != reference.artifact_name { + return Err(LOCAL_AUDIO_RE_ADMISSION_ERROR.to_string()); + } + Ok(expected_identity) +} + +fn verify_re_admitted_publication( + identity: LocalAudioPublicationIdentity, + reader: R, +) -> Result { + let expected_receipt = LocalAudioCopyReceipt { + file_size_bytes: identity.file_size_bytes, + content_sha256: identity.content_sha256.clone(), + }; + verify_local_audio_publication_receipt(reader, &expected_receipt) + .map_err(|_| LOCAL_AUDIO_RE_ADMISSION_ERROR.to_string())?; + Ok(identity) +} + +/// Re-establish native content identity for a persisted app-owned full-mix artifact. +/// +/// Security Notes: `ProjectSourceReferencePayload` is durable evidence, not runtime +/// filesystem authority. This reverse ACL validates the reference through the +/// Resource Admission identity builder before reading, then hashes no more than +/// the persisted byte length plus the verifier's one-byte growth probe. Runtime +/// authority is returned only when the opened app-owned stream reproduces both +/// the exact byte count and SHA-256 digest. Paths and playback capabilities are +/// intentionally absent from this boundary; the native adapter remains +/// responsible for deriving and opening only `source.` below the +/// validated BandScope project root. +pub fn re_admit_local_audio_publication( + reference: &ProjectSourceReferencePayload, + reader: R, +) -> Result { + let expected_identity = expected_publication_identity(reference)?; + verify_re_admitted_publication(expected_identity, reader) +} + +/// Resolve and re-admit one persisted source through a native no-follow opener. +/// +/// Security Notes: durable evidence is validated before the opener is invoked, +/// so a forged `artifactName`, extension, digest, size, or project id cannot be +/// turned into a filesystem lookup. The supplied project root must end in the +/// same BandScope project id, and the path is derived from the validated fixed +/// `source.` artifact name rather than from untrusted path text. +/// `open_file` remains an injected native authority so platform code can enforce +/// O_NOFOLLOW/reparse-point and file-identity rules without duplicating those +/// primitives in this bounded-context ACL. Parent-directory descriptor binding +/// remains the native adapter's responsibility. +pub fn re_admit_local_audio_publication_from_project_root( + project_root: &Path, + reference: &ProjectSourceReferencePayload, + open_file: F, +) -> Result +where + R: Read, + F: FnOnce(&Path) -> std::io::Result, +{ + let expected_identity = expected_publication_identity(reference)?; + if project_root.file_name() != Some(OsStr::new(&expected_identity.project_id)) { + return Err(LOCAL_AUDIO_RE_ADMISSION_ERROR.to_string()); + } + + let source_path = project_root.join(&expected_identity.artifact_name); + let reader = open_file(&source_path) + .map_err(|_| LOCAL_AUDIO_RE_ADMISSION_ERROR.to_string())?; + let identity = verify_re_admitted_publication(expected_identity, reader)?; + + Ok(ReAdmittedLocalAudioPublication { + source_path, + identity, + }) +} diff --git a/apps/desktop/core/testdata/project-v1.json b/apps/desktop/core/testdata/project-v1.json new file mode 100644 index 000000000..fe2abd1fe --- /dev/null +++ b/apps/desktop/core/testdata/project-v1.json @@ -0,0 +1,67 @@ +{ + "projectFormatVersion": 1, + "song": { + "id": "fixture-song", + "title": "Fixture Rehearsal", + "tempo": 96, + "sections": [ + { + "id": "verse-1", + "label": "verse", + "groove": "Straight eighths", + "timeRange": { + "start": 0, + "end": 4 + }, + "confidence": { + "level": "medium", + "source": "model", + "notes": "Check the entrance." + }, + "roles": [ + { + "id": "bass-guitar", + "name": "Bass Guitar", + "roleType": "instrument", + "harmony": { + "chord": "C", + "functionLabel": "tonic", + "source": "model" + }, + "cue": { + "kind": "transition", + "value": "Enter on the downbeat." + }, + "range": { + "lowestNote": "C2", + "highestNote": "G3" + }, + "confidence": { + "level": "medium", + "source": "model", + "notes": "" + }, + "rehearsalPriority": "high", + "simplification": "Play roots.", + "setupNote": "Keep the attack short.", + "manualOverrides": [], + "overlapWarnings": [] + } + ], + "partGraph": [ + { + "role_id": "bass-guitar", + "is_active": true, + "handoff_to": [], + "handoff_from": [] + } + ] + } + ], + "exportSummary": { + "format": "cue-sheet", + "headline": "Start with the verse.", + "focusSections": ["verse-1"] + } + } +} diff --git a/apps/desktop/core/testdata/project-v2.json b/apps/desktop/core/testdata/project-v2.json new file mode 100644 index 000000000..0c572298e --- /dev/null +++ b/apps/desktop/core/testdata/project-v2.json @@ -0,0 +1,70 @@ +{ + "projectFormatVersion": 2, + "song": { + "id": "fixture-song", + "title": "Fixture Rehearsal", + "tempo": 96, + "sections": [ + { + "id": "verse-1", + "label": "verse", + "groove": "Straight eighths", + "timeRange": { + "start": 0, + "end": 4 + }, + "confidence": { + "level": "medium", + "source": "model", + "notes": "Check the entrance." + }, + "roles": [ + { + "id": "bass-guitar", + "name": "Bass Guitar", + "roleType": "instrument", + "harmony": { + "chord": "C", + "functionLabel": "tonic", + "source": "model" + }, + "cue": { + "kind": "transition", + "value": "Enter on the downbeat." + }, + "range": { + "lowestNote": "C2", + "highestNote": "G3" + }, + "confidence": { + "level": "medium", + "source": "model", + "notes": "" + }, + "rehearsalPriority": "high", + "simplification": "Play roots.", + "setupNote": "Keep the attack short.", + "manualOverrides": [], + "overlapWarnings": [] + } + ], + "partGraph": [ + { + "role_id": "bass-guitar", + "is_active": true, + "handoff_to": [], + "handoff_from": [] + } + ] + } + ], + "exportSummary": { + "format": "cue-sheet", + "headline": "Start with the verse.", + "focusSections": ["verse-1"] + } + }, + "preferences": { + "selectedPlaybackSource": "vocals" + } +} diff --git a/apps/desktop/core/tests/audio_resource_next_action.rs b/apps/desktop/core/tests/audio_resource_next_action.rs new file mode 100644 index 000000000..9fa77d800 --- /dev/null +++ b/apps/desktop/core/tests/audio_resource_next_action.rs @@ -0,0 +1,9 @@ +use bandscope_desktop_core::{validate_local_audio_file_size, MAX_LOCAL_AUDIO_FILE_BYTES}; + +#[test] +fn oversized_local_audio_names_the_next_rehearsal_action() { + assert_eq!( + validate_local_audio_file_size(MAX_LOCAL_AUDIO_FILE_BYTES + 1), + Err("Choose a shorter or smaller song file to start analysis.".to_string()) + ); +} diff --git a/apps/desktop/core/tests/audio_resource_policy.rs b/apps/desktop/core/tests/audio_resource_policy.rs new file mode 100644 index 000000000..163e49d50 --- /dev/null +++ b/apps/desktop/core/tests/audio_resource_policy.rs @@ -0,0 +1,25 @@ +use bandscope_desktop_core::{validate_local_audio_file_size, MAX_LOCAL_AUDIO_FILE_BYTES}; + +#[test] +fn local_audio_size_policy_accepts_the_exact_native_bootstrap_ceiling() { + assert_eq!( + validate_local_audio_file_size(MAX_LOCAL_AUDIO_FILE_BYTES), + Ok(MAX_LOCAL_AUDIO_FILE_BYTES) + ); +} + +#[test] +fn local_audio_size_policy_rejects_an_empty_native_bootstrap_source() { + assert_eq!( + validate_local_audio_file_size(0), + Err("Could not read the selected audio file.".to_string()) + ); +} + +#[test] +fn local_audio_size_policy_rejects_a_native_source_above_the_canonical_ceiling() { + assert_eq!( + validate_local_audio_file_size(MAX_LOCAL_AUDIO_FILE_BYTES + 1), + Err("Choose a shorter or smaller song file to start analysis.".to_string()) + ); +} diff --git a/apps/desktop/core/tests/content_sha256_shared_kernel.rs b/apps/desktop/core/tests/content_sha256_shared_kernel.rs new file mode 100644 index 000000000..b8d9a0ebd --- /dev/null +++ b/apps/desktop/core/tests/content_sha256_shared_kernel.rs @@ -0,0 +1,10 @@ +use bandscope_desktop_core::sha256_hex_reader; +use std::io::Cursor; + +#[test] +fn shared_sha256_reader_matches_the_fips_180_4_abc_vector() { + assert_eq!( + sha256_hex_reader(Cursor::new(b"abc")).as_deref(), + Ok("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + ); +} diff --git a/apps/desktop/core/tests/local_audio_content_identity.rs b/apps/desktop/core/tests/local_audio_content_identity.rs new file mode 100644 index 000000000..b43eb1bc5 --- /dev/null +++ b/apps/desktop/core/tests/local_audio_content_identity.rs @@ -0,0 +1,43 @@ +use bandscope_desktop_core::{ + copy_bounded_local_audio_with_receipt, verify_local_audio_publication_receipt, +}; +use std::io::Cursor; + +#[test] +fn local_audio_copy_receipt_hashes_exact_admitted_bytes() { + let input = vec![1_u8, 2, 3, 4]; + let mut staged = Vec::new(); + + let receipt = copy_bounded_local_audio_with_receipt(Cursor::new(&input), &mut staged) + .expect("bounded admission should return content identity for the bytes it stages"); + + assert_eq!(staged, input); + assert_eq!(receipt.file_size_bytes, 4); + assert_eq!( + receipt.content_sha256, + "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a" + ); +} + +#[test] +fn publication_receipt_requires_the_published_bytes_to_match_the_stage() { + let input = vec![1_u8, 2, 3, 4]; + let mut staged = Vec::new(); + let staged_receipt = copy_bounded_local_audio_with_receipt(Cursor::new(&input), &mut staged) + .expect("staging should produce native identity evidence"); + + let published_receipt = verify_local_audio_publication_receipt( + Cursor::new(&staged), + &staged_receipt, + ) + .expect("unchanged published bytes should retain the staging identity"); + + assert_eq!(published_receipt, staged_receipt); + + let mismatch = verify_local_audio_publication_receipt( + Cursor::new(vec![1_u8, 2, 3, 5]), + &staged_receipt, + ) + .expect_err("same-size mutation after staging must fail publication binding"); + assert_eq!(mismatch, "Could not prepare the local project workspace."); +} diff --git a/apps/desktop/core/tests/local_audio_publication_identity.rs b/apps/desktop/core/tests/local_audio_publication_identity.rs new file mode 100644 index 000000000..cd3b5fb63 --- /dev/null +++ b/apps/desktop/core/tests/local_audio_publication_identity.rs @@ -0,0 +1,77 @@ +use bandscope_desktop_core::{ + build_local_audio_publication_identity, LocalAudioCopyReceipt, +}; + +fn receipt() -> LocalAudioCopyReceipt { + LocalAudioCopyReceipt { + file_size_bytes: 4, + content_sha256: + "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a".to_string(), + } +} + +#[test] +fn publication_identity_is_path_free_and_deterministic() { + let identity = build_local_audio_publication_identity("project-1-1", "wav", &receipt()) + .expect("verified publication evidence should become a durable path-free identity"); + + assert_eq!(identity.project_id, "project-1-1"); + assert_eq!(identity.artifact_name, "source.wav"); + assert_eq!(identity.extension, "wav"); + assert_eq!(identity.file_size_bytes, 4); + assert_eq!( + identity.content_sha256, + "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a" + ); + + let json = serde_json::to_value(&identity).expect("publication identity should serialize"); + assert_eq!( + json, + serde_json::json!({ + "projectId": "project-1-1", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 4, + "contentSha256": "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a" + }) + ); + assert!(json.get("sourcePath").is_none()); + assert!(json.get("path").is_none()); +} + +#[test] +fn publication_identity_rejects_noncanonical_or_fabricated_evidence() { + for (project_id, extension, receipt) in [ + ("../project-1-1", "wav", receipt()), + ("project-1-1", "WAV", receipt()), + ("project-1-1", "exe", receipt()), + ( + "project-1-1", + "wav", + LocalAudioCopyReceipt { + file_size_bytes: 0, + content_sha256: "00".repeat(32), + }, + ), + ( + "project-1-1", + "wav", + LocalAudioCopyReceipt { + file_size_bytes: 4, + content_sha256: "AA".repeat(32), + }, + ), + ( + "project-1-1", + "wav", + LocalAudioCopyReceipt { + file_size_bytes: 4, + content_sha256: "not-a-sha256".to_string(), + }, + ), + ] { + let error = build_local_audio_publication_identity(project_id, extension, &receipt) + .expect_err("only canonical native publication evidence may cross persistence handoff"); + assert_eq!(error, "Could not prepare the local project workspace."); + } +} diff --git a/apps/desktop/core/tests/local_audio_restart_readmission.rs b/apps/desktop/core/tests/local_audio_restart_readmission.rs new file mode 100644 index 000000000..fb1e76e77 --- /dev/null +++ b/apps/desktop/core/tests/local_audio_restart_readmission.rs @@ -0,0 +1,146 @@ +use bandscope_desktop_core::{ + re_admit_local_audio_publication, re_admit_local_audio_publication_from_project_root, + ProjectSourceReferencePayload, +}; +use std::{ + io::{Cursor, Error, ErrorKind, Read, Result as IoResult}, + path::Path, +}; + +const WAV_BYTES: &[u8] = &[ + 0x52, 0x49, 0x46, 0x46, 0x2c, 0x00, 0x00, 0x00, 0x57, 0x41, 0x56, 0x45, 0x66, 0x6d, + 0x74, 0x20, 0x10, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x40, 0x1f, 0x00, 0x00, + 0x40, 0x1f, 0x00, 0x00, 0x01, 0x00, 0x08, 0x00, 0x64, 0x61, 0x74, 0x61, 0x08, 0x00, + 0x00, 0x00, 0x80, 0xa0, 0xc0, 0xe0, 0xff, 0xe0, 0xc0, 0xa0, +]; +const WAV_SHA256: &str = + "6edea6da3400897a1eae8dede07c13843cffd02a91dc3599cd1f542a9a888be5"; + +struct RejectRead; + +impl Read for RejectRead { + fn read(&mut self, _buf: &mut [u8]) -> IoResult { + panic!("malformed durable evidence must be rejected before reading the artifact"); + } +} + +struct FailingReader; + +impl Read for FailingReader { + fn read(&mut self, _buf: &mut [u8]) -> IoResult { + Err(Error::new(ErrorKind::PermissionDenied, "private OS detail")) + } +} + +fn source_reference() -> ProjectSourceReferencePayload { + ProjectSourceReferencePayload { + project_id: "project-600-6".to_string(), + artifact_name: "source.wav".to_string(), + extension: "wav".to_string(), + file_size_bytes: WAV_BYTES.len() as u64, + content_sha256: WAV_SHA256.to_string(), + } +} + +#[test] +fn restart_re_admission_accepts_only_the_exact_persisted_audio_bytes() { + let identity = re_admit_local_audio_publication(&source_reference(), Cursor::new(WAV_BYTES)) + .expect("the exact persisted app-owned WAV should regain native identity"); + + assert_eq!(identity.project_id, "project-600-6"); + assert_eq!(identity.artifact_name, "source.wav"); + assert_eq!(identity.extension, "wav"); + assert_eq!(identity.file_size_bytes, WAV_BYTES.len() as u64); + assert_eq!(identity.content_sha256, WAV_SHA256); +} + +#[test] +fn restart_re_admission_rejects_same_size_audio_mutation() { + let mut mutated = WAV_BYTES.to_vec(); + let last = mutated.len() - 1; + mutated[last] ^= 0x01; + + let error = re_admit_local_audio_publication(&source_reference(), Cursor::new(mutated)) + .expect_err("same-size audio replacement must not regain runtime authority"); + + assert_eq!(error, "Could not prepare the local project workspace."); +} + +#[test] +fn restart_re_admission_rejects_growth_and_truncation() { + let mut grown = WAV_BYTES.to_vec(); + grown.push(0x00); + let truncated = &WAV_BYTES[..WAV_BYTES.len() - 1]; + + for bytes in [grown.as_slice(), truncated] { + let error = re_admit_local_audio_publication(&source_reference(), Cursor::new(bytes)) + .expect_err("changed byte length must not regain runtime authority"); + assert_eq!(error, "Could not prepare the local project workspace."); + } +} + +#[test] +fn restart_re_admission_revalidates_fixed_app_owned_artifact_identity_before_reading() { + let mut forged = source_reference(); + forged.artifact_name = "../source.wav".to_string(); + + let error = re_admit_local_audio_publication(&forged, RejectRead) + .expect_err("typed but forged artifact identity must fail the reverse ACL"); + + assert_eq!(error, "Could not prepare the local project workspace."); +} + +#[test] +fn restart_re_admission_rejects_malformed_durable_identity_before_reading() { + let mut invalid_project = source_reference(); + invalid_project.project_id = "../project-600-6".to_string(); + let mut invalid_extension = source_reference(); + invalid_extension.extension = "WAV".to_string(); + invalid_extension.artifact_name = "source.WAV".to_string(); + + for malformed in [invalid_project, invalid_extension] { + let error = re_admit_local_audio_publication(&malformed, RejectRead) + .expect_err("malformed persisted identity must fail before becoming runtime authority"); + assert_eq!(error, "Could not prepare the local project workspace."); + } +} + +#[test] +fn restart_re_admission_does_not_expose_reader_failures() { + let error = re_admit_local_audio_publication(&source_reference(), FailingReader) + .expect_err("a failed app-owned read must not regain runtime authority"); + + assert_eq!(error, "Could not prepare the local project workspace."); + assert!(!error.contains("private OS detail")); +} + +#[test] +fn project_root_adapter_derives_only_the_validated_fixed_artifact_path() { + let root = Path::new("/trusted/app/project-600-6"); + let reopened = re_admit_local_audio_publication_from_project_root( + root, + &source_reference(), + |path| { + assert_eq!(path, root.join("source.wav")); + Ok(Cursor::new(WAV_BYTES)) + }, + ) + .expect("validated durable evidence should derive one fixed app-owned artifact path"); + + assert_eq!(reopened.source_path, root.join("source.wav")); + assert_eq!(reopened.identity.project_id, "project-600-6"); +} + +#[test] +fn project_root_adapter_rejects_cross_project_binding_before_opening() { + let error = re_admit_local_audio_publication_from_project_root( + Path::new("/trusted/app/project-700-7"), + &source_reference(), + |_path| -> IoResult> { + panic!("a mismatched project root must fail before filesystem authority is requested") + }, + ) + .expect_err("persisted evidence must remain bound to its exact project aggregate"); + + assert_eq!(error, "Could not prepare the local project workspace."); +} diff --git a/apps/desktop/core/tests/project_format_resource_admission_handoff.rs b/apps/desktop/core/tests/project_format_resource_admission_handoff.rs new file mode 100644 index 000000000..82d90eff5 --- /dev/null +++ b/apps/desktop/core/tests/project_format_resource_admission_handoff.rs @@ -0,0 +1,58 @@ +use bandscope_desktop_core::{ + build_local_audio_publication_identity, project_source_reference_from_publication_identity, + LocalAudioCopyReceipt, LocalAudioPublicationIdentity, +}; + +const CONTENT_SHA256: &str = + "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a"; + +fn verified_identity() -> LocalAudioPublicationIdentity { + build_local_audio_publication_identity( + "project-400-4", + "flac", + &LocalAudioCopyReceipt { + file_size_bytes: 8192, + content_sha256: CONTENT_SHA256.to_string(), + }, + ) + .expect("Resource Admission fixture should be valid") +} + +#[test] +fn projects_verified_native_publication_identity_into_path_free_persistence_evidence() { + let identity = verified_identity(); + let reference = project_source_reference_from_publication_identity(&identity) + .expect("verified native identity should cross the persistence ACL"); + + assert_eq!(reference.project_id, "project-400-4"); + assert_eq!(reference.artifact_name, "source.flac"); + assert_eq!(reference.extension, "flac"); + assert_eq!(reference.file_size_bytes, 8192); + assert_eq!(reference.content_sha256, CONTENT_SHA256); + + let serialized = serde_json::to_value(reference).expect("source reference should serialize"); + let keys = serialized + .as_object() + .expect("source reference should serialize as an object") + .keys() + .map(String::as_str) + .collect::>(); + assert_eq!( + keys, + std::collections::BTreeSet::from([ + "artifactName", + "contentSha256", + "extension", + "fileSizeBytes", + "projectId", + ]) + ); +} + +#[test] +fn rejects_forged_identity_at_the_resource_admission_to_persistence_acl() { + let mut forged = verified_identity(); + forged.artifact_name = "../source.flac".to_string(); + + assert!(project_source_reference_from_publication_identity(&forged).is_err()); +} diff --git a/apps/desktop/core/tests/project_format_v2_fixture.rs b/apps/desktop/core/tests/project_format_v2_fixture.rs new file mode 100644 index 000000000..ed74710e1 --- /dev/null +++ b/apps/desktop/core/tests/project_format_v2_fixture.rs @@ -0,0 +1,29 @@ +use bandscope_desktop_core::{ + project_content_for_document, project_document_from_content, SelectedPlaybackSourcePayload, + CURRENT_PROJECT_FORMAT_VERSION, +}; +use serde_json::{json, Value}; + +#[test] +fn golden_v2_fixture_preserves_the_selected_playback_source() { + let document = project_document_from_content(include_str!("../testdata/project-v2.json")) + .expect("the checked-in v2 fixture should load"); + + assert_eq!( + document.preferences.selected_playback_source, + SelectedPlaybackSourcePayload::Vocals + ); + + let serialized = project_content_for_document(&document) + .expect("the checked-in v2 fixture should serialize"); + let value: Value = serde_json::from_str(&serialized) + .expect("the serialized v2 fixture should remain valid JSON"); + assert_eq!( + value["projectFormatVersion"], + json!(CURRENT_PROJECT_FORMAT_VERSION) + ); + assert_eq!( + value["preferences"]["selectedPlaybackSource"], + json!("vocals") + ); +} diff --git a/apps/desktop/core/tests/project_format_v2_playback_preference.rs b/apps/desktop/core/tests/project_format_v2_playback_preference.rs new file mode 100644 index 000000000..54212ff6c --- /dev/null +++ b/apps/desktop/core/tests/project_format_v2_playback_preference.rs @@ -0,0 +1,195 @@ +use bandscope_desktop_core::{ + project_content_for_document, project_document_from_content, project_document_from_value, + project_payload_from_content, ProjectDocumentPayload, ProjectPreferencesPayload, + SelectedPlaybackSourcePayload, CURRENT_PROJECT_FORMAT_VERSION, +}; +use serde_json::{json, Value}; + +fn v1_fixture() -> &'static str { + include_str!("../testdata/project-v1.json") +} + +#[test] +fn v1_migrates_to_current_with_full_mix_as_the_explicit_default() { + let document = project_document_from_content(v1_fixture()) + .expect("the supported v1 fixture should migrate to the current project document"); + let serialized = project_content_for_document(&document) + .expect("the migrated project document should serialize"); + let value: Value = serde_json::from_str(&serialized) + .expect("the current project document should remain valid JSON"); + + assert_eq!( + value["projectFormatVersion"], + json!(CURRENT_PROJECT_FORMAT_VERSION) + ); + assert_eq!( + value["preferences"]["selectedPlaybackSource"], + json!("full_mix") + ); + assert!(value.get("sourceReference").is_none()); +} + +#[test] +fn v2_preserves_each_stable_playback_source_semantic_when_migrated_to_current() { + let v1: Value = serde_json::from_str(v1_fixture()).expect("v1 fixture should parse"); + let song = v1["song"].clone(); + + for selected_source in ["full_mix", "vocals", "bass", "drums", "other"] { + let content = json!({ + "projectFormatVersion": 2, + "song": song.clone(), + "preferences": { + "selectedPlaybackSource": selected_source + } + }) + .to_string(); + + let document = project_document_from_content(&content) + .expect("every stable playback-source semantic should load"); + let round_trip = project_content_for_document(&document) + .expect("a valid v2 document should serialize as the current version"); + let round_trip_value: Value = serde_json::from_str(&round_trip) + .expect("the serialized current document should remain valid JSON"); + assert_eq!( + round_trip_value["projectFormatVersion"], + json!(CURRENT_PROJECT_FORMAT_VERSION) + ); + assert_eq!( + round_trip_value["preferences"]["selectedPlaybackSource"], + json!(selected_source) + ); + assert!(round_trip_value.get("sourceReference").is_none()); + } +} + +#[test] +fn v2_rejects_unknown_or_revocable_playback_authorities() { + let v1: Value = serde_json::from_str(v1_fixture()).expect("v1 fixture should parse"); + let song = v1["song"].clone(); + + for invalid_source in [ + "karaoke", + "bandscope-playback://project-400-4/vocals?generation=7", + ] { + let content = json!({ + "projectFormatVersion": 2, + "song": song.clone(), + "preferences": { + "selectedPlaybackSource": invalid_source + } + }) + .to_string(); + + assert!( + project_document_from_content(&content).is_err(), + "invalid or revocable source {invalid_source} must fail closed" + ); + } +} + +#[test] +fn legacy_song_compatibility_also_migrates_to_full_mix() { + let v1: Value = serde_json::from_str(v1_fixture()).expect("v1 fixture should parse"); + let legacy_song = v1["song"].to_string(); + + let document = project_document_from_content(&legacy_song) + .expect("legacy raw RehearsalSong JSON should remain a supported compatibility input"); + let serialized = project_content_for_document(&document) + .expect("legacy input should serialize to the current version"); + let value: Value = serde_json::from_str(&serialized) + .expect("the migrated project should remain valid JSON"); + + assert_eq!( + value["projectFormatVersion"], + json!(CURRENT_PROJECT_FORMAT_VERSION) + ); + assert_eq!( + value["preferences"]["selectedPlaybackSource"], + json!("full_mix") + ); + assert!(value.get("sourceReference").is_none()); + + // Existing callers that consume only the song view must remain source-compatible. + assert!(project_payload_from_content(&legacy_song).is_ok()); +} + +#[test] +fn document_constructor_does_not_require_a_revocable_runtime_authority() { + let song = project_payload_from_content(v1_fixture()).expect("v1 fixture should load"); + let document = ProjectDocumentPayload { + song, + preferences: ProjectPreferencesPayload { + selected_playback_source: SelectedPlaybackSourcePayload::Drums, + }, + source_reference: None, + }; + + let serialized = project_content_for_document(&document) + .expect("typed project preferences should serialize without a playback URL"); + let value: Value = serde_json::from_str(&serialized).expect("current project JSON should parse"); + assert_eq!( + value["projectFormatVersion"], + json!(CURRENT_PROJECT_FORMAT_VERSION) + ); + assert_eq!( + value["preferences"]["selectedPlaybackSource"], + json!("drums") + ); + assert!(value.get("sourceReference").is_none()); + assert!(!serialized.contains("bandscope-playback://")); +} + +#[test] +fn ipc_document_payload_accepts_only_stable_project_preferences() { + let v1: Value = serde_json::from_str(v1_fixture()).expect("v1 fixture should parse"); + let song = v1["song"].clone(); + + for selected_source in ["full_mix", "vocals", "bass", "drums", "other"] { + let document = project_document_from_value(json!({ + "song": song.clone(), + "preferences": { + "selectedPlaybackSource": selected_source + } + })) + .expect("the IPC document boundary should accept every stable source semantic"); + + let serialized = project_content_for_document(&document) + .expect("an admitted IPC document should serialize to the current durable envelope"); + let value: Value = serde_json::from_str(&serialized).expect("current JSON should parse"); + assert_eq!( + value["projectFormatVersion"], + json!(CURRENT_PROJECT_FORMAT_VERSION) + ); + assert_eq!( + value["preferences"]["selectedPlaybackSource"], + json!(selected_source) + ); + } + + for invalid_document in [ + json!({ + "song": song.clone(), + "preferences": { + "selectedPlaybackSource": "bandscope-playback://project-400-4/vocals?generation=7" + } + }), + json!({ + "song": song.clone(), + "preferences": { + "selectedPlaybackSource": "karaoke" + } + }), + json!({ + "song": song, + "preferences": { + "selectedPlaybackSource": "vocals" + }, + "runtimeAuthority": "bandscope-playback://project-400-4/vocals?generation=7" + }), + ] { + assert!( + project_document_from_value(invalid_document).is_err(), + "unknown or revocable IPC state must fail closed before project publication" + ); + } +} diff --git a/apps/desktop/core/tests/project_format_v3_renderer_source_authority.rs b/apps/desktop/core/tests/project_format_v3_renderer_source_authority.rs new file mode 100644 index 000000000..b77a9cfe3 --- /dev/null +++ b/apps/desktop/core/tests/project_format_v3_renderer_source_authority.rs @@ -0,0 +1,31 @@ +use bandscope_desktop_core::project_document_from_value; +use serde_json::{json, Value}; + +const CONTENT_SHA256: &str = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn v2_song() -> Value { + let fixture: Value = serde_json::from_str(include_str!("../testdata/project-v2.json")) + .expect("the checked-in v2 fixture should remain valid JSON"); + fixture["song"].clone() +} + +#[test] +fn renderer_cannot_author_source_reference_before_native_handoff() { + let payload = json!({ + "song": v2_song(), + "preferences": { "selectedPlaybackSource": "full_mix" }, + "sourceReference": { + "projectId": "project-400-4", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 4096, + "contentSha256": CONTENT_SHA256 + } + }); + + let error = project_document_from_value(payload) + .expect_err("renderer JSON must not author native filesystem identity or digest evidence"); + + assert_eq!(error, "Invalid project document payload"); +} diff --git a/apps/desktop/core/tests/project_format_v3_source_reference.rs b/apps/desktop/core/tests/project_format_v3_source_reference.rs new file mode 100644 index 000000000..0fad0a6d4 --- /dev/null +++ b/apps/desktop/core/tests/project_format_v3_source_reference.rs @@ -0,0 +1,164 @@ +use bandscope_desktop_core::{ + project_content_for_document, project_document_from_content, ProjectSourceReferencePayload, + CURRENT_PROJECT_FORMAT_VERSION, +}; +use serde_json::{json, Value}; + +const CONTENT_SHA256: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn v2_song() -> Value { + let fixture: Value = serde_json::from_str(include_str!("../testdata/project-v2.json")) + .expect("the checked-in v2 fixture should remain valid JSON"); + fixture["song"].clone() +} + +#[test] +fn current_project_round_trips_an_app_owned_source_reference_without_a_filesystem_path() { + let content = json!({ + "projectFormatVersion": 3, + "song": v2_song(), + "preferences": { "selectedPlaybackSource": "drums" }, + "sourceReference": { + "projectId": "project-400-4", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 4096, + "contentSha256": CONTENT_SHA256 + } + }) + .to_string(); + + let document = project_document_from_content(&content) + .expect("the current project should admit one app-owned source reference"); + assert_eq!( + document.source_reference, + Some(ProjectSourceReferencePayload { + project_id: "project-400-4".to_string(), + artifact_name: "source.wav".to_string(), + extension: "wav".to_string(), + file_size_bytes: 4096, + content_sha256: CONTENT_SHA256.to_string(), + }) + ); + + let serialized = project_content_for_document(&document) + .expect("the admitted current project should serialize"); + let value: Value = serde_json::from_str(&serialized) + .expect("the serialized current project should remain valid JSON"); + assert_eq!(value["projectFormatVersion"], json!(CURRENT_PROJECT_FORMAT_VERSION)); + assert_eq!(value["sourceReference"]["projectId"], json!("project-400-4")); + assert_eq!(value["sourceReference"]["artifactName"], json!("source.wav")); + assert_eq!(value["sourceReference"]["contentSha256"], json!(CONTENT_SHA256)); + assert!(serialized.find("sourcePath").is_none()); + assert!(serialized.find("bandscope-playback://").is_none()); +} + +#[test] +fn v2_migrates_without_inventing_a_source_reference() { + let document = project_document_from_content(include_str!("../testdata/project-v2.json")) + .expect("v2 should migrate into the current document"); + assert_eq!(document.source_reference, None); + + let serialized = project_content_for_document(&document) + .expect("migrated v2 should serialize as the current format"); + let value: Value = serde_json::from_str(&serialized) + .expect("the migrated project should remain valid JSON"); + assert_eq!(value["projectFormatVersion"], json!(CURRENT_PROJECT_FORMAT_VERSION)); + assert!(value.get("sourceReference").is_none()); +} + +#[test] +fn current_project_rejects_paths_and_untrusted_source_reference_shapes() { + for source_reference in [ + json!({ + "projectId": "../escape", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 4096, + "contentSha256": CONTENT_SHA256 + }), + json!({ + "projectId": "project-400-4", + "artifactName": "../source.wav", + "extension": "wav", + "fileSizeBytes": 4096, + "contentSha256": CONTENT_SHA256 + }), + json!({ + "projectId": "project-400-4", + "artifactName": "source.mp3", + "extension": "wav", + "fileSizeBytes": 4096, + "contentSha256": CONTENT_SHA256 + }), + json!({ + "projectId": "project-400-4", + "artifactName": "source.wav", + "extension": "exe", + "fileSizeBytes": 4096, + "contentSha256": CONTENT_SHA256 + }), + json!({ + "projectId": "project-400-4", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 0, + "contentSha256": CONTENT_SHA256 + }), + json!({ + "projectId": "project-400-4", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 100 * 1024 * 1024 + 1, + "contentSha256": CONTENT_SHA256 + }), + json!({ + "projectId": "project-400-4", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 4096, + "contentSha256": CONTENT_SHA256, + "sourcePath": "/Users/example/Music/private.wav" + }), + json!({ + "projectId": "project-400-4", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 4096 + }), + json!({ + "projectId": "project-400-4", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 4096, + "contentSha256": "0123456789abcdef" + }), + json!({ + "projectId": "project-400-4", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 4096, + "contentSha256": "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF" + }), + json!({ + "projectId": "project-400-4", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 4096, + "contentSha256": "g123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + }), + ] { + let content = json!({ + "projectFormatVersion": 3, + "song": v2_song(), + "preferences": { "selectedPlaybackSource": "full_mix" }, + "sourceReference": source_reference + }) + .to_string(); + + assert!( + project_document_from_content(&content).is_err(), + "unsafe or ambiguous source reference must fail closed" + ); + } +} diff --git a/apps/desktop/core/tests/project_persistence_contract.rs b/apps/desktop/core/tests/project_persistence_contract.rs new file mode 100644 index 000000000..11db3b2a0 --- /dev/null +++ b/apps/desktop/core/tests/project_persistence_contract.rs @@ -0,0 +1,296 @@ +use bandscope_desktop_core::project_payload_from_content; +use serde_json::{json, Value}; + +fn current_rehearsal_song() -> Value { + json!({ + "id": "demo-song", + "title": "Late Night Set", + "tempo": 120, + "sections": [ + { + "id": "verse-1", + "label": "verse", + "groove": "Straight eighths with a late snare feel", + "timeRange": { "start": 10, "end": 30 }, + "confidence": { + "level": "medium", + "source": "model", + "notes": "Double-check the pickup into the chorus." + }, + "roles": [ + { + "id": "bass-guitar", + "name": "Bass Guitar", + "roleType": "instrument", + "harmony": { + "chord": "C#m7", + "functionLabel": "vi pedal anchor", + "source": "model" + }, + "harmonicExplanation": "The bass holds the tonal floor through the pickup.", + "cue": { + "kind": "transition", + "value": "Hold through the pickup before the downbeat." + }, + "range": { "lowestNote": "C#2", "highestNote": "E3" }, + "confidence": { + "level": "medium", + "source": "model", + "notes": "Watch the slide into the turnaround." + }, + "rehearsalPriority": "high", + "simplification": "Stay on roots if the chorus entrance gets muddy.", + "setupNote": "Keep the attack short so the verse breathes.", + "transpositionPlan": "Move the shape down a whole step if the singer changes key.", + "manualOverrides": [ + { + "field": "harmony", + "value": { + "chord": "C#m7", + "functionLabel": "vi pedal anchor", + "source": "user" + }, + "source": "user" + } + ], + "overlapWarnings": [], + "transcription": [ + { "pitch": "C#2", "onset": 10.0, "offset": 10.5, "velocity": 0.8 } + ], + "practiceProgress": 45 + } + ], + "partGraph": [ + { + "role_id": "bass-guitar", + "is_active": true, + "handoff_to": [], + "handoff_from": [] + } + ] + } + ], + "exportSummary": { + "format": "cue-sheet", + "headline": "Start with the verse handoff and low-register overlap.", + "focusSections": ["verse-1"] + }, + "collaboration": { + "syncMode": "local_only", + "syncNote": "Keep rehearsal coordination on this device.", + "assignments": [ + { + "id": "assign-bass", + "assignee": "Rhythm Section", + "summary": "Lock the pickup.", + "sectionId": "verse-1", + "roleId": "bass-guitar", + "status": "in_progress" + } + ], + "comments": [ + { + "id": "comment-bass", + "author": "MD", + "body": "Keep the attack short.", + "sectionId": "verse-1", + "roleId": "bass-guitar", + "status": "open" + } + ], + "approvals": [ + { + "id": "approval-bass", + "scope": "Verse rhythm pass", + "owner": "MD", + "status": "pending" + } + ] + } + }) +} + +#[test] +fn project_persistence_round_trips_current_shared_song_fields() { + let content = serde_json::to_string(¤t_rehearsal_song()) + .expect("current rehearsal song should serialize"); + + let parsed = project_payload_from_content(&content) + .expect("native project persistence must accept the current shared rehearsal song contract"); + let round_trip = serde_json::to_value(parsed) + .expect("native project payload should serialize back to renderer JSON"); + + assert_eq!(round_trip["tempo"], json!(120.0)); + assert_eq!(round_trip["sections"][0]["roles"][0]["harmonicExplanation"], json!("The bass holds the tonal floor through the pickup.")); + assert_eq!(round_trip["sections"][0]["roles"][0]["transpositionPlan"], json!("Move the shape down a whole step if the singer changes key.")); + assert_eq!(round_trip["sections"][0]["roles"][0]["transcription"][0]["pitch"], json!("C#2")); + assert_eq!(round_trip["sections"][0]["roles"][0]["practiceProgress"], json!(45)); + assert_eq!(round_trip["sections"][0]["roles"][0]["manualOverrides"][0]["source"], json!("user")); + assert_eq!(round_trip["collaboration"]["assignments"][0]["roleId"], json!("bass-guitar")); +} + +#[test] +fn project_persistence_rejects_invalid_shared_collaboration_states_and_progress() { + let mut invalid_sync_mode = current_rehearsal_song(); + invalid_sync_mode["collaboration"]["syncMode"] = json!("cloud_now"); + assert!(project_payload_from_content(&invalid_sync_mode.to_string()).is_err()); + + let mut invalid_assignment_status = current_rehearsal_song(); + invalid_assignment_status["collaboration"]["assignments"][0]["status"] = json!("done"); + assert!(project_payload_from_content(&invalid_assignment_status.to_string()).is_err()); + + let mut invalid_comment_status = current_rehearsal_song(); + invalid_comment_status["collaboration"]["comments"][0]["status"] = json!("archived"); + assert!(project_payload_from_content(&invalid_comment_status.to_string()).is_err()); + + let mut invalid_approval_status = current_rehearsal_song(); + invalid_approval_status["collaboration"]["approvals"][0]["status"] = json!("rejected"); + assert!(project_payload_from_content(&invalid_approval_status.to_string()).is_err()); + + let mut invalid_practice_progress = current_rehearsal_song(); + invalid_practice_progress["sections"][0]["roles"][0]["practiceProgress"] = json!(101); + assert!(project_payload_from_content(&invalid_practice_progress.to_string()).is_err()); +} + +#[test] +fn project_persistence_accepts_all_shared_closed_domain_tokens() { + for label in [ + "intro", + "verse", + "pre-chorus", + "chorus", + "bridge", + "outro", + "tag", + "pickup", + "stop", + "handoff", + ] { + let mut song = current_rehearsal_song(); + song["sections"][0]["label"] = json!(label); + assert!( + project_payload_from_content(&song.to_string()).is_ok(), + "shared section label {label} should remain loadable" + ); + } + + for level in ["low", "medium", "high"] { + let mut section_song = current_rehearsal_song(); + section_song["sections"][0]["confidence"]["level"] = json!(level); + assert!(project_payload_from_content(§ion_song.to_string()).is_ok()); + + let mut role_song = current_rehearsal_song(); + role_song["sections"][0]["roles"][0]["confidence"]["level"] = json!(level); + assert!(project_payload_from_content(&role_song.to_string()).is_ok()); + } + + for source in ["model", "user"] { + let mut confidence_song = current_rehearsal_song(); + confidence_song["sections"][0]["confidence"]["source"] = json!(source); + assert!(project_payload_from_content(&confidence_song.to_string()).is_ok()); + + let mut harmony_song = current_rehearsal_song(); + harmony_song["sections"][0]["roles"][0]["harmony"]["source"] = json!(source); + assert!(project_payload_from_content(&harmony_song.to_string()).is_ok()); + } + + for role_type in ["instrument", "vocal", "hand"] { + let mut song = current_rehearsal_song(); + song["sections"][0]["roles"][0]["roleType"] = json!(role_type); + assert!(project_payload_from_content(&song.to_string()).is_ok()); + } + + for cue_kind in ["lyric", "count", "transition"] { + let mut song = current_rehearsal_song(); + song["sections"][0]["roles"][0]["cue"]["kind"] = json!(cue_kind); + assert!(project_payload_from_content(&song.to_string()).is_ok()); + } + + for priority in ["low", "medium", "high"] { + let mut song = current_rehearsal_song(); + song["sections"][0]["roles"][0]["rehearsalPriority"] = json!(priority); + assert!(project_payload_from_content(&song.to_string()).is_ok()); + } + + for format in ["cue-sheet", "chart-summary"] { + let mut song = current_rehearsal_song(); + song["exportSummary"]["format"] = json!(format); + assert!(project_payload_from_content(&song.to_string()).is_ok()); + } +} + +#[test] +fn project_persistence_rejects_invalid_shared_closed_domains() { + let mut invalid_section_label = current_rehearsal_song(); + invalid_section_label["sections"][0]["label"] = json!("solo"); + assert!(project_payload_from_content(&invalid_section_label.to_string()).is_err()); + + let mut invalid_section_confidence_level = current_rehearsal_song(); + invalid_section_confidence_level["sections"][0]["confidence"]["level"] = json!("certain"); + assert!(project_payload_from_content(&invalid_section_confidence_level.to_string()).is_err()); + + let mut invalid_section_confidence_source = current_rehearsal_song(); + invalid_section_confidence_source["sections"][0]["confidence"]["source"] = json!("imported"); + assert!(project_payload_from_content(&invalid_section_confidence_source.to_string()).is_err()); + + let mut invalid_role_type = current_rehearsal_song(); + invalid_role_type["sections"][0]["roles"][0]["roleType"] = json!("guitar"); + assert!(project_payload_from_content(&invalid_role_type.to_string()).is_err()); + + let mut invalid_harmony_source = current_rehearsal_song(); + invalid_harmony_source["sections"][0]["roles"][0]["harmony"]["source"] = json!("imported"); + assert!(project_payload_from_content(&invalid_harmony_source.to_string()).is_err()); + + let mut invalid_cue_kind = current_rehearsal_song(); + invalid_cue_kind["sections"][0]["roles"][0]["cue"]["kind"] = json!("bar"); + assert!(project_payload_from_content(&invalid_cue_kind.to_string()).is_err()); + + let mut invalid_role_confidence_level = current_rehearsal_song(); + invalid_role_confidence_level["sections"][0]["roles"][0]["confidence"]["level"] = json!("certain"); + assert!(project_payload_from_content(&invalid_role_confidence_level.to_string()).is_err()); + + let mut invalid_rehearsal_priority = current_rehearsal_song(); + invalid_rehearsal_priority["sections"][0]["roles"][0]["rehearsalPriority"] = json!("urgent"); + assert!(project_payload_from_content(&invalid_rehearsal_priority.to_string()).is_err()); + + let mut invalid_export_format = current_rehearsal_song(); + invalid_export_format["exportSummary"]["format"] = json!("pdf"); + assert!(project_payload_from_content(&invalid_export_format.to_string()).is_err()); + + let mut invalid_override_field = current_rehearsal_song(); + invalid_override_field["sections"][0]["roles"][0]["manualOverrides"][0]["field"] = json!("tempo"); + assert!(project_payload_from_content(&invalid_override_field.to_string()).is_err()); + + let mut invalid_override_source = current_rehearsal_song(); + invalid_override_source["sections"][0]["roles"][0]["manualOverrides"][0]["source"] = json!("model"); + assert!(project_payload_from_content(&invalid_override_source.to_string()).is_err()); + + let mut invalid_override_value_source = current_rehearsal_song(); + invalid_override_value_source["sections"][0]["roles"][0]["manualOverrides"][0]["value"]["source"] = json!("model"); + assert!(project_payload_from_content(&invalid_override_value_source.to_string()).is_err()); +} + +#[test] +fn project_persistence_rejects_explicit_null_for_optional_shared_fields() { + let mut null_collaboration = current_rehearsal_song(); + null_collaboration["collaboration"] = Value::Null; + assert!(project_payload_from_content(&null_collaboration.to_string()).is_err()); + + let mut null_score_attachments = current_rehearsal_song(); + null_score_attachments["scoreAttachments"] = Value::Null; + assert!(project_payload_from_content(&null_score_attachments.to_string()).is_err()); + + let mut null_assignment_role = current_rehearsal_song(); + null_assignment_role["collaboration"]["assignments"][0]["roleId"] = Value::Null; + assert!(project_payload_from_content(&null_assignment_role.to_string()).is_err()); + + let mut null_comment_role = current_rehearsal_song(); + null_comment_role["collaboration"]["comments"][0]["roleId"] = Value::Null; + assert!(project_payload_from_content(&null_comment_role.to_string()).is_err()); + + for field in ["harmonicExplanation", "transpositionPlan", "transcription"] { + let mut null_role_field = current_rehearsal_song(); + null_role_field["sections"][0]["roles"][0][field] = Value::Null; + assert!(project_payload_from_content(&null_role_field.to_string()).is_err()); + } +} \ No newline at end of file diff --git a/apps/desktop/core/tests/score_pdf_read.rs b/apps/desktop/core/tests/score_pdf_read.rs new file mode 100644 index 000000000..b70068931 --- /dev/null +++ b/apps/desktop/core/tests/score_pdf_read.rs @@ -0,0 +1,91 @@ +use bandscope_desktop_core::{read_validated_score_pdf, MAX_SCORE_PDF_BYTES}; +use std::io::Write; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn unique_test_dir(name: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!("bandscope-{name}-{suffix}")) +} + +#[test] +fn score_pdf_read_returns_only_valid_bounded_pdf_bytes() { + let root = unique_test_dir("score-read-valid"); + std::fs::create_dir_all(&root).expect("test directory should be created"); + let path = root.join("score.pdf"); + let expected = b"%PDF-1.7\nvalidated body"; + std::fs::write(&path, expected).expect("valid PDF fixture should be written"); + + let actual = read_validated_score_pdf(&path).expect("valid stored PDF should be readable"); + + assert_eq!(actual, expected); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn score_pdf_read_rejects_empty_short_and_wrong_magic_content() { + let root = unique_test_dir("score-read-invalid"); + std::fs::create_dir_all(&root).expect("test directory should be created"); + + for (name, content) in [ + ("empty.pdf", b"".as_slice()), + ("short.pdf", b"%PD".as_slice()), + ("wrong.pdf", b"PK\x03\x04 not a PDF".as_slice()), + ] { + let path = root.join(name); + std::fs::write(&path, content).expect("invalid PDF fixture should be written"); + let error = read_validated_score_pdf(&path).expect_err("invalid PDF must fail closed"); + assert!( + error == "Could not read the score PDF." || error == "Stored score is not a valid PDF.", + "unexpected payload-safe error: {error}" + ); + assert!(!error.contains(root.to_string_lossy().as_ref())); + } + + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn score_pdf_read_rejects_oversized_sparse_file_before_heap_allocation() { + let root = unique_test_dir("score-read-oversized"); + std::fs::create_dir_all(&root).expect("test directory should be created"); + let path = root.join("oversized.pdf"); + let mut file = std::fs::File::create(&path).expect("oversized PDF fixture should be created"); + file.write_all(b"%PDF-") + .expect("PDF magic should be written before extending sparse file"); + file.set_len(MAX_SCORE_PDF_BYTES + 1) + .expect("sparse PDF fixture should exceed the product limit"); + drop(file); + + let error = read_validated_score_pdf(&path).expect_err("oversized PDF must fail closed"); + + assert_eq!(error, "Score PDF is too large (exceeds 25MB limit)."); + let _ = std::fs::remove_dir_all(root); +} + +#[cfg(unix)] +#[test] +fn score_pdf_read_rejects_non_file_descriptor() { + let root = unique_test_dir("score-read-directory"); + std::fs::create_dir_all(&root).expect("test directory should be created"); + + let error = read_validated_score_pdf(&root).expect_err("directory must fail closed"); + + assert_eq!(error, "Could not read the score PDF."); + assert!(!error.contains(root.to_string_lossy().as_ref())); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn score_pdf_read_rejects_missing_file_without_exposing_path() { + let root = unique_test_dir("score-read-missing"); + let path = root.join("private-score.pdf"); + + let error = read_validated_score_pdf(&path).expect_err("missing PDF must fail closed"); + + assert_eq!(error, "Could not read the score PDF."); + assert!(!error.contains("private-score.pdf")); +} diff --git a/apps/desktop/src-tauri/src/analysis_source.rs b/apps/desktop/src-tauri/src/analysis_source.rs new file mode 100644 index 000000000..c70c426e3 --- /dev/null +++ b/apps/desktop/src-tauri/src/analysis_source.rs @@ -0,0 +1,61 @@ +use bandscope_desktop_core::{ + project_source_reference_from_publication_identity, + re_admit_local_audio_publication_from_project_root, + LocalAudioPublicationIdentity, ProjectBootstrapSummaryPayload, +}; +use std::{io::Read, path::Path}; + +const ANALYSIS_SOURCE_NOT_FOUND: &str = + "Analysis job source was not found. Choose local audio again."; + +/// Re-establish current app-owned source bytes immediately before analysis dispatch. +/// +/// Security Notes: the bootstrap path is transient native state, not durable +/// evidence. The retained path-free Resource Admission identity is projected +/// through the Project Persistence ACL, the fixed `source.` artifact +/// is reopened by the supplied no-follow/reparse-aware native opener, and the +/// current bytes must reproduce the retained bounded size and SHA-256 before +/// they can be sent to the analysis process. Cache and temporary workspaces are +/// namespaced by that same canonical digest so a same-path/same-size replacement +/// cannot alias analysis or stem-work artifacts from another content identity. +/// OS/file-system details are reduced to the stable buyer-facing re-selection +/// error. Decoder-byte continuity is completed downstream by the per-process +/// identity handoff and verified snapshot; this adapter does not mint a second +/// content identity. +pub fn revalidate_local_audio_bootstrap_for_analysis( + bootstrap: &ProjectBootstrapSummaryPayload, + identity: &LocalAudioPublicationIdentity, + open_file: F, +) -> Result +where + R: Read, + F: FnOnce(&Path) -> std::io::Result, +{ + if bootstrap.project_id != identity.project_id { + return Err(ANALYSIS_SOURCE_NOT_FOUND.to_string()); + } + + let reference = project_source_reference_from_publication_identity(identity) + .map_err(|_| ANALYSIS_SOURCE_NOT_FOUND.to_string())?; + let reopened = re_admit_local_audio_publication_from_project_root( + Path::new(&bootstrap.project_root), + &reference, + open_file, + ) + .map_err(|_| ANALYSIS_SOURCE_NOT_FOUND.to_string())?; + + let content_sha256 = reopened.identity.content_sha256.clone(); + let mut refreshed = bootstrap.clone(); + refreshed.source.source_path = reopened.source_path.to_string_lossy().into_owned(); + refreshed.source.extension = reopened.identity.extension; + refreshed.source.file_size_bytes = reopened.identity.file_size_bytes; + refreshed.cache_root = Path::new(&bootstrap.cache_root) + .join(&content_sha256) + .to_string_lossy() + .into_owned(); + refreshed.temp_root = Path::new(&bootstrap.temp_root) + .join(&content_sha256) + .to_string_lossy() + .into_owned(); + Ok(refreshed) +} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index ed4f967bd..d58651d93 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,5 +1,10 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod analysis_source; +mod project_persistence; +mod project_root; + +use analysis_source::revalidate_local_audio_bootstrap_for_analysis; use bandscope_desktop_core::*; use rfd::FileDialog; use serde_json::{json, Value}; @@ -14,6 +19,19 @@ use std::{ use tauri::{Emitter, Manager, Runtime}; use time::{format_description::well_known::Rfc3339, OffsetDateTime}; +const ADMITTED_AUDIO_BYTES_ENV: &str = "BANDSCOPE_ADMITTED_AUDIO_BYTES"; +const ADMITTED_AUDIO_SHA256_ENV: &str = "BANDSCOPE_ADMITTED_AUDIO_SHA256"; + +/// Native-only cache of verified local-audio publication identities. +/// +/// Security Notes: entries are keyed only by BandScope-minted project ids and +/// contain the bounded path-free publication evidence emitted by Resource +/// Admission. User filesystem paths are never retained in this state. +#[derive(Default)] +struct LocalAudioPublicationIdentityState( + std::sync::Mutex>, +); + fn iso_timestamp_now() -> String { OffsetDateTime::now_utc() .format(&Rfc3339) @@ -141,7 +159,25 @@ fn app_owned_root( Ok(root) } -fn normalize_local_audio_source(path: &Path) -> Result { +/// Admit one OS-selected local audio file into a project-owned immutable source artifact. +/// +/// Security Notes: the external path is used only to canonicalize and open the +/// user-authorized source. Size is checked from that opened descriptor, bytes +/// are copied through the bounded Resource Admission helper into a private +/// same-project staging file. After the stage is synchronized, publication uses +/// a same-filesystem hard link so an existing `source.` name cannot +/// be overwritten; the private stage name is then removed. The published object +/// is required to remain a regular non-symlink filesystem entry and its opened +/// bytes must reproduce the staging size+SHA-256 receipt before bootstrap +/// authority is returned. This keeps later analysis bound to the app-owned +/// publication rather than the mutable user-selected path. Atomic no-follow +/// descriptor acquisition remains a separate platform-hardening requirement; +/// these portable checks do not claim O_NOFOLLOW-equivalent race semantics. +fn materialize_local_audio_source( + path: &Path, + project_root: &Path, + project_id: &str, +) -> Result<(LocalAudioSourcePayload, LocalAudioPublicationIdentity), String> { let canonical = path .canonicalize() .map_err(|_| "Could not read the selected audio file.".to_string())?; @@ -153,22 +189,109 @@ fn normalize_local_audio_source(path: &Path) -> Result receipt, + Err(error) => { + drop(staged); + let _ = std::fs::remove_file(&stage); + return Err(error); + } + }; + if staged.sync_all().is_err() { + drop(staged); + let _ = std::fs::remove_file(&stage); + return Err("Could not prepare the local project workspace.".to_string()); + } + drop(staged); - Ok(LocalAudioSourcePayload { - source_path: canonical.to_string_lossy().into_owned(), - file_name: file_name.to_string(), - extension, - file_size_bytes: metadata.len(), - }) + if std::fs::hard_link(&stage, &destination).is_err() { + let _ = std::fs::remove_file(&stage); + return Err("Could not prepare the local project workspace.".to_string()); + } + if std::fs::remove_file(&stage).is_err() { + let _ = std::fs::remove_file(&destination); + let _ = std::fs::remove_file(&stage); + return Err("Could not prepare the local project workspace.".to_string()); + } + + let published_path_metadata = match std::fs::symlink_metadata(&destination) { + Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => metadata, + _ => { + let _ = std::fs::remove_file(&destination); + return Err("Could not prepare the local project workspace.".to_string()); + } + }; + if published_path_metadata.len() != receipt.file_size_bytes { + let _ = std::fs::remove_file(&destination); + return Err("Could not prepare the local project workspace.".to_string()); + } + let published = match std::fs::File::open(&destination) { + Ok(file) => file, + Err(_) => { + let _ = std::fs::remove_file(&destination); + return Err("Could not prepare the local project workspace.".to_string()); + } + }; + let published_descriptor_metadata = match published.metadata() { + Ok(metadata) if metadata.is_file() && metadata.len() == receipt.file_size_bytes => metadata, + _ => { + drop(published); + let _ = std::fs::remove_file(&destination); + return Err("Could not prepare the local project workspace.".to_string()); + } + }; + if published_descriptor_metadata.len() != published_path_metadata.len() + || verify_local_audio_publication_receipt(published, &receipt).is_err() + { + let _ = std::fs::remove_file(&destination); + return Err("Could not prepare the local project workspace.".to_string()); + } + let published_path_metadata = match std::fs::symlink_metadata(&destination) { + Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => metadata, + _ => { + let _ = std::fs::remove_file(&destination); + return Err("Could not prepare the local project workspace.".to_string()); + } + }; + if published_path_metadata.len() != receipt.file_size_bytes { + let _ = std::fs::remove_file(&destination); + return Err("Could not prepare the local project workspace.".to_string()); + } + + let publication_identity = + build_local_audio_publication_identity(project_id, &extension, &receipt)?; + Ok(( + LocalAudioSourcePayload { + source_path: destination.to_string_lossy().into_owned(), + file_name, + extension, + file_size_bytes: receipt.file_size_bytes, + }, + publication_identity, + )) } fn parse_request_payload(payload: Value) -> Result { @@ -304,6 +427,119 @@ fn store_bootstrap_source(state: &AppState, summary: ProjectBootstrapSummaryPayl } } +/// Retain path-free publication evidence before the renderer receives bootstrap authority. +fn store_local_audio_publication_identity( + state: &LocalAudioPublicationIdentityState, + identity: LocalAudioPublicationIdentity, +) -> Result<(), String> { + let project_id = identity.project_id.clone(); + let mut identities = state + .0 + .lock() + .map_err(|_| "Could not prepare the local project workspace.".to_string())?; + identities.insert(project_id, identity); + Ok(()) +} + +/// Bind renderer-owned project state to an already-verified Resource Admission identity. +/// +/// Security Notes: the renderer can select only a BandScope-minted project id. +/// It cannot submit a path, artifact name, byte count, digest, or sourceReference. +/// The exact keyed native identity is revalidated through the Project Persistence +/// ACL before serialization. An unknown or malformed id fails closed; omitting +/// the selector preserves compatibility for projects that have no admitted local +/// source identity yet. +fn project_document_with_retained_source_reference( + mut document: ProjectDocumentPayload, + project_id: Option<&str>, + state: &LocalAudioPublicationIdentityState, +) -> Result { + let Some(project_id) = project_id else { + return Ok(document); + }; + if !is_valid_project_id(project_id) { + return Err("Invalid project payload".to_string()); + } + + let identity = state + .0 + .lock() + .map_err(|_| "Invalid project payload".to_string())? + .get(project_id) + .cloned() + .ok_or_else(|| "Invalid project payload".to_string())?; + if identity.project_id != project_id { + return Err("Invalid project payload".to_string()); + } + + document.source_reference = Some( + project_source_reference_from_publication_identity(&identity) + .map_err(|_| "Invalid project payload".to_string())?, + ); + Ok(document) +} + +/// Rebuild native full-mix authority for one persisted v3 project before returning it. +/// +/// Security Notes: the persisted `sourceReference` is evidence only. The project +/// root is resolved from the Tauri app-local base without provisioning a missing +/// directory; the source is then opened through the canonical no-follow/reparse +/// Project Persistence opener and must reproduce the persisted bounded byte count +/// and SHA-256. Only after that verification do native publication and bootstrap +/// maps regain authority. Cache/temp workspaces are provisioned after source +/// re-admission, so a forged or missing project source cannot cause read-side +/// project-directory creation. +fn restore_project_source_after_restart( + app: &tauri::AppHandle, + state: &AppState, + publication_state: &LocalAudioPublicationIdentityState, + document: &ProjectDocumentPayload, +) -> Result<(), String> { + let Some(reference) = document.source_reference.as_ref() else { + return Ok(()); + }; + + let base_root = app + .path() + .app_local_data_dir() + .map_err(|_| "Could not prepare the local project workspace.".to_string())?; + let project_root = project_root::resolve_existing_project_root(&base_root, &reference.project_id)?; + let reopened = re_admit_local_audio_publication_from_project_root( + &project_root, + reference, + project_persistence::open_project_file, + )?; + + let cache_root = app_owned_root(app, "cache", &reference.project_id)?; + let temp_root = app_owned_root(app, "temp", &reference.project_id)?; + let summary = ProjectBootstrapSummaryPayload { + project_id: reference.project_id.clone(), + source_mode: "reference".into(), + project_root: project_root.to_string_lossy().into_owned(), + cache_root: cache_root.to_string_lossy().into_owned(), + temp_root: temp_root.to_string_lossy().into_owned(), + source: LocalAudioSourcePayload { + source_path: reopened.source_path.to_string_lossy().into_owned(), + file_name: reference.artifact_name.clone(), + extension: reference.extension.clone(), + file_size_bytes: reference.file_size_bytes, + }, + }; + + let mut identities = publication_state + .0 + .lock() + .map_err(|_| "Could not prepare the local project workspace.".to_string())?; + let mut sources = state + .0 + .bootstrap_sources + .lock() + .map_err(|_| "Could not prepare the local project workspace.".to_string())?; + identities.insert(reference.project_id.clone(), reopened.identity); + sources.insert(reference.project_id.clone(), summary); + Ok(()) +} + fn lookup_bootstrap_source( state: &AppState, project_id: &str, @@ -334,6 +570,7 @@ fn run_analysis_engine( app: tauri::AppHandle, job_id: String, request: AnalysisJobRequest, + admitted_identity: Option, requested_at: String, ) -> AnalysisJobStatus { let (working_dir, program, mut args) = analysis_command(); @@ -348,14 +585,25 @@ fn run_analysis_engine( } args.push("--progress-jsonl".into()); - let mut process = match Command::new(program) + let mut command = Command::new(program); + command .args(args) .current_dir(working_dir) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .spawn() - { + .env_remove(ADMITTED_AUDIO_BYTES_ENV) + .env_remove(ADMITTED_AUDIO_SHA256_ENV); + if let Some(identity) = admitted_identity.as_ref() { + command + .env( + ADMITTED_AUDIO_BYTES_ENV, + identity.file_size_bytes.to_string(), + ) + .env(ADMITTED_AUDIO_SHA256_ENV, &identity.content_sha256); + } + + let mut process = match command.spawn() { Ok(process) => process, Err(_) => { return failed_status( @@ -519,6 +767,7 @@ fn start_analysis_job( request: Value, app: tauri::AppHandle, state: tauri::State<'_, AppState>, + publication_state: tauri::State<'_, LocalAudioPublicationIdentityState>, ) -> AnalysisJobStatus { let requested_at = iso_timestamp_now(); let mut parsed_request = match parse_request_payload(request) { @@ -532,6 +781,7 @@ fn start_analysis_job( ) } }; + let mut admitted_identity = None; if parsed_request.source_kind == "local_audio" { let Some(project_id) = parsed_request.project_id.clone() else { @@ -553,6 +803,38 @@ fn start_analysis_job( ) } }; + let identity = match publication_state + .0 + .lock() + .ok() + .and_then(|identities| identities.get(&project_id).cloned()) + { + Some(identity) => identity, + None => { + return failed_status( + "invalid-job".into(), + requested_at, + AnalysisJobErrorCode::NotFound, + "Analysis job source was not found. Choose local audio again.", + ) + } + }; + let bootstrap = match revalidate_local_audio_bootstrap_for_analysis( + &bootstrap, + &identity, + project_persistence::open_project_file, + ) { + Ok(bootstrap) => bootstrap, + Err(message) => { + return failed_status( + "invalid-job".into(), + requested_at, + AnalysisJobErrorCode::NotFound, + &message, + ) + } + }; + admitted_identity = Some(identity); parsed_request.source_label = bootstrap.source.file_name.clone(); parsed_request.cache_root = Some(bootstrap.cache_root.clone()); parsed_request.temp_root = Some(bootstrap.temp_root.clone()); @@ -606,6 +888,7 @@ fn start_analysis_job( worker_app_handle.clone(), job_id, parsed_request, + admitted_identity, requested_at, ); store_status_and_emit(&app_state, &worker_app_handle, &finished); @@ -637,16 +920,19 @@ fn get_analysis_job_status(job_id: String, state: tauri::State<'_, AppState>) -> fn select_local_audio_source( app: tauri::AppHandle, state: tauri::State<'_, AppState>, + publication_state: tauri::State<'_, LocalAudioPublicationIdentityState>, ) -> Result { let path = FileDialog::new() .add_filter("Audio", &AUDIO_EXTENSIONS) .pick_file() .ok_or_else(|| "Choose a WAV, MP3, FLAC, or M4A file to start analysis.".to_string())?; - let source = normalize_local_audio_source(&path)?; let project_id = next_project_id(&state); let project_root = app_owned_root(&app, "projects", &project_id)?; let cache_root = app_owned_root(&app, "cache", &project_id)?; let temp_root = app_owned_root(&app, "temp", &project_id)?; + let (source, publication_identity) = + materialize_local_audio_source(&path, &project_root, &project_id)?; + store_local_audio_publication_identity(&publication_state, publication_identity)?; let summary = ProjectBootstrapSummaryPayload { project_id, @@ -712,6 +998,7 @@ async fn import_youtube_url( if parsed.get("ok").and_then(|v| v.as_bool()) == Some(true) { if let Some(metadata) = parsed.get("metadata") { let source = youtube_source_from_metadata(metadata, &cache_root)?; + validate_local_audio_file_size(source.file_size_bytes)?; let summary = ProjectBootstrapSummaryPayload { project_id, @@ -739,36 +1026,47 @@ async fn import_youtube_url( } #[tauri::command] -fn save_project(payload: Value) -> Result<(), String> { - let parsed = serde_json::from_value::(payload) +fn save_project( + payload: Value, + project_id: Option, + publication_state: tauri::State<'_, LocalAudioPublicationIdentityState>, +) -> Result<(), String> { + let parsed = project_document_from_value(payload) .map_err(|_| "Invalid project payload".to_string())?; + let parsed = project_document_with_retained_source_reference( + parsed, + project_id.as_deref(), + &publication_state, + )?; let path = FileDialog::new() .add_filter("BandScope Project", &["bscope", "json"]) .save_file() .ok_or_else(|| "User cancelled".to_string())?; - let content = serde_json::to_string_pretty(&parsed) - .map_err(|_| "Failed to serialize project".to_string())?; - std::fs::write(path, content).map_err(|_| "Failed to write file".to_string())?; + let content = project_content_for_document(&parsed)?; + project_persistence::recover_project_publication(&path)?; + project_persistence::publish_new_project_file(&path, content.as_bytes())?; Ok(()) } #[tauri::command] -fn load_project() -> Result { +fn load_project( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, + publication_state: tauri::State<'_, LocalAudioPublicationIdentityState>, +) -> Result { let path = FileDialog::new() .add_filter("BandScope Project", &["bscope", "json"]) .pick_file() .ok_or_else(|| "User cancelled".to_string())?; - let metadata = std::fs::metadata(&path).map_err(|_| "Failed to read file".to_string())?; - if metadata.len() > 5 * 1024 * 1024 { - return Err("Project file is too large (exceeds 5MB limit)".to_string()); - } - - let content = std::fs::read_to_string(path).map_err(|_| "Failed to read file".to_string())?; - project_payload_from_content(&content) + project_persistence::recover_project_publication(&path)?; + let content = project_persistence::read_project_file(&path)?; + let document = project_document_from_content(&content)?; + restore_project_source_after_restart(&app, &state, &publication_state, &document)?; + Ok(document) } fn scores_root_for_project( @@ -826,7 +1124,9 @@ fn attach_score_pdf( /// Security Notes: no path crosses the IPC boundary. Both ids are validated /// against strict allowlist shapes, the path is rebuilt locally, and the /// canonicalize-plus-prefix guard in `resolve_existing_score_pdf` rejects any -/// escape from the app-owned scores root. +/// escape from the app-owned scores root. The resolved file is then read +/// through the bounded core helper so growth after attachment cannot trigger +/// an allocation beyond the 25 MiB product limit. #[tauri::command] fn read_score_pdf( project_id: String, @@ -838,7 +1138,7 @@ fn read_score_pdf( } let scores_root = scores_root_for_project(&app, &project_id)?; let path = resolve_existing_score_pdf(&scores_root, &score_id)?; - std::fs::read(path).map_err(|_| "Could not read the score PDF.".to_string()) + read_validated_score_pdf(&path) } /// Security Notes: same id validation and traversal guard as `read_score_pdf`; @@ -868,6 +1168,7 @@ fn remove_score_pdf( fn main() { tauri::Builder::default() .manage(AppState::default()) + .manage(LocalAudioPublicationIdentityState::default()) .invoke_handler(tauri::generate_handler![ select_local_audio_source, import_youtube_url, diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs new file mode 100644 index 000000000..108723452 --- /dev/null +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -0,0 +1,1775 @@ +use std::{ + fs::{self, File}, + io::{Read, Write}, + path::{Path, PathBuf}, +}; +use serde::{Deserialize, Serialize}; + +const MAX_PROJECT_FILE_BYTES: usize = 5 * 1024 * 1024; +const MAX_RECOVERY_JOURNAL_BYTES: usize = 64 * 1024; +const PROJECT_EXISTS_ERROR: &str = "Project file already exists. Choose a new file name."; +const PROJECT_STAGE_ERROR: &str = "Could not stage the project safely."; +const PROJECT_PUBLISH_ERROR: &str = "Could not publish the project safely."; +const PROJECT_READ_ERROR: &str = "Failed to read file"; +const PROJECT_TOO_LARGE_ERROR: &str = "Project file is too large (exceeds 5 MiB limit)"; +const PROJECT_RECOVERY_ERROR: &str = "Could not recover the project publication safely."; + +#[cfg(windows)] +const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; +#[cfg(windows)] +const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; +#[cfg(target_os = "linux")] +const UNIX_PROJECT_OPEN_FLAGS: i32 = 0x0002_0800; // O_NOFOLLOW | O_NONBLOCK +#[cfg(target_os = "macos")] +const UNIX_PROJECT_OPEN_FLAGS: i32 = 0x0000_0104; // O_NOFOLLOW | O_NONBLOCK + +fn project_parent(target: &Path) -> &Path { + match target.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent, + _ => Path::new("."), + } +} + +fn staging_path(target: &Path) -> Result { + let parent = project_parent(target); + if target.file_name().is_none() { + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + let stage_name = format!(".bandscope-stage-{}.stage", uuid::Uuid::new_v4()); + Ok(parent.join(stage_name)) +} + +fn remove_stage(path: &Path) { + let _ = fs::remove_file(path); +} + +#[cfg(target_os = "linux")] +fn rename_noreplace(source: &Path, destination: &Path) -> std::io::Result<()> { + use std::{ffi::CString, os::unix::ffi::OsStrExt}; + + const AT_FDCWD: i32 = -100; + const RENAME_NOREPLACE: u32 = 1; + + extern "C" { + fn renameat2( + olddirfd: i32, + oldpath: *const std::os::raw::c_char, + newdirfd: i32, + newpath: *const std::os::raw::c_char, + flags: u32, + ) -> i32; + } + + let source = CString::new(source.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "project staging path contains NUL", + ) + })?; + let destination = CString::new(destination.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "project destination path contains NUL", + ) + })?; + + let result = unsafe { + renameat2( + AT_FDCWD, + source.as_ptr(), + AT_FDCWD, + destination.as_ptr(), + RENAME_NOREPLACE, + ) + }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(target_os = "macos")] +fn rename_noreplace(source: &Path, destination: &Path) -> std::io::Result<()> { + use std::{ffi::CString, os::unix::ffi::OsStrExt}; + + const RENAME_EXCL: u32 = 0x0000_0004; + + extern "C" { + fn renamex_np( + from: *const std::os::raw::c_char, + to: *const std::os::raw::c_char, + flags: u32, + ) -> i32; + } + + let source = CString::new(source.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "project staging path contains NUL", + ) + })?; + let destination = CString::new(destination.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "project destination path contains NUL", + ) + })?; + + let result = unsafe { renamex_np(source.as_ptr(), destination.as_ptr(), RENAME_EXCL) }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(windows)] +fn rename_noreplace(source: &Path, destination: &Path) -> std::io::Result<()> { + use std::os::windows::ffi::OsStrExt; + + const MOVEFILE_WRITE_THROUGH: u32 = 0x0000_0008; + + #[link(name = "kernel32")] + extern "system" { + #[link_name = "MoveFileExW"] + fn move_file_ex_w(existing: *const u16, new: *const u16, flags: u32) -> i32; + } + + let source = source + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let destination = destination + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + + let result = unsafe { + move_file_ex_w( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_WRITE_THROUGH, + ) + }; + if result != 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] +fn rename_noreplace(_source: &Path, _destination: &Path) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "atomic no-replace project publication is unsupported on this platform", + )) +} + +#[cfg(target_os = "linux")] +fn rename_exchange(left: &Path, right: &Path) -> std::io::Result<()> { + use std::{ffi::CString, os::unix::ffi::OsStrExt}; + + const AT_FDCWD: i32 = -100; + const RENAME_EXCHANGE: u32 = 2; + + extern "C" { + fn renameat2( + olddirfd: i32, + oldpath: *const std::os::raw::c_char, + newdirfd: i32, + newpath: *const std::os::raw::c_char, + flags: u32, + ) -> i32; + } + + let left = CString::new(left.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "project exchange path contains NUL", + ) + })?; + let right = CString::new(right.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "project exchange path contains NUL", + ) + })?; + + let result = unsafe { + renameat2( + AT_FDCWD, + left.as_ptr(), + AT_FDCWD, + right.as_ptr(), + RENAME_EXCHANGE, + ) + }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(target_os = "macos")] +fn rename_exchange(left: &Path, right: &Path) -> std::io::Result<()> { + use std::{ffi::CString, os::unix::ffi::OsStrExt}; + + const RENAME_SWAP: u32 = 0x0000_0002; + + extern "C" { + fn renamex_np( + from: *const std::os::raw::c_char, + to: *const std::os::raw::c_char, + flags: u32, + ) -> i32; + } + + let left = CString::new(left.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "project exchange path contains NUL", + ) + })?; + let right = CString::new(right.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "project exchange path contains NUL", + ) + })?; + + let result = unsafe { renamex_np(left.as_ptr(), right.as_ptr(), RENAME_SWAP) }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(windows)] +fn replace_file_with_backup( + replaced: &Path, + replacement: &Path, + backup: &Path, +) -> std::io::Result<()> { + use std::{os::windows::ffi::OsStrExt, ptr}; + + #[link(name = "kernel32")] + extern "system" { + #[link_name = "ReplaceFileW"] + fn replace_file_w( + replaced_file_name: *const u16, + replacement_file_name: *const u16, + backup_file_name: *const u16, + replace_flags: u32, + exclude: *mut std::ffi::c_void, + reserved: *mut std::ffi::c_void, + ) -> i32; + } + + let wide = |path: &Path| { + path.as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>() + }; + let replaced = wide(replaced); + let replacement = wide(replacement); + let backup = wide(backup); + + let result = unsafe { + replace_file_w( + replaced.as_ptr(), + replacement.as_ptr(), + backup.as_ptr(), + 0, + ptr::null_mut(), + ptr::null_mut(), + ) + }; + if result != 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(windows)] +pub(crate) fn open_project_file(target: &Path) -> std::io::Result { + use std::os::windows::fs::OpenOptionsExt; + + let mut options = fs::OpenOptions::new(); + options + .read(true) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + options.open(target) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn open_project_file(target: &Path) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + + let mut options = fs::OpenOptions::new(); + options.read(true).custom_flags(UNIX_PROJECT_OPEN_FLAGS); + options.open(target) +} + +#[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))] +pub(crate) fn open_project_file(_target: &Path) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "project loading requires no-follow handle acquisition on this platform", + )) +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn open_project_file(_target: &Path) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "project loading is unsupported on this platform", + )) +} + +#[cfg(unix)] +fn same_file_identity(left: &fs::Metadata, right: &fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + + left.dev() == right.dev() && left.ino() == right.ino() +} + +#[cfg(windows)] +#[repr(C)] +struct WindowsFileTime { + low_date_time: u32, + high_date_time: u32, +} + +#[cfg(windows)] +#[repr(C)] +struct WindowsByHandleFileInformation { + file_attributes: u32, + creation_time: WindowsFileTime, + last_access_time: WindowsFileTime, + last_write_time: WindowsFileTime, + volume_serial_number: u32, + file_size_high: u32, + file_size_low: u32, + number_of_links: u32, + file_index_high: u32, + file_index_low: u32, +} + +#[cfg(windows)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct WindowsFileIdentity { + volume_serial_number: u32, + file_index: u64, +} + +#[cfg(windows)] +pub(crate) fn windows_file_identity(file: &File) -> std::io::Result { + use std::{mem::MaybeUninit, os::windows::io::AsRawHandle}; + + #[link(name = "kernel32")] + extern "system" { + #[link_name = "GetFileInformationByHandle"] + fn get_file_information_by_handle( + file: std::os::windows::io::RawHandle, + information: *mut WindowsByHandleFileInformation, + ) -> i32; + } + + let mut information = MaybeUninit::::uninit(); + let result = unsafe { + get_file_information_by_handle(file.as_raw_handle(), information.as_mut_ptr()) + }; + if result == 0 { + return Err(std::io::Error::last_os_error()); + } + let information = unsafe { information.assume_init() }; + Ok(WindowsFileIdentity { + volume_serial_number: information.volume_serial_number, + file_index: ((information.file_index_high as u64) << 32) + | information.file_index_low as u64, + }) +} + +#[cfg(windows)] +fn metadata_is_regular_project_file(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + + metadata.is_file() && metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 +} + +#[cfg(not(windows))] +fn metadata_is_regular_project_file(metadata: &fs::Metadata) -> bool { + metadata.is_file() && !metadata.file_type().is_symlink() +} + +#[cfg(windows)] +fn metadata_is_safe_project_directory(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + + metadata.is_dir() && metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 +} + +#[cfg(not(windows))] +fn metadata_is_safe_project_directory(metadata: &fs::Metadata) -> bool { + metadata.is_dir() && !metadata.file_type().is_symlink() +} + +#[cfg(unix)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct ProjectFileIdentity { + device: u64, + inode: u64, +} + +#[cfg(unix)] +fn project_file_identity_from_metadata(metadata: &fs::Metadata) -> ProjectFileIdentity { + use std::os::unix::fs::MetadataExt; + + ProjectFileIdentity { + device: metadata.dev(), + inode: metadata.ino(), + } +} + +#[cfg(unix)] +pub(crate) fn project_file_identity(target: &Path) -> Result { + let metadata = fs::symlink_metadata(target).map_err(|_| PROJECT_PUBLISH_ERROR.to_string())?; + if !metadata_is_regular_project_file(&metadata) { + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + Ok(project_file_identity_from_metadata(&metadata)) +} + +#[cfg(windows)] +pub(crate) type ProjectFileIdentity = WindowsFileIdentity; + +#[cfg(windows)] +pub(crate) fn project_file_identity(target: &Path) -> Result { + let file = open_project_file(target).map_err(|_| PROJECT_PUBLISH_ERROR.to_string())?; + let metadata = file + .metadata() + .map_err(|_| PROJECT_PUBLISH_ERROR.to_string())?; + if !metadata_is_regular_project_file(&metadata) { + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + windows_file_identity(&file).map_err(|_| PROJECT_PUBLISH_ERROR.to_string()) +} + +#[cfg(not(any(unix, windows)))] +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct ProjectFileIdentity; + +#[cfg(not(any(unix, windows)))] +pub(crate) fn project_file_identity(_target: &Path) -> Result { + Err(PROJECT_PUBLISH_ERROR.to_string()) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[cfg(unix)] +type JournalPathName = Vec; + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[cfg(windows)] +type JournalPathName = Vec; + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[derive(Debug, Deserialize, Serialize)] +struct PublicationJournal { + version: u8, + target_name: JournalPathName, + candidate_name: JournalPathName, + displaced_name: JournalPathName, + expected: ProjectFileIdentity, + candidate: ProjectFileIdentity, +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn journal_path_name(path: &Path) -> Result { + let name = path + .file_name() + .ok_or_else(|| PROJECT_RECOVERY_ERROR.to_string())?; + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + Ok(name.as_bytes().to_vec()) + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + Ok(name.encode_wide().collect()) + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn path_from_journal_name(parent: &Path, name: &JournalPathName) -> Option { + #[cfg(unix)] + { + use std::{ffi::OsStr, os::unix::ffi::OsStrExt}; + Some(parent.join(OsStr::from_bytes(name))) + } + #[cfg(windows)] + { + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + Some(parent.join(OsString::from_wide(name))) + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn generated_stage_name(name: &JournalPathName) -> bool { + let Some(path) = path_from_journal_name(Path::new("."), name) else { + return false; + }; + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + return false; + }; + let Some(uuid) = name + .strip_prefix(".bandscope-stage-") + .and_then(|value| value.strip_suffix(".stage")) + else { + return false; + }; + uuid::Uuid::parse_str(uuid).is_ok() +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +pub(crate) fn journal_target_key(target: &Path) -> Result { + let canonical_target = fs::canonicalize(target).unwrap_or_else(|_| target.to_path_buf()); + // ponytail: bounded dual-hash names avoid oversized filenames; journal target/path identity + // validation prevents redirects, with a journal index as the upgrade path for hostile collisions. + let mut primary = 0xcbf29ce484222325u64; + let mut secondary = 0x84222325cbf29ce4u64; + let mut update = |byte: u8| { + primary ^= u64::from(byte); + primary = primary.wrapping_mul(0x100000001b3); + secondary ^= u64::from(byte); + secondary = secondary.wrapping_mul(0x100000001b3); + }; + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + + for byte in canonical_target.as_os_str().as_bytes() { + update(*byte); + } + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + + for unit in canonical_target.as_os_str().encode_wide() { + for byte in unit.to_le_bytes() { + update(byte); + } + } + } + Ok(format!("{primary:016x}{secondary:016x}")) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn publication_journal_path(target: &Path, published: bool) -> Result { + let phase = if published { "published" } else { "prepared" }; + Ok(project_parent(target).join(format!( + ".bandscope-recovery-{}.{}.journal", + journal_target_key(target)?, + phase + ))) +} + +#[cfg(unix)] +fn sync_parent_directory(parent: &Path) -> std::io::Result<()> { + File::open(parent)?.sync_all() +} + +#[cfg(windows)] +fn sync_parent_directory(_parent: &Path) -> std::io::Result<()> { + // Windows ReplaceFileW/MoveFileExW provide the native write-through step; directory + // handles are not opened here because ordinary directory opens are not portable on Windows. + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn create_publication_journal( + target: &Path, + candidate_stage: &Path, + displaced: &Path, + expected: &ProjectFileIdentity, + candidate: &ProjectFileIdentity, +) -> Result { + let journal_path = publication_journal_path(target, false)?; + let journal = PublicationJournal { + version: 1, + target_name: journal_path_name(target)?, + candidate_name: journal_path_name(candidate_stage)?, + displaced_name: journal_path_name(displaced)?, + expected: expected.clone(), + candidate: candidate.clone(), + }; + let bytes = serde_json::to_vec(&journal).map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + let mut file = match File::create_new(&journal_path) { + Ok(file) => file, + Err(_) => return Err(PROJECT_RECOVERY_ERROR.to_string()), + }; + if file.write_all(&bytes).is_err() + || file.sync_all().is_err() + || sync_parent_directory(project_parent(target)).is_err() + { + drop(file); + remove_stage(&journal_path); + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + Ok(journal_path) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn project_file_identity_if_present( + path: &Path, +) -> Result, String> { + match fs::symlink_metadata(path) { + Ok(metadata) => { + if !metadata_is_regular_project_file(&metadata) { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + project_file_identity(path).map(Some) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(_) => Err(PROJECT_RECOVERY_ERROR.to_string()), + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn remove_recovery_artifact(path: &Path) -> Result<(), String> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err(PROJECT_RECOVERY_ERROR.to_string()), + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn recovery_artifact_exists(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(_) => Err(PROJECT_RECOVERY_ERROR.to_string()), + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn promote_publication_journal(prepared: &Path, target: &Path) -> Result { + let published = publication_journal_path(target, true)?; + rename_noreplace(prepared, &published).map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + Ok(published) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn finish_successful_publication( + prepared: &Path, + stage: &Path, + target: &Path, +) -> Result<(), String> { + let published = promote_publication_journal(prepared, target)?; + remove_stage(stage); + if matches!( + fs::symlink_metadata(stage), + Err(error) if error.kind() == std::io::ErrorKind::NotFound + ) && sync_parent_directory(project_parent(target)).is_ok() + { + remove_stage(&published); + let _ = sync_parent_directory(project_parent(target)); + } + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn finish_rolled_back_publication( + stage: &Path, + journal: &Path, + target: &Path, +) -> Result<(), String> { + let journal_content = read_project_file_with_opener( + journal, + open_project_file, + MAX_RECOVERY_JOURNAL_BYTES, + PROJECT_RECOVERY_ERROR, + ) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + let durable_journal: PublicationJournal = serde_json::from_str(&journal_content) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + let stage_identity = + project_file_identity(stage).map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + if stage_identity != durable_journal.candidate { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + remove_recovery_artifact(stage)?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + remove_recovery_artifact(journal)?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn recover_publication_state( + target: &Path, + journal_path: &Path, + journal: &PublicationJournal, + candidate_stage: &Path, + displaced: &Path, + published: bool, +) -> Result<(), String> { + let target_identity = project_file_identity_if_present(target)?; + let candidate_identity = project_file_identity_if_present(candidate_stage)?; + let displaced_identity = if displaced == candidate_stage { + candidate_identity.clone() + } else { + project_file_identity_if_present(displaced)? + }; + + if published { + if target_identity.as_ref() != Some(&journal.candidate) + || (displaced_identity.is_some() + && displaced_identity.as_ref() != Some(&journal.expected)) + || (displaced != candidate_stage && candidate_identity.is_some()) + { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + if displaced_identity.is_some() { + remove_recovery_artifact(displaced)?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + } + remove_recovery_artifact(journal_path)?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + return Ok(()); + } + + if target_identity.as_ref() == Some(&journal.candidate) + && displaced_identity + .as_ref() + .is_some_and(|identity| identity != &journal.candidate) + { + #[cfg(any(target_os = "linux", target_os = "macos"))] + if rename_exchange(displaced, target).is_err() { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + + #[cfg(windows)] + { + if replace_file_with_backup(target, displaced, candidate_stage).is_err() { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + } + remove_recovery_artifact(candidate_stage)?; + if displaced != candidate_stage { + remove_recovery_artifact(displaced)?; + } + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + remove_recovery_artifact(journal_path)?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + return Ok(()); + } + + if target_identity.as_ref() == Some(&journal.expected) + && candidate_identity.as_ref() == Some(&journal.candidate) + && (displaced_identity.is_none() || displaced == candidate_stage) + { + remove_recovery_artifact(candidate_stage)?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + remove_recovery_artifact(journal_path)?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + return Ok(()); + } + + let rollback_artifact_consumed = displaced == candidate_stage || displaced_identity.is_none(); + if target_identity + .as_ref() + .is_some_and(|identity| identity != &journal.expected && identity != &journal.candidate) + && candidate_identity.as_ref() == Some(&journal.candidate) + && rollback_artifact_consumed + { + return finish_rolled_back_publication(candidate_stage, journal_path, target); + } + + if candidate_identity.is_none() && displaced_identity.is_none() { + remove_recovery_artifact(journal_path)?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + return Ok(()); + } + + Err(PROJECT_RECOVERY_ERROR.to_string()) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn journal_target_matches( + target: &Path, + parent: &Path, + journal_target_name: &JournalPathName, +) -> bool { + let Ok(target_name) = journal_path_name(target) else { + return false; + }; + if target_name == *journal_target_name { + return true; + } + let Some(journal_target) = path_from_journal_name(parent, journal_target_name) else { + return false; + }; + let Ok(Some(target_identity)) = project_file_identity_if_present(target) else { + return false; + }; + let Ok(Some(journal_identity)) = project_file_identity_if_present(&journal_target) else { + return false; + }; + target_identity == journal_identity + && fs::canonicalize(target).ok() == fs::canonicalize(journal_target).ok() +} + +/// Repairs one durable, adjacent publication journal when its target is selected again. +/// +/// Security Notes: journal names are derived from the selected target and stage names are generated +/// UUID-based same-directory names; target, journal, and stage paths must stay regular non-link files; +/// journal reads use the bounded no-follow project reader; unrecognized identity pairs fail closed. +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +pub(crate) fn recover_project_publication(target: &Path) -> Result<(), String> { + let parent = project_parent(target); + let prepared_path = publication_journal_path(target, false)?; + let published_path = publication_journal_path(target, true)?; + let prepared_exists = recovery_artifact_exists(&prepared_path)?; + let published_exists = recovery_artifact_exists(&published_path)?; + if prepared_exists && published_exists { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + let Some((journal_path, published)) = (if prepared_exists { + Some((prepared_path, false)) + } else if published_exists { + Some((published_path, true)) + } else { + None + }) else { + return Ok(()); + }; + if !project_parent_chain_is_safe(parent) { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + let metadata = fs::symlink_metadata(&journal_path) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + if !metadata_is_regular_project_file(&metadata) { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + let content = read_project_file_with_opener( + &journal_path, + open_project_file, + MAX_RECOVERY_JOURNAL_BYTES, + PROJECT_RECOVERY_ERROR, + ) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + let journal: PublicationJournal = + serde_json::from_str(&content).map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + if !journal_target_matches(target, parent, &journal.target_name) { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + if journal.version != 1 + || !generated_stage_name(&journal.candidate_name) + || !generated_stage_name(&journal.displaced_name) + { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + let candidate_stage = path_from_journal_name(parent, &journal.candidate_name) + .ok_or_else(|| PROJECT_RECOVERY_ERROR.to_string())?; + let displaced = path_from_journal_name(parent, &journal.displaced_name) + .ok_or_else(|| PROJECT_RECOVERY_ERROR.to_string())?; + recover_publication_state( + target, + &journal_path, + &journal, + &candidate_stage, + &displaced, + published, + )?; + Ok(()) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] +pub(crate) fn recover_project_publication(_target: &Path) -> Result<(), String> { + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn replace_existing_project_file( + stage: &Path, + target: &Path, + expected: &ProjectFileIdentity, +) -> Result<(), String> { + let candidate = match project_file_identity(stage) { + Ok(candidate) => candidate, + Err(error) => { + remove_stage(stage); + return Err(error); + } + }; + let journal = match create_publication_journal(target, stage, stage, expected, &candidate) { + Ok(journal) => journal, + Err(error) => { + remove_stage(stage); + return Err(error); + } + }; + if rename_exchange(stage, target).is_err() { + remove_stage(stage); + remove_stage(&journal); + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + + let displaced = project_file_identity(stage); + if displaced.as_ref().is_ok_and(|identity| identity == expected) { + return finish_successful_publication(&journal, stage, target); + } + + let target_is_candidate = + project_file_identity(target).is_ok_and(|identity| identity == candidate); + if target_is_candidate && rename_exchange(stage, target).is_ok() { + let _ = finish_rolled_back_publication(stage, &journal, target); + } + Err(PROJECT_PUBLISH_ERROR.to_string()) +} + +#[cfg(windows)] +pub(crate) fn replace_existing_project_file( + stage: &Path, + target: &Path, + expected: &ProjectFileIdentity, +) -> Result<(), String> { + let candidate = match project_file_identity(stage) { + Ok(candidate) => candidate, + Err(error) => { + remove_stage(stage); + return Err(error); + } + }; + let backup = staging_path(target)?; + let journal = match create_publication_journal(target, stage, &backup, expected, &candidate) { + Ok(journal) => journal, + Err(error) => { + remove_stage(stage); + return Err(error); + } + }; + if replace_file_with_backup(target, stage, &backup).is_err() { + remove_stage(stage); + remove_stage(&journal); + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + + let displaced = project_file_identity(&backup); + if displaced.as_ref().is_ok_and(|identity| identity == expected) { + return finish_successful_publication(&journal, &backup, target); + } + + let target_is_candidate = + project_file_identity(target).is_ok_and(|identity| identity == candidate); + if target_is_candidate && replace_file_with_backup(target, &backup, stage).is_ok() { + let _ = finish_rolled_back_publication(stage, &journal, target); + } + Err(PROJECT_PUBLISH_ERROR.to_string()) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] +pub(crate) fn replace_existing_project_file( + stage: &Path, + _target: &Path, + _expected: &ProjectFileIdentity, +) -> Result<(), String> { + remove_stage(stage); + Err(PROJECT_PUBLISH_ERROR.to_string()) +} + +#[cfg(any(target_os = "macos", test))] +pub(crate) fn trusted_macos_root_alias_target(path: &Path) -> Option<&'static Path> { + match path.to_str()? { + "/etc" => Some(Path::new("/private/etc")), + "/tmp" => Some(Path::new("/private/tmp")), + "/var" => Some(Path::new("/private/var")), + _ => None, + } +} + +#[cfg(target_os = "macos")] +fn metadata_is_trusted_macos_root_directory_alias(path: &Path, metadata: &fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + + let Some(expected_target) = trusted_macos_root_alias_target(path) else { + return false; + }; + + metadata.file_type().is_symlink() + && metadata.uid() == 0 + && path.parent() == Some(Path::new("/")) + && fs::canonicalize(path).is_ok_and(|resolved| resolved == expected_target) + && fs::symlink_metadata(expected_target) + .is_ok_and(|target_metadata| metadata_is_safe_project_directory(&target_metadata)) +} + +#[cfg(not(target_os = "macos"))] +fn metadata_is_trusted_macos_root_directory_alias(_path: &Path, _metadata: &fs::Metadata) -> bool { + false +} + +fn project_parent_chain_is_safe(parent: &Path) -> bool { + parent + .ancestors() + .filter(|ancestor| !ancestor.as_os_str().is_empty()) + .all(|ancestor| { + fs::symlink_metadata(ancestor).is_ok_and(|metadata| { + metadata_is_safe_project_directory(&metadata) + || metadata_is_trusted_macos_root_directory_alias(ancestor, &metadata) + }) + }) +} + +fn read_project_file_with_opener( + target: &Path, + open_file: F, + max_bytes: usize, + too_large_error: &str, +) -> Result +where + F: FnOnce(&Path) -> std::io::Result, +{ + let before = fs::symlink_metadata(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; + if !metadata_is_regular_project_file(&before) { + return Err(PROJECT_READ_ERROR.to_string()); + } + + #[cfg(windows)] + let before_file = { + let file = open_project_file(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; + let metadata = file + .metadata() + .map_err(|_| PROJECT_READ_ERROR.to_string())?; + if !metadata_is_regular_project_file(&metadata) { + return Err(PROJECT_READ_ERROR.to_string()); + } + file + }; + + let file = open_file(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; + let opened = file + .metadata() + .map_err(|_| PROJECT_READ_ERROR.to_string())?; + let after = fs::symlink_metadata(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; + if !metadata_is_regular_project_file(&opened) || !metadata_is_regular_project_file(&after) { + return Err(PROJECT_READ_ERROR.to_string()); + } + + #[cfg(unix)] + if !same_file_identity(&before, &opened) || !same_file_identity(&opened, &after) { + return Err(PROJECT_READ_ERROR.to_string()); + } + + #[cfg(windows)] + { + let after_file = open_project_file(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; + let after_opened = after_file + .metadata() + .map_err(|_| PROJECT_READ_ERROR.to_string())?; + if !metadata_is_regular_project_file(&after_opened) { + return Err(PROJECT_READ_ERROR.to_string()); + } + + let before_identity = + windows_file_identity(&before_file).map_err(|_| PROJECT_READ_ERROR.to_string())?; + let opened_identity = + windows_file_identity(&file).map_err(|_| PROJECT_READ_ERROR.to_string())?; + let after_identity = + windows_file_identity(&after_file).map_err(|_| PROJECT_READ_ERROR.to_string())?; + if before_identity != opened_identity || opened_identity != after_identity { + return Err(PROJECT_READ_ERROR.to_string()); + } + } + + #[cfg(not(any(unix, windows)))] + return Err(PROJECT_READ_ERROR.to_string()); + + let mut reader = file.take((max_bytes + 1) as u64); + let mut bytes = Vec::new(); + reader + .read_to_end(&mut bytes) + .map_err(|_| PROJECT_READ_ERROR.to_string())?; + if bytes.len() > max_bytes { + return Err(too_large_error.to_string()); + } + String::from_utf8(bytes).map_err(|_| PROJECT_READ_ERROR.to_string()) +} + +/// Reads one project through a bounded, path-stable native file handle. +/// +/// The selected path must name the same regular file before the open, on the opened handle, and +/// immediately after the open. Linux and macOS acquire the handle with no-follow plus non-blocking +/// flags before comparing device/inode identity, so a last-component symlink swap cannot redirect +/// handle acquisition and a special-file swap cannot block the UI thread. Windows opens reparse +/// points without following them, rejects reparse handles, and compares the volume serial number plus +/// file index returned for native handles before, during, and after acquisition. Other Unix targets +/// fail closed until their no-follow open contract is explicitly modeled. The reader remains capped +/// at `MAX_PROJECT_FILE_BYTES + 1`; backup rotation and migration semantics remain later #962 work. +pub(crate) fn read_project_file(target: &Path) -> Result { + read_project_file_with_opener( + target, + open_project_file, + MAX_PROJECT_FILE_BYTES, + PROJECT_TOO_LARGE_ERROR, + ) +} + +/// Publishes a selected project only after its complete bounded bytes are staged and synced. +/// +/// The selected parent and each lexical ancestor must be a real directory rather than a +/// symlink/reparse point before any staging artifact is created. On macOS, only the canonical +/// root-owned `/etc`, `/tmp`, and `/var` aliases are admitted, and each must resolve to its exact +/// `/private` system directory; arbitrary root-level aliases remain fail-closed. This rejects +/// user-writable static ancestor-link redirection without breaking normal paths below macOS system +/// aliases. `File::create_new` makes staging non-clobbering. If the selected target exists, its native +/// identity and permissions are captured from the same pre-staging metadata snapshot on Unix; the +/// staged inode receives the existing read/write permission bits after its bytes are written and +/// before it is synced; executable and special bits are never copied to project data. +/// Linux and macOS then atomically exchange the synced staging inode with the target and accept the +/// publication only when the displaced inode still matches that captured identity; a mismatch is +/// exchanged back before returning an error. Windows uses `ReplaceFileW` with a unique same-directory +/// backup, validates the displaced file's native identity, and restores it when the snapshot no longer +/// matches. For a destination that was absent at the snapshot, a hard link is attempted first; Linux +/// then uses `renameat2(RENAME_NOREPLACE)`, macOS uses `renamex_np(RENAME_EXCL)`, and Windows uses +/// `MoveFileExW` without `MOVEFILE_REPLACE_EXISTING` so a concurrently appearing destination is not +/// clobbered. A newly created final directory entry is part of the success contract: Unix fsyncs its +/// parent before first-save success is acknowledged, while Windows keeps the existing native +/// write-through publication semantics. If that durability step fails after the complete target is +/// visible, the target is not deleted or truncated and the caller receives the safe publication error. +/// Filesystems without the required native primitive fail closed. These checks do not claim +/// descriptor-bound protection for a parent-chain swap or authority before the first post-dialog +/// identity snapshot. A durable adjacent journal repairs an interrupted mismatch rollback the next +/// time the same target is selected; global startup scanning and backup rotation remain #962 work. +pub(crate) fn publish_new_project_file(target: &Path, content: &[u8]) -> Result<(), String> { + publish_new_project_file_with_linker(target, content, |source, destination| { + fs::hard_link(source, destination) + }) +} + +pub(crate) fn publish_new_project_file_with_linker( + target: &Path, + content: &[u8], + link: F, +) -> Result<(), String> +where + F: FnOnce(&Path, &Path) -> std::io::Result<()>, +{ + publish_new_project_file_with_linker_and_directory_sync( + target, + content, + link, + sync_parent_directory, + ) +} + +pub(crate) fn publish_new_project_file_with_linker_and_directory_sync( + target: &Path, + content: &[u8], + link: F, + mut sync_parent: S, +) -> Result<(), String> +where + F: FnOnce(&Path, &Path) -> std::io::Result<()>, + S: FnMut(&Path) -> std::io::Result<()>, +{ + if content.is_empty() { + return Err(PROJECT_STAGE_ERROR.to_string()); + } + if content.len() > MAX_PROJECT_FILE_BYTES { + return Err(PROJECT_TOO_LARGE_ERROR.to_string()); + } + + let parent = project_parent(target); + if !project_parent_chain_is_safe(parent) { + return Err(PROJECT_STAGE_ERROR.to_string()); + } + + let expected_target = match fs::symlink_metadata(target) { + Ok(metadata) => { + if !metadata_is_regular_project_file(&metadata) { + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + #[cfg(unix)] + let identity = project_file_identity_from_metadata(&metadata); + #[cfg(not(unix))] + let identity = project_file_identity(target)?; + Some((identity, metadata.permissions())) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(_) => return Err(PROJECT_PUBLISH_ERROR.to_string()), + }; + + let stage = staging_path(target)?; + let mut staged = File::create_new(&stage).map_err(|_| PROJECT_STAGE_ERROR.to_string())?; + if staged.write_all(content).is_err() { + drop(staged); + remove_stage(&stage); + return Err(PROJECT_STAGE_ERROR.to_string()); + } + #[cfg(unix)] + if let Some((_, permissions)) = expected_target.as_ref() { + use std::os::unix::fs::PermissionsExt; + + let data_permissions = fs::Permissions::from_mode(permissions.mode() & 0o666); + if staged.set_permissions(data_permissions).is_err() { + drop(staged); + remove_stage(&stage); + return Err(PROJECT_STAGE_ERROR.to_string()); + } + } + if staged.sync_all().is_err() { + drop(staged); + remove_stage(&stage); + return Err(PROJECT_STAGE_ERROR.to_string()); + } + drop(staged); + + if let Some((expected, _)) = expected_target { + return replace_existing_project_file(&stage, target, &expected); + } + + if let Err(error) = link(&stage, target) { + if error.kind() == std::io::ErrorKind::AlreadyExists { + remove_stage(&stage); + return Err(PROJECT_EXISTS_ERROR.to_string()); + } + + match rename_noreplace(&stage, target) { + Ok(()) => { + sync_parent(parent).map_err(|_| PROJECT_PUBLISH_ERROR.to_string())?; + return Ok(()); + } + Err(publish_error) if publish_error.kind() == std::io::ErrorKind::AlreadyExists => { + remove_stage(&stage); + return Err(PROJECT_EXISTS_ERROR.to_string()); + } + Err(_) => { + remove_stage(&stage); + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + } + } + + // The final hard-link directory entry must be durable before staging cleanup can be acknowledged. + // A failed sync leaves both complete names intact and reports a publication failure; it never + // deletes the buyer-visible target or pretends that crash-safe first-save durability was achieved. + sync_parent(parent).map_err(|_| PROJECT_PUBLISH_ERROR.to_string())?; + remove_stage(&stage); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + publish_new_project_file, read_project_file, read_project_file_with_opener, + MAX_PROJECT_FILE_BYTES, PROJECT_TOO_LARGE_ERROR, + }; + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn test_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "bandscope-project-persistence-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).expect("test directory should be created"); + path + } + + #[cfg(any(target_os = "linux", target_os = "macos", windows))] + #[test] + fn native_no_replace_rename_preserves_a_competing_destination() { + let root = test_dir("rename-noreplace-conflict"); + let stage = root.join("candidate.stage"); + let target = root.join("setlist.bscope"); + let candidate = br#"{\"id\":\"candidate\"}"#; + let competing = br#"{\"id\":\"competing\"}"#; + fs::write(&stage, candidate).expect("candidate stage should be written"); + fs::write(&target, competing).expect("competing target should be written"); + + let error = super::rename_noreplace(&stage, &target) + .expect_err("native no-replace rename must refuse an existing target"); + + assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!( + fs::read(&target).expect("competing target should remain readable"), + competing + ); + assert_eq!( + fs::read(&stage).expect("candidate stage should remain after conflict"), + candidate + ); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[cfg(any(target_os = "linux", target_os = "macos", windows))] + #[test] + fn native_no_replace_rename_publishes_when_destination_is_absent() { + let root = test_dir("rename-noreplace-new"); + let stage = root.join("candidate.stage"); + let target = root.join("setlist.bscope"); + let candidate = br#"{\"id\":\"candidate\"}"#; + fs::write(&stage, candidate).expect("candidate stage should be written"); + + super::rename_noreplace(&stage, &target) + .expect("native no-replace rename should publish an absent target"); + + assert_eq!( + fs::read(&target).expect("published target should be readable"), + candidate + ); + assert!(!stage.exists()); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[test] + fn publishes_complete_new_project_without_stage_artifacts() { + let root = test_dir("new"); + let target = root.join("setlist.bscope"); + let content = br#"{\"id\":\"song-1\"}"#; + + publish_new_project_file(&target, content).expect("new project should publish safely"); + + assert_eq!( + fs::read(&target).expect("published project should be readable"), + content + ); + let names = fs::read_dir(&root) + .expect("test directory should be readable") + .map(|entry| { + entry + .expect("directory entry should be readable") + .file_name() + }) + .collect::>(); + assert_eq!(names, vec![target.file_name().unwrap().to_os_string()]); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[test] + fn stages_a_project_with_a_max_length_file_name() { + let root = test_dir("max-name"); + let target = root.join("a".repeat(255)); + + publish_new_project_file(&target, br#"{\"id\":\"song-1\"}"#) + .expect("a max-length target name should still be stageable"); + + assert!(target.is_file()); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[cfg(any(target_os = "macos", windows))] + #[test] + fn recovers_a_case_alias_of_the_selected_target() { + let root = test_dir("case-alias"); + let target = root.join("Setlist.bscope"); + let alias = root.join("setlist.bscope"); + let stage = super::staging_path(&target).expect("candidate stage path should be derivable"); + let displaced = + super::staging_path(&target).expect("displaced stage path should be derivable"); + let original = br#"{\"id\":\"original\"}"#; + let candidate = br#"{\"id\":\"candidate\"}"#; + fs::write(&target, original).expect("original fixture should be written"); + if fs::symlink_metadata(&alias).is_err() { + fs::remove_dir_all(root).expect("case-sensitive fixture directory should be removable"); + return; + } + fs::write(&stage, candidate).expect("candidate fixture should be written"); + + let expected = super::project_file_identity(&target) + .expect("original target identity should be capturable"); + let candidate_identity = + super::project_file_identity(&stage).expect("candidate identity should be capturable"); + let journal = super::create_publication_journal( + &target, + &stage, + &displaced, + &expected, + &candidate_identity, + ) + .expect("the recovery journal should be durable before publication"); + fs::rename(&target, &displaced).expect("original target should be displaced"); + fs::rename(&stage, &target).expect("candidate should become the target"); + + super::recover_project_publication(&alias) + .expect("recovery should resolve the case-insensitive target alias"); + + assert_eq!(fs::read(&target).expect("recovered target should be readable"), original); + assert!(!journal.exists(), "the recovered journal should be removed"); + assert!(!displaced.exists(), "the displaced artifact should be removed"); + fs::remove_dir_all(root).expect("fixture directory should be removable"); + } + + #[cfg(any(target_os = "linux", target_os = "macos", windows))] + #[test] + fn cleans_a_completed_rollback_after_process_interruption() { + let root = test_dir("completed-rollback"); + let target = root.join("setlist.bscope"); + let stage = super::staging_path(&target).expect("candidate stage path should be derivable"); + let displaced = if cfg!(windows) { + super::staging_path(&target).expect("backup path should be derivable") + } else { + stage.clone() + }; + let original = br#"{\"id\":\"original\"}"#; + let candidate = br#"{\"id\":\"candidate\"}"#; + let competing = br#"{\"id\":\"competing\"}"#; + fs::write(&target, original).expect("original fixture should be written"); + fs::write(&stage, candidate).expect("candidate fixture should be written"); + let expected = super::project_file_identity(&target) + .expect("original target identity should be capturable"); + let candidate_identity = + super::project_file_identity(&stage).expect("candidate identity should be capturable"); + let journal = super::create_publication_journal( + &target, + &stage, + &displaced, + &expected, + &candidate_identity, + ) + .expect("the recovery journal should be durable before publication"); + + fs::remove_file(&target).expect("the original target should be replaced by the racer"); + fs::write(&target, competing).expect("the competing target should be written"); + super::recover_project_publication(&target) + .expect("completed rollback state should be safely cleaned"); + + assert_eq!(fs::read(&target).expect("competing target should remain readable"), competing); + assert!(!stage.exists(), "the owned candidate should be removed"); + assert!(!displaced.exists(), "the consumed rollback artifact should be absent"); + assert!(!journal.exists(), "the completed rollback journal should be removed"); + fs::remove_dir_all(root).expect("fixture directory should be removable"); + } + + #[test] + fn invalid_replacement_does_not_clobber_an_existing_known_good_project() { + let root = test_dir("existing-invalid"); + let target = root.join("setlist.bscope"); + let known_good = br#"{\"id\":\"known-good\"}"#; + fs::write(&target, known_good).expect("fixture should be written"); + + let error = publish_new_project_file(&target, b"") + .expect_err("invalid replacement must fail before publication"); + + assert_eq!(error, "Could not stage the project safely."); + assert_eq!( + fs::read(&target).expect("known-good project should remain"), + known_good + ); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[cfg(unix)] + #[test] + fn refuses_to_replace_a_symlink_target() { + use std::os::unix::fs::symlink; + + let root = test_dir("save-symlink"); + let external = root.join("external.bscope"); + let selected = root.join("selected.bscope"); + let known_good = br#"{\"id\":\"external-known-good\"}"#; + fs::write(&external, known_good).expect("external fixture should be written"); + symlink(&external, &selected).expect("fixture symlink should be created"); + + let error = publish_new_project_file(&selected, br#"{\"id\":\"replacement\"}"#) + .expect_err("a selected symlink must not be replaced as project authority"); + + assert_eq!(error, "Could not publish the project safely."); + assert_eq!( + fs::read(&external).expect("external project should remain readable"), + known_good + ); + assert!(fs::symlink_metadata(&selected) + .expect("selected symlink should remain") + .file_type() + .is_symlink()); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[test] + fn rejects_project_bytes_beyond_the_existing_load_limit_before_staging() { + let root = test_dir("oversize"); + let target = root.join("setlist.bscope"); + let content = vec![b'x'; MAX_PROJECT_FILE_BYTES + 1]; + + let error = publish_new_project_file(&target, &content) + .expect_err("oversized project should fail before publication"); + + assert_eq!(error, "Project file is too large (exceeds 5 MiB limit)"); + assert!(!target.exists()); + assert_eq!( + fs::read_dir(&root) + .expect("directory should be readable") + .count(), + 0 + ); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[test] + fn reads_project_content_within_the_existing_load_limit() { + let root = test_dir("read-valid"); + let target = root.join("setlist.bscope"); + let content = r#"{\"id\":\"song-1\"}"#; + fs::write(&target, content).expect("fixture should be written"); + + assert_eq!( + read_project_file(&target).expect("bounded project should be readable"), + content + ); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[cfg(unix)] + #[test] + fn rejects_project_symlink_before_reading_external_content() { + use std::os::unix::fs::symlink; + + let root = test_dir("read-symlink"); + let external = root.join("external.json"); + let selected = root.join("selected.bscope"); + fs::write(&external, r#"{\"id\":\"external\"}"#).expect("external fixture should be written"); + symlink(&external, &selected).expect("fixture symlink should be created"); + + let error = read_project_file(&selected) + .expect_err("a selected symlink must not redirect the project reader"); + + assert_eq!(error, "Failed to read file"); + fs::remove_dir_all(root).expect("test fixture should be removable"); + } + + #[test] + fn rejects_project_replaced_between_preflight_and_open() { + let root = test_dir("read-swap"); + let selected = root.join("selected.bscope"); + let replacement = root.join("replacement.bscope"); + let parked = root.join("parked.bscope"); + fs::write(&selected, r#"{\"id\":\"selected\"}"#).expect("selected fixture should be written"); + fs::write(&replacement, r#"{\"id\":\"replacement-with-different-bytes\"}"#) + .expect("replacement fixture should be written"); + + let error = read_project_file_with_opener(&selected, |path| { + fs::rename(path, &parked)?; + fs::rename(&replacement, path)?; + fs::File::open(path) + }, MAX_PROJECT_FILE_BYTES, PROJECT_TOO_LARGE_ERROR) + .expect_err("a path replacement between preflight and open must fail closed"); + + assert_eq!(error, "Failed to read file"); + fs::remove_dir_all(root).expect("test fixture should be removable"); + } + + #[test] + fn rejects_oversized_project_during_the_read_itself() { + let root = test_dir("read-oversize"); + let target = root.join("setlist.bscope"); + let file = fs::File::create(&target).expect("fixture should be created"); + file.set_len((MAX_PROJECT_FILE_BYTES + 1) as u64) + .expect("sparse oversize fixture should be sized"); + drop(file); + + let error = read_project_file(&target) + .expect_err("the project reader must enforce the byte ceiling while reading"); + + assert_eq!(error, "Project file is too large (exceeds 5 MiB limit)"); + fs::remove_dir_all(root).expect("test fixture should be removable"); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn recovers_an_interrupted_existing_project_publication() { + let root = test_dir("recovery"); + let target = root.join("setlist.bscope"); + let stage = root.join(format!(".bandscope-stage-{}.stage", uuid::Uuid::new_v4())); + let known_good = br#"{\"id\":\"known-good\"}"#; + let candidate = br#"{\"id\":\"candidate\"}"#; + fs::write(&target, known_good).expect("known-good fixture should be written"); + fs::write(&stage, candidate).expect("candidate fixture should be written"); + + let expected = super::project_file_identity(&target).expect("target identity should exist"); + let candidate_identity = + super::project_file_identity(&stage).expect("candidate identity should exist"); + let journal = super::create_publication_journal( + &target, + &stage, + &stage, + &expected, + &candidate_identity, + ) + .expect("the recovery journal should be durable before publication"); + super::rename_exchange(&stage, &target).expect("fixture should model interrupted exchange"); + + super::recover_project_publication(&target) + .expect("the next selection should recover the known-good target"); + + assert_eq!(fs::read(&target).expect("target should remain readable"), known_good); + assert!(!stage.exists(), "the interrupted candidate should be cleaned"); + assert!(!journal.exists(), "the recovery journal should be cleaned"); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn recovers_a_competing_file_preserved_by_an_interrupted_exchange() { + let root = test_dir("raced-recovery"); + let target = root.join("setlist.bscope"); + let parked = root.join("parked-authorized.bscope"); + let stage = root.join(format!(".bandscope-stage-{}.stage", uuid::Uuid::new_v4())); + let authorized = br#"{\"id\":\"authorized\"}"#; + let racer = br#"{\"id\":\"racer\"}"#; + let candidate = br#"{\"id\":\"candidate\"}"#; + fs::write(&target, authorized).expect("authorized fixture should be written"); + fs::write(&stage, candidate).expect("candidate fixture should be written"); + + let expected = super::project_file_identity(&target).expect("target identity should exist"); + let candidate_identity = + super::project_file_identity(&stage).expect("candidate identity should exist"); + let journal = super::create_publication_journal( + &target, + &stage, + &stage, + &expected, + &candidate_identity, + ) + .expect("the recovery journal should be durable before publication"); + fs::rename(&target, &parked).expect("authorized target should be parked by the racer"); + fs::write(&target, racer).expect("racer should win the target pathname"); + super::rename_exchange(&stage, &target).expect("fixture should model interrupted exchange"); + + super::recover_project_publication(&target) + .expect("the preserved competing file should be restored"); + + assert_eq!(fs::read(&target).expect("target should remain readable"), racer); + assert_eq!( + fs::read(&parked).expect("the authorized file should remain readable"), + authorized + ); + assert!(!stage.exists(), "the candidate should be cleaned"); + assert!(!journal.exists(), "the recovery journal should be cleaned"); + fs::remove_dir_all(root).expect("fixture directory should be removable"); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn cleans_a_durable_published_journal_after_target_exchange() { + let root = test_dir("published-recovery"); + let target = root.join("setlist.bscope"); + let stage = root.join(format!(".bandscope-stage-{}.stage", uuid::Uuid::new_v4())); + let known_good = br#"{\"id\":\"known-good\"}"#; + let candidate = br#"{\"id\":\"candidate\"}"#; + fs::write(&target, known_good).expect("known-good fixture should be written"); + fs::write(&stage, candidate).expect("candidate fixture should be written"); + + let expected = super::project_file_identity(&target).expect("target identity should exist"); + let candidate_identity = + super::project_file_identity(&stage).expect("candidate identity should exist"); + let prepared = super::create_publication_journal( + &target, + &stage, + &stage, + &expected, + &candidate_identity, + ) + .expect("the recovery journal should be durable before publication"); + super::rename_exchange(&stage, &target).expect("fixture should model target exchange"); + let published = super::publication_journal_path(&target, true) + .expect("published journal path should be derivable"); + super::rename_noreplace(&prepared, &published) + .expect("fixture should model the durable published marker"); + + super::recover_project_publication(&target) + .expect("the next selection should clean the completed publication"); + + assert_eq!(fs::read(&target).expect("target should remain readable"), candidate); + assert!(!stage.exists(), "the displaced known-good stage should be cleaned"); + assert!(!published.exists(), "the published journal should be cleaned"); + fs::remove_dir_all(root).expect("fixture directory should be removable"); + } + + #[cfg(any(target_os = "linux", target_os = "macos", windows))] + #[test] + fn unrelated_incomplete_journals_do_not_block_project_recovery() { + let root = test_dir("unrelated-recovery"); + let target = root.join("selected.bscope"); + let unrelated = root.join("other.bscope"); + fs::write(&target, br#"{\"id\":\"selected\"}"#).expect("target fixture should be written"); + fs::write( + super::publication_journal_path(&unrelated, false) + .expect("unrelated journal path should be derivable"), + b"{", + ) + .expect("the incomplete unrelated journal should be written"); + + super::recover_project_publication(&target) + .expect("an unrelated incomplete journal must not block recovery"); + assert_eq!( + fs::read(&target).expect("target should remain readable"), + br#"{\"id\":\"selected\"}"# + ); + fs::remove_dir_all(root).expect("fixture directory should be removable"); + } + + #[test] + fn save_project_command_routes_through_safe_publisher() { + let main_source = include_str!("main.rs"); + + assert!( + main_source.contains("project_persistence::publish_new_project_file"), + "the Tauri save command must use the staged project publisher" + ); + assert!( + !main_source.contains("std::fs::write(path, content)"), + "the Tauri save command must not truncate the selected destination directly" + ); + } + + #[test] + fn load_project_command_routes_through_bounded_reader() { + let main_source = include_str!("main.rs"); + + assert!( + main_source.contains("project_persistence::read_project_file(&path)"), + "the Tauri load command must enforce the byte ceiling while reading" + ); + assert!( + !main_source.contains("std::fs::read_to_string(path)"), + "the Tauri load command must not allocate through an unbounded second read" + ); + } +} diff --git a/apps/desktop/src-tauri/src/project_root.rs b/apps/desktop/src-tauri/src/project_root.rs new file mode 100644 index 000000000..86c5f216e --- /dev/null +++ b/apps/desktop/src-tauri/src/project_root.rs @@ -0,0 +1,59 @@ +use bandscope_desktop_core::is_valid_project_id; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +const PROJECT_ROOT_ERROR: &str = "Could not prepare the local project workspace."; + +#[cfg(windows)] +const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + +/// Return whether an existing Windows project directory is a real directory, not a reparse point. +#[cfg(windows)] +fn metadata_is_safe_existing_project_directory(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + + metadata.is_dir() && metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 +} + +/// Return whether an existing non-Windows project directory is a real directory, not a symlink. +#[cfg(not(windows))] +fn metadata_is_safe_existing_project_directory(metadata: &fs::Metadata) -> bool { + metadata.is_dir() && !metadata.file_type().is_symlink() +} + +/// Resolve one already-provisioned app-local project directory without creating it. +/// +/// Security Notes: `project_id` is validated before joining. The app-local base +/// itself and the final project directory must already exist as real directories +/// rather than symlinks or Windows reparse points. Rejecting a linked base before +/// joining prevents a stable app-local path name from redirecting reopen into a +/// different filesystem subtree. This read-side resolver never calls +/// `create_dir_all`, so a missing or replaced project root cannot be silently +/// provisioned during reopen. Descriptor-bound authority for every ancestor and +/// concurrent parent replacement remains a separate platform-hardening +/// requirement. +pub(crate) fn resolve_existing_project_root( + base_root: &Path, + project_id: &str, +) -> Result { + if !is_valid_project_id(project_id) { + return Err(PROJECT_ROOT_ERROR.to_string()); + } + + let base_metadata = + fs::symlink_metadata(base_root).map_err(|_| PROJECT_ROOT_ERROR.to_string())?; + if !metadata_is_safe_existing_project_directory(&base_metadata) { + return Err(PROJECT_ROOT_ERROR.to_string()); + } + + let project_root = base_root.join(project_id); + let metadata = fs::symlink_metadata(&project_root) + .map_err(|_| PROJECT_ROOT_ERROR.to_string())?; + if !metadata_is_safe_existing_project_directory(&metadata) { + return Err(PROJECT_ROOT_ERROR.to_string()); + } + + Ok(project_root) +} diff --git a/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs b/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs new file mode 100644 index 000000000..52bdec5f5 --- /dev/null +++ b/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs @@ -0,0 +1,113 @@ +#[path = "../src/analysis_source.rs"] +mod analysis_source; + +use analysis_source::revalidate_local_audio_bootstrap_for_analysis; +use bandscope_desktop_core::{ + build_local_audio_publication_identity, LocalAudioCopyReceipt, LocalAudioSourcePayload, + ProjectBootstrapSummaryPayload, +}; +use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +const MAIN_SOURCE: &str = include_str!("../src/main.rs"); +const WAV_BYTES: &[u8] = b"RIFF\x04\x00\x00\x00WAVE"; +const WAV_SHA256: &str = "1fe5a351bf0314c8a1840b023fd1e4cab3f0f123468940c241bd7bf20e989ab8"; + +fn unique_project_root() -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + std::env::temp_dir() + .join(format!("bandscope-analysis-dispatch-{suffix}")) + .join("project-1-1") +} + +fn bootstrap(project_root: &std::path::Path) -> ProjectBootstrapSummaryPayload { + ProjectBootstrapSummaryPayload { + project_id: "project-1-1".to_string(), + source_mode: "reference".to_string(), + project_root: project_root.to_string_lossy().into_owned(), + cache_root: project_root.join("cache").to_string_lossy().into_owned(), + temp_root: project_root.join("temp").to_string_lossy().into_owned(), + source: LocalAudioSourcePayload { + source_path: project_root + .join("source.wav") + .to_string_lossy() + .into_owned(), + file_name: "rehearsal.wav".to_string(), + extension: "wav".to_string(), + file_size_bytes: WAV_BYTES.len() as u64, + }, + } +} + +fn retained_identity() -> bandscope_desktop_core::LocalAudioPublicationIdentity { + build_local_audio_publication_identity( + "project-1-1", + "wav", + &LocalAudioCopyReceipt { + file_size_bytes: WAV_BYTES.len() as u64, + content_sha256: WAV_SHA256.to_string(), + }, + ) + .expect("fixture identity should be valid") +} + +#[test] +fn analysis_dispatch_revalidates_current_app_owned_bytes() { + let project_root = unique_project_root(); + fs::create_dir_all(&project_root).expect("project root should be created"); + let source_path = project_root.join("source.wav"); + fs::write(&source_path, WAV_BYTES).expect("source fixture should be written"); + + let refreshed = revalidate_local_audio_bootstrap_for_analysis( + &bootstrap(&project_root), + &retained_identity(), + |path| fs::File::open(path), + ) + .expect("unchanged app-owned bytes should regain dispatch authority"); + assert_eq!(refreshed.source.source_path, source_path.to_string_lossy()); + assert_eq!(refreshed.source.file_size_bytes, WAV_BYTES.len() as u64); + assert_eq!( + PathBuf::from(&refreshed.cache_root), + project_root.join("cache").join(WAV_SHA256) + ); + assert_eq!( + PathBuf::from(&refreshed.temp_root), + project_root.join("temp").join(WAV_SHA256) + ); + + let mut changed = WAV_BYTES.to_vec(); + let last_byte = changed.len() - 1; + changed[last_byte] = b'A'; + fs::write(&source_path, changed).expect("same-size mutation should be written"); + + let error = revalidate_local_audio_bootstrap_for_analysis( + &bootstrap(&project_root), + &retained_identity(), + |path| fs::File::open(path), + ) + .expect_err("same-size mutation must fail before analysis dispatch"); + assert_eq!( + error, + "Analysis job source was not found. Choose local audio again." + ); + + fs::remove_dir_all(project_root.parent().expect("project root should have parent")) + .expect("fixture should be removed"); +} + +#[test] +fn analysis_process_receives_native_evidence_without_global_environment_mutation() { + assert!(MAIN_SOURCE.contains("BANDSCOPE_ADMITTED_AUDIO_BYTES")); + assert!(MAIN_SOURCE.contains("BANDSCOPE_ADMITTED_AUDIO_SHA256")); + assert!(MAIN_SOURCE.contains(".env_remove(ADMITTED_AUDIO_BYTES_ENV)")); + assert!(MAIN_SOURCE.contains(".env_remove(ADMITTED_AUDIO_SHA256_ENV)")); + assert!(MAIN_SOURCE.contains("ADMITTED_AUDIO_BYTES_ENV,")); + assert!(MAIN_SOURCE.contains("ADMITTED_AUDIO_SHA256_ENV, &identity.content_sha256")); + assert!(!MAIN_SOURCE.contains("std::env::set_var(\"BANDSCOPE_ADMITTED_AUDIO_")); +} diff --git a/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs b/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs new file mode 100644 index 000000000..af3ce3839 --- /dev/null +++ b/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs @@ -0,0 +1,149 @@ +#[test] +fn local_audio_materializer_consumes_publication_bound_receipt() { + let source = include_str!("../src/main.rs"); + let materializer_start = source + .find("fn materialize_local_audio_source(") + .expect("desktop materializer must remain present"); + let materializer_tail = &source[materializer_start..]; + let materializer_end = materializer_tail + .find("\n}\n\nfn parse_request_payload") + .expect("materializer boundary must remain inspectable"); + let materializer = &materializer_tail[..materializer_end]; + + assert!( + materializer.contains("copy_bounded_local_audio_with_receipt"), + "production materialization must retain native size+SHA-256 staging evidence" + ); + assert!( + materializer.contains("verify_local_audio_publication_receipt"), + "production materialization must re-read the published app-owned source and bind it to the staging receipt" + ); + assert!( + !materializer.contains("copy_bounded_local_audio(source"), + "the compatibility byte-count-only adapter must not remain on the production publication path" + ); +} + +#[test] +fn local_audio_publication_must_not_overwrite_an_existing_source_name() { + let source = include_str!("../src/main.rs"); + let materializer_start = source + .find("fn materialize_local_audio_source(") + .expect("desktop materializer must remain present"); + let materializer_tail = &source[materializer_start..]; + let materializer_end = materializer_tail + .find("\n}\n\nfn parse_request_payload") + .expect("materializer boundary must remain inspectable"); + let materializer = &materializer_tail[..materializer_end]; + + assert!( + materializer.contains("std::fs::hard_link(&stage, &destination)"), + "publication must use an atomic no-clobber filesystem create instead of check-then-rename" + ); + assert!( + !materializer.contains("destination.exists()"), + "a preflight existence check is racy and must not authorize a later overwrite-capable rename" + ); + assert!( + !materializer.contains("std::fs::rename(&stage, &destination)"), + "overwrite-capable rename must not publish the immutable project source" + ); +} + +#[test] +fn local_audio_selection_retains_verified_path_free_identity_in_native_state() { + let source = include_str!("../src/main.rs"); + + assert!( + source.contains("struct LocalAudioPublicationIdentityState"), + "verified source identity must have a native-only state owner" + ); + assert!( + source.contains("build_local_audio_publication_identity(project_id, &extension, &receipt)"), + "the production materializer must derive persistence identity from the verified native receipt" + ); + assert!( + source.contains("store_local_audio_publication_identity(&publication_state, publication_identity)"), + "selection must retain native publication identity before returning bootstrap authority" + ); + assert!( + source.contains(".manage(LocalAudioPublicationIdentityState::default())"), + "the native publication identity state must be registered with the Tauri runtime" + ); +} + +#[test] +fn project_save_binds_only_explicit_project_id_to_retained_native_source_identity() { + let source = include_str!("../src/main.rs"); + let save_start = source + .find("fn save_project(") + .expect("native project save command must remain present"); + let save_tail = &source[save_start..]; + let save_end = save_tail + .find("\n}\n\n#[tauri::command]\nfn load_project") + .expect("save command boundary must remain inspectable"); + let save_command = &save_tail[..save_end]; + + assert!( + source.contains("fn project_document_with_retained_source_reference("), + "native persistence needs one explicit retained-identity adapter" + ); + assert!( + save_command.contains("project_id: Option"), + "renderer may submit only the already-minted project id as the save selector" + ); + assert!( + save_command.contains("publication_state: tauri::State<'_, LocalAudioPublicationIdentityState>"), + "save must read verified source identity from native state instead of renderer evidence" + ); + assert!( + save_command.contains("project_document_with_retained_source_reference("), + "save must inject the native source reference before project serialization" + ); + assert!( + !save_command.contains("source_reference = serde_json"), + "save must never reconstruct source identity from renderer JSON" + ); + assert!( + !source.contains("last_selected_project"), + "multiple project aggregates forbid a global last-selected shortcut" + ); +} + +#[test] +fn project_load_re_admits_persisted_source_before_returning_document() { + let source = include_str!("../src/main.rs"); + let load_start = source + .find("fn load_project(") + .expect("native project load command must remain present"); + let load_tail = &source[load_start..]; + let load_end = load_tail + .find("\n}\n\nfn scores_root_for_project") + .expect("load command boundary must remain inspectable"); + let load_command = &load_tail[..load_end]; + + assert!( + source.contains("fn restore_project_source_after_restart"), + "restart needs one native adapter that restores source authority from persisted evidence" + ); + assert!( + load_command.contains("app: tauri::AppHandle"), + "load must resolve the app-local project root inside the native boundary" + ); + assert!( + load_command.contains("state: tauri::State<'_, AppState>"), + "load must restore fresh native bootstrap state for the exact project aggregate" + ); + assert!( + load_command.contains("publication_state: tauri::State<'_, LocalAudioPublicationIdentityState>"), + "load must restore path-free publication identity only after re-admission" + ); + assert!( + load_command.contains("restore_project_source_after_restart("), + "a v3 source reference must be re-admitted before the loaded document is returned" + ); + assert!( + !load_command.contains("app_owned_root(&app, \"projects\""), + "restart must not provision a missing project directory while reading" + ); +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_atomic_publication.rs b/apps/desktop/src-tauri/tests/project_persistence_atomic_publication.rs new file mode 100644 index 000000000..67c71fe38 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_atomic_publication.rs @@ -0,0 +1,157 @@ +#[path = "../src/project_persistence.rs"] +mod project_persistence; + +use std::{ + cell::Cell, + fs, + io, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +fn test_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "bandscope-project-persistence-atomic-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).expect("test directory should be created"); + path +} + +fn stage_paths(root: &Path) -> Vec { + fs::read_dir(root) + .expect("test directory should be readable") + .filter_map(|entry| { + let path = entry.ok()?.path(); + let name = path.file_name()?.to_str()?; + name.starts_with(".bandscope-stage-").then_some(path) + }) + .collect() +} + +#[test] +fn hard_link_fallback_never_reserves_the_final_path_with_an_empty_file() { + let source = include_str!("../src/project_persistence.rs"); + + assert!( + !source.contains("File::create_new(target)") + && !source.contains("File::create_new(&target)"), + "hard-link fallback must not materialize an empty final-path placeholder before the staged project is atomically published" + ); +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[test] +fn hard_link_first_save_does_not_acknowledge_a_failed_parent_directory_sync() { + let root = test_dir("hard-link-dir-sync-failure"); + let target = root.join("setlist.bscope"); + let content = br#"{"id":"durable-candidate"}"#; + let sync_observed_published_target = Cell::new(false); + + let error = project_persistence::publish_new_project_file_with_linker_and_directory_sync( + &target, + content, + |source, destination| fs::hard_link(source, destination), + |parent| { + assert_eq!(parent, root.as_path()); + sync_observed_published_target.set( + fs::read(&target).is_ok_and(|published| published == content), + ); + Err(io::Error::new( + io::ErrorKind::Other, + "injected parent-directory sync failure", + )) + }, + ) + .expect_err("first-save success must wait for parent-directory durability"); + + assert_eq!(error, "Could not publish the project safely."); + assert!(sync_observed_published_target.get()); + assert_eq!( + fs::read(&target).expect("the fully published target must not be deleted on sync failure"), + content + ); + assert_eq!( + stage_paths(&root).len(), + 1, + "hard-link publication must not acknowledge staging cleanup before directory durability" + ); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[test] +fn no_replace_rename_first_save_does_not_acknowledge_a_failed_parent_directory_sync() { + let root = test_dir("rename-dir-sync-failure"); + let target = root.join("setlist.bscope"); + let content = br#"{"id":"rename-candidate"}"#; + let sync_observed_published_target = Cell::new(false); + + let error = project_persistence::publish_new_project_file_with_linker_and_directory_sync( + &target, + content, + |_source, _destination| { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "force native no-replace rename fallback", + )) + }, + |parent| { + assert_eq!(parent, root.as_path()); + sync_observed_published_target.set( + fs::read(&target).is_ok_and(|published| published == content), + ); + Err(io::Error::new( + io::ErrorKind::Other, + "injected parent-directory sync failure", + )) + }, + ) + .expect_err("rename publication must not report success before directory durability"); + + assert_eq!(error, "Could not publish the project safely."); + assert!(sync_observed_published_target.get()); + assert_eq!( + fs::read(&target).expect("the complete renamed target must survive a sync failure"), + content + ); + assert!( + stage_paths(&root).is_empty(), + "native rename consumes the staged path before the durability failure is reported" + ); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[test] +fn successful_first_save_syncs_the_parent_before_hard_link_stage_cleanup() { + let root = test_dir("dir-sync-success"); + let target = root.join("setlist.bscope"); + let content = br#"{"id":"durable-success"}"#; + let sync_calls = Cell::new(0usize); + + project_persistence::publish_new_project_file_with_linker_and_directory_sync( + &target, + content, + |source, destination| fs::hard_link(source, destination), + |parent| { + assert_eq!(parent, root.as_path()); + assert_eq!( + fs::read(&target).expect("target must exist before its directory is synced"), + content + ); + sync_calls.set(sync_calls.get() + 1); + Ok(()) + }, + ) + .expect("first save should succeed after the parent directory is durable"); + + assert_eq!(sync_calls.get(), 1); + assert_eq!(fs::read(&target).expect("published target should be readable"), content); + assert!(stage_paths(&root).is_empty()); + fs::remove_dir_all(root).expect("test directory should be removable"); +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_linked_ancestor.rs b/apps/desktop/src-tauri/tests/project_persistence_linked_ancestor.rs new file mode 100644 index 000000000..d673f0f54 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_linked_ancestor.rs @@ -0,0 +1,34 @@ +#[cfg(unix)] +#[path = "../src/project_persistence.rs"] +mod project_persistence; + +#[cfg(unix)] +#[test] +fn regular_project_under_symlinked_ancestor_reads_without_recovery_state() { + use std::{fs, os::unix::fs::symlink}; + + let root = std::env::temp_dir().join(format!( + "bandscope-linked-ancestor-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let real_parent = root.join("real"); + let linked_parent = root.join("linked"); + fs::create_dir_all(&real_parent).expect("real project directory should be created"); + symlink(&real_parent, &linked_parent).expect("linked project directory should be created"); + + let real_target = real_parent.join("setlist.bscope"); + let selected_target = linked_parent.join("setlist.bscope"); + let content = r#"{"id":"linked-folder-project"}"#; + fs::write(&real_target, content).expect("project fixture should be written"); + + project_persistence::recover_project_publication(&selected_target) + .expect("no recovery journal should not reject an otherwise readable linked-folder project"); + assert_eq!( + project_persistence::read_project_file(&selected_target) + .expect("the final regular project file should remain readable"), + content + ); + + fs::remove_dir_all(&root).expect("test directory should be removable"); +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_macos_root_alias.rs b/apps/desktop/src-tauri/tests/project_persistence_macos_root_alias.rs new file mode 100644 index 000000000..371ecc43d --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_macos_root_alias.rs @@ -0,0 +1,36 @@ +#[path = "../src/project_persistence.rs"] +mod project_persistence; + +use std::path::Path; + +#[test] +fn macos_root_alias_policy_only_allows_known_system_aliases() { + assert_eq!( + project_persistence::trusted_macos_root_alias_target(Path::new("/var")), + Some(Path::new("/private/var")) + ); + assert_eq!( + project_persistence::trusted_macos_root_alias_target(Path::new("/tmp")), + Some(Path::new("/private/tmp")) + ); + assert_eq!( + project_persistence::trusted_macos_root_alias_target(Path::new("/etc")), + Some(Path::new("/private/etc")) + ); + + assert_eq!( + project_persistence::trusted_macos_root_alias_target(Path::new("/opt")), + None, + "an arbitrary root-level alias must not gain project-save authority" + ); + assert_eq!( + project_persistence::trusted_macos_root_alias_target(Path::new("/Users")), + None, + "ordinary root directories are not trusted aliases" + ); + assert_eq!( + project_persistence::trusted_macos_root_alias_target(Path::new("/var/tmp")), + None, + "only the exact top-level system aliases are admitted" + ); +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs b/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs new file mode 100644 index 000000000..96961e962 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs @@ -0,0 +1,249 @@ +#[path = "../src/project_persistence.rs"] +mod project_persistence; +#[path = "../src/project_root.rs"] +mod project_root; + +use bandscope_desktop_core::{ + re_admit_local_audio_publication_from_project_root, sha256_hex_reader, + ProjectSourceReferencePayload, +}; +use std::io::Cursor; + +fn source_reference(project_id: &str, bytes: &[u8]) -> ProjectSourceReferencePayload { + ProjectSourceReferencePayload { + project_id: project_id.to_string(), + artifact_name: "source.wav".to_string(), + extension: "wav".to_string(), + file_size_bytes: bytes.len() as u64, + content_sha256: sha256_hex_reader(Cursor::new(bytes)) + .expect("test fixture digest should be computable"), + } +} + +#[cfg(unix)] +#[test] +fn unix_project_opener_refuses_symlink_at_handle_acquisition() { + use std::{ + fs, + os::unix::fs::symlink, + time::{SystemTime, UNIX_EPOCH}, + }; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "bandscope-project-open-authority-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&root).expect("test directory should be created"); + let external = root.join("external.bscope"); + let selected = root.join("selected.bscope"); + fs::write(&external, br#"{\"id\":\"external\"}"#).expect("external fixture should be written"); + symlink(&external, &selected).expect("fixture symlink should be created"); + + let opened = project_persistence::open_project_file(&selected); + + assert!( + opened.is_err(), + "Unix project handle acquisition must not follow a selected-path symlink" + ); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[test] +fn restart_adapter_reopens_the_exact_regular_app_owned_source() { + use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, + }; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "bandscope-source-open-authority-{}-{nonce}", + std::process::id() + )); + let project_id = "project-1-1"; + let project_root = root.join(project_id); + fs::create_dir_all(&project_root).expect("project root should be created"); + let source_bytes = b"RIFF-safe-reopen-fixture"; + fs::write(project_root.join("source.wav"), source_bytes) + .expect("source fixture should be written"); + let reference = source_reference(project_id, source_bytes); + + let reopened = re_admit_local_audio_publication_from_project_root( + &project_root, + &reference, + project_persistence::open_project_file, + ) + .expect("the exact regular app-owned source should regain native identity"); + + assert_eq!(reopened.source_path, project_root.join("source.wav")); + assert_eq!(reopened.identity.project_id, project_id); + assert_eq!(reopened.identity.content_sha256, reference.content_sha256); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[test] +fn restart_adapter_rejects_artifact_traversal_before_opening() { + use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, + }; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "bandscope-source-open-traversal-{}-{nonce}", + std::process::id() + )); + let project_id = "project-1-1"; + let project_root = root.join(project_id); + fs::create_dir_all(&project_root).expect("project root should be created"); + let mut reference = source_reference(project_id, b"outside-project"); + reference.artifact_name = "../external.wav".to_string(); + + let error = re_admit_local_audio_publication_from_project_root( + &project_root, + &reference, + |_path| -> std::io::Result { + panic!("forged durable evidence must fail before filesystem authority is requested") + }, + ) + .expect_err("persisted artifact evidence must not create path traversal authority"); + + assert_eq!(error, "Could not prepare the local project workspace."); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[cfg(unix)] +#[test] +fn restart_adapter_refuses_a_symlink_source_artifact() { + use std::{ + fs, + os::unix::fs::symlink, + time::{SystemTime, UNIX_EPOCH}, + }; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "bandscope-source-open-symlink-{}-{nonce}", + std::process::id() + )); + let project_id = "project-1-1"; + let project_root = root.join(project_id); + fs::create_dir_all(&project_root).expect("project root should be created"); + let external_bytes = b"outside-project"; + let external = root.join("external.wav"); + let source_path = project_root.join("source.wav"); + fs::write(&external, external_bytes).expect("external fixture should be written"); + symlink(&external, &source_path).expect("source symlink should be created"); + let reference = source_reference(project_id, external_bytes); + + let error = re_admit_local_audio_publication_from_project_root( + &project_root, + &reference, + project_persistence::open_project_file, + ) + .expect_err("app-owned source authority must not follow a symlink artifact"); + + assert_eq!(error, "Could not prepare the local project workspace."); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[test] +fn restart_lookup_requires_an_existing_regular_project_directory() { + use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, + }; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let base_root = std::env::temp_dir().join(format!( + "bandscope-existing-project-root-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&base_root).expect("base root should be created"); + let project_id = "project-1-1"; + let project_root = base_root.join(project_id); + fs::create_dir(&project_root).expect("project root should be created"); + + let resolved = project_root::resolve_existing_project_root(&base_root, project_id) + .expect("an existing regular project directory should resolve"); + + assert_eq!(resolved, project_root); + fs::remove_dir_all(base_root).expect("test directory should be removable"); +} + +#[test] +fn restart_lookup_does_not_create_a_missing_project_directory() { + use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, + }; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let base_root = std::env::temp_dir().join(format!( + "bandscope-missing-project-root-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&base_root).expect("base root should be created"); + let project_id = "project-1-1"; + let project_root = base_root.join(project_id); + + let error = project_root::resolve_existing_project_root(&base_root, project_id) + .expect_err("restart must not provision a missing project directory"); + + assert_eq!(error, "Could not prepare the local project workspace."); + assert!( + !project_root.exists(), + "read-side restart lookup must remain non-provisioning" + ); + fs::remove_dir_all(base_root).expect("test directory should be removable"); +} + +#[cfg(unix)] +#[test] +fn restart_lookup_refuses_a_symlink_project_directory() { + use std::{ + fs, + os::unix::fs::symlink, + time::{SystemTime, UNIX_EPOCH}, + }; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let base_root = std::env::temp_dir().join(format!( + "bandscope-linked-project-root-{}-{nonce}", + std::process::id() + )); + let external_root = base_root.join("external"); + fs::create_dir_all(&external_root).expect("external root should be created"); + let project_id = "project-1-1"; + symlink(&external_root, base_root.join(project_id)).expect("fixture symlink should be created"); + + let error = project_root::resolve_existing_project_root(&base_root, project_id) + .expect_err("restart must not follow a project-directory symlink"); + + assert_eq!(error, "Could not prepare the local project workspace."); + fs::remove_dir_all(base_root).expect("test directory should be removable"); +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs b/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs new file mode 100644 index 000000000..7f26a4be6 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs @@ -0,0 +1,251 @@ +#[path = "../src/project_persistence.rs"] +mod project_persistence; + +use std::{ + fs, io, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +fn test_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "bandscope-project-overwrite-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).expect("test directory should be created"); + path +} + +#[test] +fn confirmed_existing_project_is_replaced_after_new_bytes_are_staged() { + let root = test_dir("confirmed"); + let target = root.join("setlist.bscope"); + let known_good = br#"{\"id\":\"known-good\"}"#; + let replacement = br#"{\"id\":\"replacement\"}"#; + fs::write(&target, known_good).expect("known-good fixture should be written"); + + project_persistence::publish_new_project_file(&target, replacement) + .expect("a save-dialog-confirmed regular project should be replaceable"); + + assert_eq!( + fs::read(&target).expect("replacement project should be readable"), + replacement + ); + let names = fs::read_dir(&root) + .expect("test directory should be readable") + .map(|entry| { + entry + .expect("directory entry should be readable") + .file_name() + }) + .collect::>(); + assert_eq!(names, vec![target.file_name().unwrap().to_os_string()]); + + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[test] +fn new_project_uses_reserved_rename_when_hard_links_are_unsupported() { + let root = test_dir("no-hard-link"); + let target = root.join("setlist.bscope"); + let content = br#"{\"id\":\"portable-new-save\"}"#; + + project_persistence::publish_new_project_file_with_linker( + &target, + content, + |_stage, _target| { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "fixture filesystem has no hard links", + )) + }, + ) + .expect("a filesystem without hard links should publish through the reserved rename fallback"); + + assert_eq!( + fs::read(&target).expect("fallback-published project should be readable"), + content + ); + let names = fs::read_dir(&root) + .expect("test directory should be readable") + .map(|entry| { + entry + .expect("directory entry should be readable") + .file_name() + }) + .collect::>(); + assert_eq!(names, vec![target.file_name().unwrap().to_os_string()]); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[test] +fn fallback_never_clobbers_a_target_created_after_hard_link_failure() { + let root = test_dir("fallback-race"); + let target = root.join("setlist.bscope"); + let content = br#"{\"id\":\"candidate\"}"#; + let racer = br#"{\"id\":\"racer\"}"#; + + let error = project_persistence::publish_new_project_file_with_linker( + &target, + content, + |_stage, target| { + fs::write(target, racer)?; + Err(io::Error::new( + io::ErrorKind::Unsupported, + "hard-link publication became unavailable after a racer won the name", + )) + }, + ) + .expect_err("the reserved-rename fallback must not clobber a concurrent target"); + + assert_eq!( + error, + "Project file already exists. Choose a new file name." + ); + assert_eq!( + fs::read(&target).expect("racer project should remain readable"), + racer + ); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[test] +fn new_project_never_clobbers_a_target_that_appears_concurrently() { + let root = test_dir("no-hard-link-race"); + let target = root.join("setlist.bscope"); + let content = br#"{\"id\":\"candidate\"}"#; + let racer = br#"{\"id\":\"racer\"}"#; + + let error = project_persistence::publish_new_project_file_with_linker( + &target, + content, + |_stage, target| { + fs::write(target, racer)?; + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "racer won the target name", + )) + }, + ) + .expect_err("publication must fail closed when another writer wins the target name"); + + assert_eq!( + error, + "Project file already exists. Choose a new file name." + ); + assert_eq!( + fs::read(&target).expect("racer project should remain readable"), + racer + ); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[test] +fn existing_project_never_clobbers_a_target_swapped_after_authority_snapshot() { + let root = test_dir("existing-target-race"); + let target = root.join("setlist.bscope"); + let parked = root.join("parked-authorized.bscope"); + let stage = root.join("candidate.stage"); + let authorized = br#"{\"id\":\"authorized\"}"#; + let racer = br#"{\"id\":\"racer\"}"#; + let candidate = br#"{\"id\":\"candidate\"}"#; + fs::write(&target, authorized).expect("authorized fixture should be written"); + fs::write(&stage, candidate).expect("candidate stage should be written"); + + let expected = project_persistence::project_file_identity(&target) + .expect("the selected target identity should be capturable"); + fs::rename(&target, &parked).expect("authorized target should be parked by the racer"); + fs::write(&target, racer).expect("racer should replace the selected pathname"); + + let error = project_persistence::replace_existing_project_file(&stage, &target, &expected) + .expect_err("replacement must fail closed when target identity changed after validation"); + + assert_eq!(error, "Could not publish the project safely."); + assert_eq!( + fs::read(&target).expect("racer target should remain readable"), + racer, + "the save must not clobber a different file that won the pathname" + ); + assert_eq!( + fs::read(&parked).expect("authorized project should remain readable"), + authorized + ); + assert!( + !stage.exists(), + "the rejected candidate stage should be cleaned after a successful rollback" + ); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[cfg(windows)] +#[test] +fn failed_windows_replace_removes_the_candidate_stage() { + let root = test_dir("windows-replace-failure-cleanup"); + let target = root.join("setlist.bscope"); + let stage = root.join("candidate.stage"); + let known_good = br#"{\"id\":\"known-good\"}"#; + let candidate = br#"{\"id\":\"candidate\"}"#; + fs::write(&target, known_good).expect("known-good fixture should be written"); + fs::write(&stage, candidate).expect("candidate stage should be written"); + + let expected = project_persistence::project_file_identity(&target) + .expect("the selected target identity should be capturable"); + let mut permissions = fs::metadata(&target) + .expect("known-good metadata should be readable") + .permissions(); + permissions.set_readonly(true); + fs::set_permissions(&target, permissions) + .expect("the fixture should make ReplaceFileW reject the target"); + + let error = project_persistence::replace_existing_project_file(&stage, &target, &expected) + .expect_err("a failed native replacement must fail closed"); + + assert_eq!(error, "Could not publish the project safely."); + assert!( + !stage.exists(), + "a failed ReplaceFileW attempt must remove the owned candidate stage" + ); + assert_eq!( + fs::read(&target).expect("known-good target should remain readable"), + known_good + ); + + let mut permissions = fs::metadata(&target) + .expect("known-good metadata should remain readable") + .permissions(); + permissions.set_readonly(false); + fs::set_permissions(&target, permissions) + .expect("the fixture should restore write permission before cleanup"); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[test] +fn oversized_project_error_names_the_binary_limit_as_mib() { + let root = test_dir("oversize-unit-copy"); + let target = root.join("setlist.bscope"); + let oversized = vec![b'x'; 5 * 1024 * 1024 + 1]; + + let save_error = project_persistence::publish_new_project_file(&target, &oversized) + .expect_err("a project above the binary 5 MiB ceiling must be rejected"); + assert_eq!( + save_error, + "Project file is too large (exceeds 5 MiB limit)", + "the buyer-visible error must name the 5 * 1024 * 1024 byte ceiling as MiB, not decimal MB" + ); + + let existing = fs::File::create(&target).expect("oversize load fixture should be created"); + existing + .set_len((5 * 1024 * 1024 + 1) as u64) + .expect("oversize load fixture should be sized"); + drop(existing); + let load_error = project_persistence::read_project_file(&target) + .expect_err("the bounded reader must reject the same binary ceiling"); + assert_eq!(load_error, "Project file is too large (exceeds 5 MiB limit)"); + + fs::remove_dir_all(root).expect("test directory should be removable"); +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs b/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs new file mode 100644 index 000000000..e4a897450 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs @@ -0,0 +1,77 @@ +#[path = "../src/project_persistence.rs"] +mod project_persistence; + +#[cfg(unix)] +fn fixture_root(label: &str) -> std::path::PathBuf { + use std::time::{SystemTime, UNIX_EPOCH}; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "bandscope-project-persistence-{label}-{}-{nonce}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("fixture root should be created"); + root +} + +#[cfg(unix)] +#[test] +fn refuses_to_publish_through_symlinked_parent_directory() { + use std::{fs, os::unix::fs::symlink}; + + let root = fixture_root("parent-symlink"); + let external = root.join("external"); + let linked_parent = root.join("selected-parent"); + fs::create_dir_all(&external).expect("external fixture directory should be created"); + symlink(&external, &linked_parent).expect("fixture parent symlink should be created"); + + let target = linked_parent.join("setlist.bscope"); + let error = + project_persistence::publish_new_project_file(&target, br#"{\"id\":\"must-not-escape\"}"#) + .expect_err("a symlinked save parent must not redirect project publication"); + + assert_eq!(error, "Could not stage the project safely."); + assert!(!external.join("setlist.bscope").exists()); + assert_eq!( + fs::read_dir(&external) + .expect("external fixture directory should remain readable") + .count(), + 0, + "no staging or published artifact may escape through the symlinked parent" + ); + + fs::remove_dir_all(root).expect("test fixture should be removable"); +} + +#[cfg(unix)] +#[test] +fn refuses_to_publish_through_symlinked_ancestor_directory() { + use std::{fs, os::unix::fs::symlink}; + + let root = fixture_root("ancestor-symlink"); + let external = root.join("external"); + let external_parent = external.join("nested-parent"); + let linked_ancestor = root.join("selected-root"); + fs::create_dir_all(&external_parent).expect("external nested directory should be created"); + symlink(&external, &linked_ancestor).expect("fixture ancestor symlink should be created"); + + let target = linked_ancestor.join("nested-parent").join("setlist.bscope"); + let error = + project_persistence::publish_new_project_file(&target, br#"{\"id\":\"must-not-escape\"}"#) + .expect_err("a linked ancestor must not redirect project publication"); + + assert_eq!(error, "Could not stage the project safely."); + assert!(!external_parent.join("setlist.bscope").exists()); + assert_eq!( + fs::read_dir(&external_parent) + .expect("external nested directory should remain readable") + .count(), + 0, + "no staging or published artifact may escape through a linked ancestor" + ); + + fs::remove_dir_all(root).expect("test fixture should be removable"); +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_permissions.rs b/apps/desktop/src-tauri/tests/project_persistence_permissions.rs new file mode 100644 index 000000000..0bd2105f2 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_permissions.rs @@ -0,0 +1,75 @@ +#[path = "../src/project_persistence.rs"] +mod project_persistence; + +#[cfg(unix)] +#[test] +fn existing_project_overwrite_preserves_restrictive_mode() { + use std::{ + fs, + os::unix::fs::PermissionsExt, + time::{SystemTime, UNIX_EPOCH}, + }; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "bandscope-project-permissions-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&root).expect("test directory should be created"); + let target = root.join("private.bscope"); + fs::write(&target, br#"{"id":"private-old"}"#).expect("fixture should be written"); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)) + .expect("fixture should be restricted to its owner"); + + project_persistence::publish_new_project_file(&target, br#"{"id":"private-new"}"#) + .expect("existing private project should be replaced safely"); + + let mode = fs::metadata(&target) + .expect("replacement should be readable") + .permissions() + .mode() + & 0o777; + assert_eq!( + mode, 0o600, + "staged replacement must not widen an existing project's Unix permissions" + ); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[cfg(unix)] +#[test] +fn existing_project_overwrite_strips_executable_bits() { + use std::{ + fs, + os::unix::fs::PermissionsExt, + time::{SystemTime, UNIX_EPOCH}, + }; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "bandscope-project-permissions-executable-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&root).expect("test directory should be created"); + let target = root.join("project.bscope"); + fs::write(&target, br#"{"id":"executable-old"}"#).expect("fixture should be written"); + fs::set_permissions(&target, fs::Permissions::from_mode(0o755)) + .expect("fixture should be executable"); + + project_persistence::publish_new_project_file(&target, br#"{"id":"data-new"}"#) + .expect("existing executable project should be replaced safely"); + + let mode = fs::metadata(&target) + .expect("replacement should be readable") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o644, "project data must not retain executable bits"); + fs::remove_dir_all(root).expect("test directory should be removable"); +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_recovery_cleanup.rs b/apps/desktop/src-tauri/tests/project_persistence_recovery_cleanup.rs new file mode 100644 index 000000000..9191f6a3b --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_recovery_cleanup.rs @@ -0,0 +1,203 @@ +#[path = "../src/project_persistence.rs"] +mod project_persistence; + +#[cfg(unix)] +mod unix_recovery_cleanup { + use super::project_persistence; + use std::{ + fs, + os::unix::ffi::OsStrExt, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn test_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "bandscope-project-recovery-cleanup-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).expect("test directory should be created"); + path + } + + fn prepared_journal_path(target: &Path) -> PathBuf { + target.parent().expect("fixture target should have a parent").join(format!( + ".bandscope-recovery-{}.prepared.journal", + project_persistence::journal_target_key(target) + .expect("fixture target key should be derivable") + )) + } + + #[test] + fn stale_prepared_journal_without_recovery_artifacts_does_not_lock_a_changed_target() { + let root = test_dir("stale-journal"); + let target = root.join("setlist.bscope"); + let stage = root.join(format!(".bandscope-stage-{}.stage", uuid::Uuid::new_v4())); + let original = br#"{"id":"original"}"#; + let candidate = br#"{"id":"candidate"}"#; + let replacement = br#"{"id":"external-replacement"}"#; + fs::write(&target, original).expect("original fixture should be written"); + fs::write(&stage, candidate).expect("candidate fixture should be written"); + + let expected = project_persistence::project_file_identity(&target) + .expect("original identity should be capturable"); + let candidate_identity = project_persistence::project_file_identity(&stage) + .expect("candidate identity should be capturable"); + let target_name = target.file_name().unwrap().as_bytes().to_vec(); + let stage_name = stage.file_name().unwrap().as_bytes().to_vec(); + let journal = prepared_journal_path(&target); + let record = serde_json::json!({ + "version": 1, + "target_name": target_name, + "candidate_name": stage_name, + "displaced_name": stage.file_name().unwrap().as_bytes().to_vec(), + "expected": expected, + "candidate": candidate_identity, + }); + fs::write(&journal, serde_json::to_vec(&record).expect("journal should serialize")) + .expect("prepared journal should be written"); + + fs::remove_file(&stage).expect("orphan candidate should be removed"); + fs::remove_file(&target).expect("original target should be replaced externally"); + fs::write(&target, replacement).expect("external replacement should be written"); + + project_persistence::recover_project_publication(&target) + .expect("a journal with no rollback artifacts must not permanently lock the target"); + + assert_eq!(fs::read(&target).expect("target should remain readable"), replacement); + assert!(!journal.exists(), "stale recovery journal should be removed"); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[test] + fn failed_journal_creation_removes_the_owned_candidate_stage() { + let root = test_dir("journal-collision"); + let target = root.join("setlist.bscope"); + let known_good = br#"{"id":"known-good"}"#; + fs::write(&target, known_good).expect("known-good fixture should be written"); + let journal = prepared_journal_path(&target); + fs::write(&journal, b"occupied").expect("fixture should reserve the journal name"); + + project_persistence::publish_new_project_file(&target, br#"{"id":"candidate"}"#) + .expect_err("an occupied prepared journal should fail closed"); + + let leaked_stage = fs::read_dir(&root) + .expect("fixture directory should be readable") + .filter_map(Result::ok) + .map(|entry| entry.file_name()) + .any(|name| { + let name = name.to_string_lossy(); + name.starts_with(".bandscope-stage-") && name.ends_with(".stage") + }); + assert!(!leaked_stage, "failed journal preparation must clean the owned stage"); + assert_eq!(fs::read(&target).expect("known-good target should remain readable"), known_good); + fs::remove_dir_all(root).expect("test directory should be removable"); + } +} + +#[cfg(windows)] +mod windows_recovery_cleanup { + use super::project_persistence; + use std::{ + fs, + os::windows::ffi::OsStrExt, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn test_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "bandscope-project-recovery-cleanup-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).expect("test directory should be created"); + path + } + + fn prepared_journal_path(target: &Path) -> PathBuf { + target.parent().expect("fixture target should have a parent").join(format!( + ".bandscope-recovery-{}.prepared.journal", + project_persistence::journal_target_key(target) + .expect("fixture target key should be derivable") + )) + } + + fn journal_name(path: &Path) -> Vec { + path.file_name() + .expect("fixture path should have a file name") + .encode_wide() + .collect() + } + + #[test] + fn stale_prepared_journal_without_recovery_artifacts_does_not_lock_a_changed_target() { + let root = test_dir("windows-stale-journal"); + let target = root.join("setlist.bscope"); + let stage = root.join(format!(".bandscope-stage-{}.stage", uuid::Uuid::new_v4())); + let original = br#"{"id":"original"}"#; + let candidate = br#"{"id":"candidate"}"#; + let replacement = br#"{"id":"external-replacement"}"#; + fs::write(&target, original).expect("original fixture should be written"); + fs::write(&stage, candidate).expect("candidate fixture should be written"); + + let expected = project_persistence::project_file_identity(&target) + .expect("original identity should be capturable"); + let candidate_identity = project_persistence::project_file_identity(&stage) + .expect("candidate identity should be capturable"); + let journal = prepared_journal_path(&target); + let record = serde_json::json!({ + "version": 1, + "target_name": journal_name(&target), + "candidate_name": journal_name(&stage), + "displaced_name": journal_name(&stage), + "expected": expected, + "candidate": candidate_identity, + }); + fs::write(&journal, serde_json::to_vec(&record).expect("journal should serialize")) + .expect("prepared journal should be written"); + + fs::remove_file(&stage).expect("orphan candidate should be removed"); + fs::remove_file(&target).expect("original target should be replaced externally"); + fs::write(&target, replacement).expect("external replacement should be written"); + + project_persistence::recover_project_publication(&target) + .expect("a journal with no rollback artifacts must not permanently lock the target"); + + assert_eq!(fs::read(&target).expect("target should remain readable"), replacement); + assert!(!journal.exists(), "stale recovery journal should be removed"); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[test] + fn failed_journal_creation_removes_the_owned_candidate_stage() { + let root = test_dir("windows-journal-collision"); + let target = root.join("setlist.bscope"); + let known_good = br#"{"id":"known-good"}"#; + fs::write(&target, known_good).expect("known-good fixture should be written"); + let journal = prepared_journal_path(&target); + fs::write(&journal, b"occupied").expect("fixture should reserve the journal name"); + + project_persistence::publish_new_project_file(&target, br#"{"id":"candidate"}"#) + .expect_err("an occupied prepared journal should fail closed"); + + let leaked_stage = fs::read_dir(&root) + .expect("fixture directory should be readable") + .filter_map(Result::ok) + .map(|entry| entry.file_name()) + .any(|name| { + let name = name.to_string_lossy(); + name.starts_with(".bandscope-stage-") && name.ends_with(".stage") + }); + assert!(!leaked_stage, "failed journal preparation must clean the owned stage"); + assert_eq!(fs::read(&target).expect("known-good target should remain readable"), known_good); + fs::remove_dir_all(root).expect("test directory should be removable"); + } +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_rollback_identity.rs b/apps/desktop/src-tauri/tests/project_persistence_rollback_identity.rs new file mode 100644 index 000000000..3f49d8518 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_rollback_identity.rs @@ -0,0 +1,93 @@ +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +mod project_persistence { + include!("../src/project_persistence.rs"); + + pub(crate) fn create_publication_journal_for_test( + target: &std::path::Path, + candidate_stage: &std::path::Path, + displaced: &std::path::Path, + expected: &ProjectFileIdentity, + candidate: &ProjectFileIdentity, + ) -> Result { + create_publication_journal(target, candidate_stage, displaced, expected, candidate) + } + + pub(crate) fn finish_rolled_back_publication_for_test( + stage: &std::path::Path, + journal: &std::path::Path, + target: &std::path::Path, + ) -> Result<(), String> { + finish_rolled_back_publication(stage, journal, target) + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +mod rollback_identity { + use super::project_persistence; + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn test_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "bandscope-project-rollback-identity-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).expect("test directory should be created"); + path + } + + #[test] + fn rollback_cleanup_preserves_a_stage_that_is_no_longer_the_candidate() { + let root = test_dir("foreign-stage"); + let target = root.join("setlist.bscope"); + let stage = root.join(format!(".bandscope-stage-{}.stage", uuid::Uuid::new_v4())); + let original = br#"{"id":"original"}"#; + let candidate = br#"{"id":"candidate"}"#; + let foreign = br#"{"id":"foreign-racer"}"#; + fs::write(&target, original).expect("original fixture should be written"); + fs::write(&stage, candidate).expect("candidate fixture should be written"); + + let expected = project_persistence::project_file_identity(&target) + .expect("original identity should be capturable"); + let candidate_identity = project_persistence::project_file_identity(&stage) + .expect("candidate identity should be capturable"); + let journal = project_persistence::create_publication_journal_for_test( + &target, + &stage, + &stage, + &expected, + &candidate_identity, + ) + .expect("prepared rollback journal should be durable"); + + fs::remove_file(&stage).expect("candidate pathname should be replaceable by the race fixture"); + fs::write(&stage, foreign).expect("foreign rollback artifact should be written"); + + let error = project_persistence::finish_rolled_back_publication_for_test( + &stage, + &journal, + &target, + ) + .expect_err("rollback cleanup must not delete a stage whose identity no longer matches the candidate"); + + assert_eq!(error, "Could not recover the project publication safely."); + assert_eq!( + fs::read(&stage).expect("foreign artifact must remain for recovery"), + foreign + ); + assert!(journal.exists(), "the journal must remain when rollback identity is ambiguous"); + assert_eq!( + fs::read(&target).expect("target must remain untouched"), + original + ); + + fs::remove_dir_all(root).expect("test directory should be removable"); + } +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_windows_identity.rs b/apps/desktop/src-tauri/tests/project_persistence_windows_identity.rs new file mode 100644 index 000000000..cb1a8406c --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_windows_identity.rs @@ -0,0 +1,69 @@ +#![cfg(windows)] + +#[path = "../src/project_persistence.rs"] +mod project_persistence; + +use std::{ + fs::{self, File}, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +fn test_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "bandscope-project-windows-identity-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).expect("test directory should be created"); + path +} + +#[test] +fn distinct_windows_files_have_distinct_native_identity() { + let root = test_dir("distinct"); + let left_path = root.join("left.bscope"); + let right_path = root.join("right.bscope"); + let bytes = br#"{\"id\":\"same-size\"}"#; + fs::write(&left_path, bytes).expect("left fixture should be written"); + fs::write(&right_path, bytes).expect("right fixture should be written"); + + let left = File::open(&left_path).expect("left fixture should open"); + let right = File::open(&right_path).expect("right fixture should open"); + + assert_ne!( + project_persistence::windows_file_identity(&left) + .expect("left native identity should be readable"), + project_persistence::windows_file_identity(&right) + .expect("right native identity should be readable"), + "distinct files with the same bytes must not collapse to one Windows identity" + ); + + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[test] +fn windows_hard_link_aliases_share_native_identity() { + let root = test_dir("hard-link"); + let original_path = root.join("original.bscope"); + let alias_path = root.join("alias.bscope"); + fs::write(&original_path, br#"{\"id\":\"shared\"}"#) + .expect("original fixture should be written"); + fs::hard_link(&original_path, &alias_path).expect("hard-link fixture should be created"); + + let original = File::open(&original_path).expect("original fixture should open"); + let alias = File::open(&alias_path).expect("alias fixture should open"); + + assert_eq!( + project_persistence::windows_file_identity(&original) + .expect("original native identity should be readable"), + project_persistence::windows_file_identity(&alias) + .expect("alias native identity should be readable"), + "two handles to one file must report one Windows identity" + ); + + fs::remove_dir_all(root).expect("test directory should be removable"); +} diff --git a/apps/desktop/src-tauri/tests/project_root_existing_authority.rs b/apps/desktop/src-tauri/tests/project_root_existing_authority.rs new file mode 100644 index 000000000..ad7d7b725 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_root_existing_authority.rs @@ -0,0 +1,62 @@ +#[path = "../src/project_root.rs"] +mod project_root; + +#[cfg(unix)] +#[test] +fn linked_app_local_base_cannot_authorize_a_project_root() { + use std::{fs, os::unix::fs::symlink}; + + let test_root = std::env::temp_dir().join(format!( + "bandscope-project-root-authority-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let real_app_local_base = test_root.join("real-app-local"); + let linked_app_local_base = test_root.join("linked-app-local"); + let project_id = "project-1-1"; + + fs::create_dir_all(real_app_local_base.join(project_id)) + .expect("real app-local project directory should be created"); + symlink(&real_app_local_base, &linked_app_local_base) + .expect("linked app-local base should be created"); + + assert!( + project_root::resolve_existing_project_root(&linked_app_local_base, project_id).is_err(), + "a symlinked app-local base must not become native project authority" + ); + + fs::remove_dir_all(&test_root).expect("test directory should be removable"); +} + +#[cfg(windows)] +#[test] +fn reparse_app_local_base_cannot_authorize_a_project_root() { + use std::{fs, process::Command}; + + let test_root = std::env::temp_dir().join(format!( + "bandscope-project-root-authority-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let real_app_local_base = test_root.join("real-app-local"); + let linked_app_local_base = test_root.join("linked-app-local"); + let project_id = "project-1-1"; + + fs::create_dir_all(real_app_local_base.join(project_id)) + .expect("real app-local project directory should be created"); + let junction = Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&linked_app_local_base) + .arg(&real_app_local_base) + .status() + .expect("junction command should start"); + assert!(junction.success(), "junction fixture should be created"); + + assert!( + project_root::resolve_existing_project_root(&linked_app_local_base, project_id).is_err(), + "a reparse app-local base must not become native project authority" + ); + + fs::remove_dir(&linked_app_local_base).expect("junction should be removable"); + fs::remove_dir_all(&test_root).expect("test directory should be removable"); +} diff --git a/apps/desktop/src/App.project-save-source-authority.test.tsx b/apps/desktop/src/App.project-save-source-authority.test.tsx new file mode 100644 index 000000000..c52aada7b --- /dev/null +++ b/apps/desktop/src/App.project-save-source-authority.test.tsx @@ -0,0 +1,120 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { App } from "./App"; + +const { mockLoadProject, mockLoadProjectDocument, mockSaveProject } = vi.hoisted(() => ({ + mockLoadProject: vi.fn(), + mockLoadProjectDocument: vi.fn(), + mockSaveProject: vi.fn().mockResolvedValue(undefined) +})); + +vi.mock("./features/score/pdfjs", () => ({ + configureScorePdfWorker: vi.fn(), + loadScorePdf: vi.fn(() => ({ + promise: Promise.resolve({ numPages: 1, getPage: vi.fn() }), + destroy: vi.fn(() => Promise.resolve()) + })) +})); + +vi.mock("./lib/analysis", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + createDefaultAnalysisRequest: () => ({ + sourceKind: "demo", + sourceLabel: "Late Night Set", + roleFocus: ["bass-guitar"] + }), + selectLocalAudioSource: async () => ({ + ok: true as const, + bootstrap: { + projectId: "project-400-4", + sourceMode: "reference" as const, + projectRoot: "/tmp/bandscope/projects/project-400-4", + cacheRoot: "/tmp/bandscope/cache/project-400-4", + tempRoot: "/tmp/bandscope/temp/project-400-4", + source: { + sourcePath: "/tmp/bandscope/projects/project-400-4/source.wav", + fileName: "source.wav", + extension: "wav", + fileSizeBytes: 4096 + } + } + }), + startAnalysisJob: async () => ({ + jobId: "job-local-save", + state: "succeeded" as const, + requestedAt: "2026-09-06T08:00:00Z", + updatedAt: "2026-09-06T08:00:01Z", + progressLabel: "Analysis ready", + progressStage: "ready" as const, + progressPercent: 100, + result: createDemoRehearsalSong() + }), + subscribeToAnalysisJobUpdates: async () => () => undefined, + loadProject: (...args: unknown[]) => mockLoadProject(...args), + loadProjectDocument: (...args: unknown[]) => mockLoadProjectDocument(...args), + saveProject: (...args: unknown[]) => mockSaveProject(...args) + }; +}); + +describe("App local-audio save authority", () => { + beforeEach(() => { + mockLoadProject.mockReset(); + mockLoadProjectDocument.mockReset(); + mockSaveProject.mockClear(); + }); + + it("saves the analyzed local project with its exact native project id", async () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + await waitFor(() => expect(screen.getByText("source.wav")).toBeTruthy()); + + fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); + await waitFor(() => expect(screen.getByRole("button", { name: /save project/i })).toBeTruthy()); + + fireEvent.click(screen.getByRole("button", { name: /save project/i })); + + await waitFor(() => { + expect(mockSaveProject).toHaveBeenCalledWith( + expect.objectContaining({ id: expect.any(String) }), + "full_mix", + "project-400-4" + ); + }); + }); + + it("preserves reopened source identity and playback-source intent on resave", async () => { + const song = createDemoRehearsalSong(); + const projectDocument = { + song, + preferences: { selectedPlaybackSource: "vocals" as const }, + sourceReference: { + projectId: "project-500-5", + artifactName: "source.wav", + extension: "wav" as const, + fileSizeBytes: 8192, + contentSha256: "a".repeat(64) + } + }; + mockLoadProject.mockResolvedValueOnce(song); + mockLoadProjectDocument.mockResolvedValueOnce(projectDocument); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + await waitFor(() => expect(screen.getByRole("button", { name: /save project/i })).toBeTruthy()); + + fireEvent.click(screen.getByRole("button", { name: /save project/i })); + + await waitFor(() => { + expect(mockSaveProject).toHaveBeenCalledWith( + expect.objectContaining({ id: expect.any(String) }), + "vocals", + "project-500-5" + ); + }); + }); +}); \ No newline at end of file diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f3d678454..69111efb5 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -36,12 +36,13 @@ import { getAnalysisJobStatus, importYoutubeUrl, isSupportedYoutubeUrl, - loadProject, + loadProjectDocument, MAX_YOUTUBE_URL_LENGTH, saveProject, subscribeToAnalysisJobUpdates, selectLocalAudioSource, - startAnalysisJob + startAnalysisJob, + type SelectedPlaybackSource } from "./lib/analysis"; import { createTranslator, detectPreferredLocale, type TranslationKey } from "./i18n"; import { ScoreView } from "./features/score/ScoreView"; @@ -254,11 +255,15 @@ export function App() { const [jobStatus, setJobStatus] = useState(null); const [jobResult, setJobResult] = useState(null); const [jobResultBootstrap, setJobResultBootstrap] = useState(null); + const [jobResultPublicationProjectId, setJobResultPublicationProjectId] = useState(null); + const [jobResultSelectedPlaybackSource, setJobResultSelectedPlaybackSource] = useState("full_mix"); const [jobError, setJobError] = useState(null); const [renderedProgressPercent, setRenderedProgressPercent] = useState(undefined); const [isStarting, setIsStarting] = useState(false); const [selectedBootstrap, setSelectedBootstrap] = useState(null); + const [selectedPublicationProjectId, setSelectedPublicationProjectId] = useState(null); const [activeAnalysisBootstrap, setActiveAnalysisBootstrap] = useState(null); + const [activeAnalysisPublicationProjectId, setActiveAnalysisPublicationProjectId] = useState(null); const [selectionError, setSelectionError] = useState(null); const [selectionErrorSource, setSelectionErrorSource] = useState<"local" | "youtube" | null>(null); const [youtubeUrl, setYoutubeUrl] = useState(""); @@ -287,14 +292,18 @@ export function App() { if (nextStatus.state === "succeeded" && nextStatus.result) { setJobResult(nextStatus.result); setJobResultBootstrap(activeAnalysisBootstrap); + setJobResultPublicationProjectId(activeAnalysisPublicationProjectId); + setJobResultSelectedPlaybackSource("full_mix"); setActiveAnalysisBootstrap(null); + setActiveAnalysisPublicationProjectId(null); setJobError(null); } if (nextStatus.state === "failed") { setActiveAnalysisBootstrap(null); + setActiveAnalysisPublicationProjectId(null); setJobError(safeErrorDetail(nextStatus.error?.message, t("analysisCouldNotStart"))); } - }, [activeAnalysisBootstrap, t]); + }, [activeAnalysisBootstrap, activeAnalysisPublicationProjectId, t]); useEffect(() => { const targetPercent = jobStatus?.progressPercent; @@ -388,11 +397,14 @@ export function App() { /** Documented. */ const handleStartAnalysis = async () => { const submittedBootstrap = selectedBootstrap; + const submittedPublicationProjectId = selectedPublicationProjectId; setJobError(null); setJobResult(null); setJobResultBootstrap(null); + setJobResultPublicationProjectId(null); setJobStatus(null); setActiveAnalysisBootstrap(submittedBootstrap); + setActiveAnalysisPublicationProjectId(submittedPublicationProjectId); setIsStarting(true); try { const nextStatus = await startAnalysisJob(selectedRequest); @@ -400,13 +412,17 @@ export function App() { setJobStatus(nextStatus); setJobResult(nextStatus.result); setJobResultBootstrap(submittedBootstrap); + setJobResultPublicationProjectId(submittedPublicationProjectId); + setJobResultSelectedPlaybackSource("full_mix"); setActiveAnalysisBootstrap(null); + setActiveAnalysisPublicationProjectId(null); } else { applyJobStatus(nextStatus); } } catch { setJobStatus(null); setActiveAnalysisBootstrap(null); + setActiveAnalysisPublicationProjectId(null); setJobError(t("analysisCouldNotStart")); } finally { setIsStarting(false); @@ -420,10 +436,12 @@ export function App() { const selection = await selectLocalAudioSource(); if (selection.ok) { setSelectedBootstrap(selection.bootstrap); + setSelectedPublicationProjectId(selection.bootstrap.projectId); return; } setSelectedBootstrap(null); + setSelectedPublicationProjectId(null); setSelectionError(safeErrorDetail(selection.error.message, t("unsupportedLocalAudio"))); setSelectionErrorSource("local"); setJobStatus(null); @@ -451,6 +469,7 @@ export function App() { const selection = await importYoutubeUrl(normalizedUrl); if (selection.ok) { setSelectedBootstrap(selection.bootstrap); + setSelectedPublicationProjectId(null); setYoutubeUrl(""); } else { setSelectionError(safeErrorDetail(selection.error.message, t("youtubeImportFailed"))); @@ -473,12 +492,16 @@ export function App() { /** Documented. */ const handleLoadProject = async () => { try { - const song = await loadProject(); - setJobResult(song); + const projectDocument = await loadProjectDocument(); + setJobResult(projectDocument.song); setJobResultBootstrap(null); + setJobResultPublicationProjectId(projectDocument.sourceReference?.projectId ?? null); + setJobResultSelectedPlaybackSource(projectDocument.preferences.selectedPlaybackSource); setJobError(null); setSelectedBootstrap(null); + setSelectedPublicationProjectId(null); setActiveAnalysisBootstrap(null); + setActiveAnalysisPublicationProjectId(null); setJobStatus(null); } catch (e) { if (!isUserCancellation(e)) { @@ -490,7 +513,11 @@ export function App() { /** Documented. */ const handleSaveProject = async () => { try { - await saveProject(jobResult!); + await saveProject( + jobResult!, + jobResultSelectedPlaybackSource, + jobResultPublicationProjectId ?? undefined + ); } catch (e) { if (!isUserCancellation(e)) { setJobError(`${t("saveProjectFailedPrefix")}: ${safeErrorDetail(e, t("saveProjectFailedFallback"))}`); diff --git a/apps/desktop/src/lib/analysis.audio-resource-next-action.test.ts b/apps/desktop/src/lib/analysis.audio-resource-next-action.test.ts new file mode 100644 index 000000000..8643ab84b --- /dev/null +++ b/apps/desktop/src/lib/analysis.audio-resource-next-action.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + MAX_LOCAL_AUDIO_FILE_BYTES, + importYoutubeUrl, + selectLocalAudioSource +} from "./analysis"; + +type TauriWindow = Window & { + __TAURI_INTERNALS__?: unknown; + __TAURI_INVOKE__?: unknown; +}; + +const tauriWindow = window as TauriWindow; +const NEXT_ACTION = "Choose a shorter or smaller song file to start analysis."; + +function oversizedBootstrap(projectId: string) { + return { + projectId, + sourceMode: "reference", + projectRoot: `/tmp/bandscope/projects/${projectId}`, + cacheRoot: `/tmp/bandscope/cache/${projectId}`, + tempRoot: `/tmp/bandscope/temp/${projectId}`, + source: { + sourcePath: `/tmp/bandscope/${projectId}/input.wav`, + fileName: "input.wav", + extension: "wav", + fileSizeBytes: MAX_LOCAL_AUDIO_FILE_BYTES + 1 + } + }; +} + +describe("audio resource rejection next action", () => { + beforeEach(() => { + delete tauriWindow.__TAURI_INTERNALS__; + delete tauriWindow.__TAURI_INVOKE__; + }); + + it("names the next action for an oversized local selection", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue(oversizedBootstrap("local-project")); + + await expect(selectLocalAudioSource()).resolves.toEqual({ + ok: false, + error: { code: "invalid_request", message: NEXT_ACTION } + }); + }); + + it("names the same next action for an oversized imported selection", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue(oversizedBootstrap("youtube-project")); + + await expect(importYoutubeUrl("https://youtu.be/4ozX4yFUC34")).resolves.toEqual({ + ok: false, + error: { code: "invalid_request", message: NEXT_ACTION } + }); + }); +}); diff --git a/apps/desktop/src/lib/analysis.resource-policy.test.ts b/apps/desktop/src/lib/analysis.resource-policy.test.ts new file mode 100644 index 000000000..33ab81804 --- /dev/null +++ b/apps/desktop/src/lib/analysis.resource-policy.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { importYoutubeUrl, selectLocalAudioSource } from "./analysis"; + +type TauriWindow = Window & { + __TAURI_INTERNALS__?: unknown; + __TAURI_INVOKE__?: unknown; +}; + +const tauriWindow = window as TauriWindow; +const INVALID_RESOURCE_POLICY_MESSAGE = + "Selected audio file metadata violates the analysis resource policy."; + +function fractionalBootstrap(projectId: string) { + return { + projectId, + sourceMode: "reference", + projectRoot: `/tmp/bandscope/projects/${projectId}`, + cacheRoot: `/tmp/bandscope/cache/${projectId}`, + tempRoot: `/tmp/bandscope/temp/${projectId}`, + source: { + sourcePath: `/tmp/bandscope/${projectId}/input.wav`, + fileName: "input.wav", + extension: "wav", + fileSizeBytes: 1.5 + } + }; +} + +describe("analysis encoded-byte policy parity", () => { + beforeEach(() => { + delete tauriWindow.__TAURI_INTERNALS__; + delete tauriWindow.__TAURI_INVOKE__; + }); + + it("rejects fractional local-file metadata before project state", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue(fractionalBootstrap("local-project")); + + await expect(selectLocalAudioSource()).resolves.toEqual({ + ok: false, + error: { + code: "invalid_request", + message: INVALID_RESOURCE_POLICY_MESSAGE + } + }); + }); + + it("rejects fractional imported-file metadata before project state", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue(fractionalBootstrap("youtube-project")); + + await expect(importYoutubeUrl("https://youtu.be/4ozX4yFUC34")).resolves.toEqual({ + ok: false, + error: { + code: "invalid_request", + message: INVALID_RESOURCE_POLICY_MESSAGE + } + }); + }); +}); diff --git a/apps/desktop/src/lib/analysis.test.ts b/apps/desktop/src/lib/analysis.test.ts index e3347d1f5..4fb47f211 100644 --- a/apps/desktop/src/lib/analysis.test.ts +++ b/apps/desktop/src/lib/analysis.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createDemoAnalysisJobRequest, createDemoRehearsalSong } from "@bandscope/shared-types"; import { + MAX_LOCAL_AUDIO_FILE_BYTES, MAX_YOUTUBE_URL_LENGTH, getAnalysisJobStatus, importYoutubeUrl, + selectLocalAudioSource, startAnalysisJob } from "./analysis"; @@ -13,6 +15,7 @@ type TauriWindow = Window & { }; const tauriWindow = window as TauriWindow; +const OVERSIZED_LOCAL_AUDIO_NEXT_ACTION = "Choose a shorter or smaller song file to start analysis."; describe("analysis bridge", () => { beforeEach(() => { @@ -20,6 +23,58 @@ describe("analysis bridge", () => { delete tauriWindow.__TAURI_INVOKE__; }); + it("rejects an oversized native local-audio selection before it becomes project state", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue({ + projectId: "native-local-project", + sourceMode: "reference", + projectRoot: "/tmp/bandscope/projects/native-local-project", + cacheRoot: "/tmp/bandscope/cache/native-local-project", + tempRoot: "/tmp/bandscope/temp/native-local-project", + source: { + sourcePath: "/tmp/bandscope/input.wav", + fileName: "input.wav", + extension: "wav", + fileSizeBytes: MAX_LOCAL_AUDIO_FILE_BYTES + 1 + } + }); + + const selection = await selectLocalAudioSource(); + + expect(selection).toEqual({ + ok: false, + error: { + code: "invalid_request", + message: OVERSIZED_LOCAL_AUDIO_NEXT_ACTION + } + }); + }); + + it("rejects an oversized native YouTube import before it becomes project state", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue({ + projectId: "native-youtube-project", + sourceMode: "reference", + projectRoot: "/tmp/bandscope/projects/native-youtube-project", + cacheRoot: "/tmp/bandscope/cache/native-youtube-project", + tempRoot: "/tmp/bandscope/temp/native-youtube-project", + source: { + sourcePath: "/tmp/bandscope/temp/native-youtube-project/youtube.wav", + fileName: "youtube.wav", + extension: "wav", + fileSizeBytes: MAX_LOCAL_AUDIO_FILE_BYTES + 1 + } + }); + + const selection = await importYoutubeUrl("https://youtu.be/4ozX4yFUC34"); + + expect(selection).toEqual({ + ok: false, + error: { + code: "invalid_request", + message: OVERSIZED_LOCAL_AUDIO_NEXT_ACTION + } + }); + }); + it("imports a standard YouTube URL through the browser fallback when Tauri is absent", async () => { const selection = await importYoutubeUrl("https://www.youtube.com/watch?v=4ozX4yFUC34"); @@ -99,6 +154,37 @@ describe("analysis bridge", () => { expect(selection.ok).toBe(true); }); + it.each([ + "Could not read the selected audio file.", + "Could not prepare the local project workspace.", + "Could not prepare the local cache workspace.", + "Could not prepare the local temp workspace." + ])("preserves an approved native local-audio string error: %s", async (message) => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockRejectedValue(message); + + await expect(selectLocalAudioSource()).resolves.toEqual({ + ok: false, + error: { + code: "invalid_request", + message + } + }); + }); + + it("redacts an unapproved native local-audio string error", async () => { + tauriWindow.__TAURI_INVOKE__ = vi + .fn() + .mockRejectedValue("Could not read /Users/example/Music/private-demo.wav"); + + await expect(selectLocalAudioSource()).resolves.toEqual({ + ok: false, + error: { + code: "invalid_request", + message: "Choose a WAV, MP3, FLAC, or M4A file to start analysis." + } + }); + }); + it("normalizes legacy analysis job status responses before returning them", async () => { const legacyResult = createDemoRehearsalSong() as unknown as { sections: Array>; diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index bb750b34b..e2b51935e 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -7,7 +7,6 @@ import { parseAnalysisJobStatus, parseAnalysisJobRequest, parseProjectBootstrapSummary, - parseRehearsalSong, type AnalysisJobError, type AnalysisJobRequest, type AnalysisJobStatus, @@ -15,6 +14,14 @@ import { type RehearsalSong } from "@bandscope/shared-types"; import { listen } from "@tauri-apps/api/event"; +import { + createProjectDocument, + parseProjectDocument, + type ProjectDocument, + type SelectedPlaybackSource +} from "./projectDocument"; + +export type { ProjectDocument, SelectedPlaybackSource } from "./projectDocument"; type TauriInvoke = (command: string, args?: Record) => Promise; @@ -35,8 +42,14 @@ const BROWSER_PROGRESS_STEPS = [ { progressLabel: "Saving reusable features", progressStage: "persist", progressPercent: 90 } ] as const; const UNSUPPORTED_LOCAL_AUDIO_MESSAGE = "Choose a WAV, MP3, FLAC, or M4A file to start analysis."; +const LOCAL_AUDIO_TOO_LARGE_MESSAGE = "Choose a shorter or smaller song file to start analysis."; +const LOCAL_AUDIO_POLICY_MESSAGE = + "Selected audio file metadata violates the analysis resource policy."; +const MAX_LOCAL_AUDIO_FILE_BYTES = 100 * 1024 * 1024; const SAFE_LOCAL_AUDIO_MESSAGES = new Set([ UNSUPPORTED_LOCAL_AUDIO_MESSAGE, + LOCAL_AUDIO_TOO_LARGE_MESSAGE, + LOCAL_AUDIO_POLICY_MESSAGE, "Could not read the selected audio file.", "Could not prepare the local project workspace.", "Could not prepare the local cache workspace.", @@ -45,7 +58,7 @@ const SAFE_LOCAL_AUDIO_MESSAGES = new Set([ const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/; const MAX_YOUTUBE_URL_LENGTH = 2000; -export { MAX_YOUTUBE_URL_LENGTH }; +export { MAX_LOCAL_AUDIO_FILE_BYTES, MAX_YOUTUBE_URL_LENGTH }; /** Documented. */ export type LocalAudioSelectionResult = @@ -177,7 +190,7 @@ async function browserFallback(command: string, args?: Record): } if (command === "save_project") { - return; + throw new Error("Local project save is not available in browser preview."); } if (command === "import_youtube_url") { @@ -217,6 +230,34 @@ async function invokeAnalysis(command: string, args?: Record): return browserFallback(command, args); } +/** Preserve only bounded native intake diagnostics approved for buyer-visible display. */ +function localAudioErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : typeof error === "string" ? error : null; + return message && SAFE_LOCAL_AUDIO_MESSAGES.has(message) + ? message + : UNSUPPORTED_LOCAL_AUDIO_MESSAGE; +} + +/** + * Parse a native/import bootstrap and enforce policy-v1 encoded-byte parity + * before the selection is allowed to become desktop project state. + * + * Python service and descriptor checks remain authoritative for analysis; this + * bridge check is defense in depth so local-file and imported-file intake fail + * at the same 100 MiB boundary instead of waiting for a later analysis stage. + */ +function parseBoundedAudioBootstrap(response: unknown): ProjectBootstrapSummary { + const bootstrap = parseProjectBootstrapSummary(response); + const fileSizeBytes = bootstrap.source.fileSizeBytes; + if (!Number.isSafeInteger(fileSizeBytes)) { + throw new Error(LOCAL_AUDIO_POLICY_MESSAGE); + } + if (fileSizeBytes > MAX_LOCAL_AUDIO_FILE_BYTES) { + throw new Error(LOCAL_AUDIO_TOO_LARGE_MESSAGE); + } + return bootstrap; +} + /** Documented. */ export function createDefaultAnalysisRequest(): AnalysisJobRequest { return createDemoAnalysisJobRequest(); @@ -228,17 +269,14 @@ export async function selectLocalAudioSource(): Promise { - const parsedSong = parseRehearsalSong(song); - await invokeAnalysis("save_project", { payload: parsedSong }); +/** + * Persist renderer-owned project state without accepting native source identity from the WebView. + * + * Native Resource Admission owns `sourceReference` evidence. Renderer-authored + * source evidence fails before persistence IPC. When the caller owns an already- + * minted project aggregate, it may pass only that project id; Tauri resolves the + * retained publication identity and injects the path-free reference natively. + */ +export async function saveProjectDocument( + projectDocument: ProjectDocument, + projectId?: string +): Promise { + const parsedDocument = parseProjectDocument(projectDocument); + if (parsedDocument.sourceReference) { + throw new Error("Invalid project document"); + } + await invokeAnalysis("save_project", { + payload: parsedDocument, + ...(projectId === undefined ? {} : { projectId }) + }); } -/** Documented. */ -export async function loadProject(): Promise { +/** Reopen one current versioned project document, including durable Project Persistence state. */ +export async function loadProjectDocument(): Promise { const response = await invokeAnalysis("load_project"); - return parseRehearsalSong(response); + return parseProjectDocument(response); +} + +/** Compatibility save for callers that do not yet own a playback-source preference. */ +export async function saveProject( + song: RehearsalSong, + selectedPlaybackSource: SelectedPlaybackSource = "full_mix", + projectId?: string +): Promise { + await saveProjectDocument(createProjectDocument(song, selectedPlaybackSource), projectId); +} + +/** Compatibility load for existing song-only consumers while mounted reopen composition remains separate work. */ +export async function loadProject(): Promise { + return (await loadProjectDocument()).song; } diff --git a/apps/desktop/src/lib/projectDocument.plainRecord.test.ts b/apps/desktop/src/lib/projectDocument.plainRecord.test.ts new file mode 100644 index 000000000..cff1157db --- /dev/null +++ b/apps/desktop/src/lib/projectDocument.plainRecord.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { parseProjectDocument } from "./projectDocument"; + +const CONTENT_SHA256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const MAX_LOCAL_AUDIO_FILE_BYTES = 100 * 1024 * 1024; + +class ProjectDocumentWithPrototype { + song = createDemoRehearsalSong(); + preferences = { selectedPlaybackSource: "vocals" }; +} + +class ProjectPreferencesWithPrototype { + selectedPlaybackSource = "vocals"; +} + +describe("project document plain-record admission", () => { + it("rejects a project document with a custom prototype before persistence IPC", () => { + expect(() => parseProjectDocument(new ProjectDocumentWithPrototype())).toThrow( + "Invalid project document" + ); + }); + + it("rejects custom-prototype preferences even when the outer document is plain", () => { + expect(() => + parseProjectDocument({ + song: createDemoRehearsalSong(), + preferences: new ProjectPreferencesWithPrototype() + }) + ).toThrow("Invalid project document"); + }); + + it("fails closed when prototype inspection itself throws", () => { + const trappedDocument = new Proxy( + { + song: createDemoRehearsalSong(), + preferences: { selectedPlaybackSource: "vocals" } + }, + { + getPrototypeOf() { + throw new Error("prototype trap"); + } + } + ); + + expect(() => parseProjectDocument(trappedDocument)).toThrow("Invalid project document"); + }); + + it("fails closed with the public contract when own-key enumeration throws", () => { + const trappedDocument = new Proxy( + { + song: createDemoRehearsalSong(), + preferences: { selectedPlaybackSource: "vocals" } + }, + { + ownKeys() { + throw new Error("own-key trap"); + } + } + ); + + expect(() => parseProjectDocument(trappedDocument)).toThrow("Invalid project document"); + }); + + it("rejects accessor-backed preference fields without invoking the accessor", () => { + let getterCalls = 0; + const document = { + song: createDemoRehearsalSong() + } as Record; + Object.defineProperty(document, "preferences", { + enumerable: true, + get() { + getterCalls += 1; + throw new Error("preference getter must not run"); + } + }); + + expect(() => parseProjectDocument(document)).toThrow("Invalid project document"); + expect(getterCalls).toBe(0); + }); + + it("rejects accessor-backed selected-source fields without invoking the accessor", () => { + let getterCalls = 0; + const preferences = {} as Record; + Object.defineProperty(preferences, "selectedPlaybackSource", { + enumerable: true, + get() { + getterCalls += 1; + throw new Error("selected source getter must not run"); + } + }); + + expect(() => + parseProjectDocument({ + song: createDemoRehearsalSong(), + preferences + }) + ).toThrow("Invalid project document"); + expect(getterCalls).toBe(0); + }); + + it("rejects an accessor-backed source reference without invoking the accessor", () => { + let getterCalls = 0; + const document = { + song: createDemoRehearsalSong(), + preferences: { selectedPlaybackSource: "vocals" } + } as Record; + Object.defineProperty(document, "sourceReference", { + enumerable: true, + get() { + getterCalls += 1; + throw new Error("source reference getter must not run"); + } + }); + + expect(() => parseProjectDocument(document)).toThrow("Invalid project document"); + expect(getterCalls).toBe(0); + }); + + it("rejects an accessor-backed source digest without invoking the accessor", () => { + let getterCalls = 0; + const sourceReference = { + projectId: "project-400-4", + artifactName: "source.wav", + extension: "wav", + fileSizeBytes: 4096 + } as Record; + Object.defineProperty(sourceReference, "contentSha256", { + enumerable: true, + get() { + getterCalls += 1; + throw new Error("source digest getter must not run"); + } + }); + + expect(() => + parseProjectDocument({ + song: createDemoRehearsalSong(), + preferences: { selectedPlaybackSource: "vocals" }, + sourceReference + }) + ).toThrow("Invalid project document"); + expect(getterCalls).toBe(0); + }); + + it("rejects a source reference whose claimed bytes exceed the Resource Admission ceiling", () => { + expect(() => + parseProjectDocument({ + song: createDemoRehearsalSong(), + preferences: { selectedPlaybackSource: "full_mix" }, + sourceReference: { + projectId: "project-400-4", + artifactName: "source.wav", + extension: "wav", + fileSizeBytes: MAX_LOCAL_AUDIO_FILE_BYTES + 1, + contentSha256: CONTENT_SHA256 + } + }) + ).toThrow("Invalid project document"); + }); + + it("fails closed when optional source-reference descriptor inspection throws", () => { + const document = new Proxy( + { + song: createDemoRehearsalSong(), + preferences: { selectedPlaybackSource: "vocals" }, + sourceReference: { + projectId: "project-400-4", + artifactName: "source.wav", + extension: "wav", + fileSizeBytes: 4096, + contentSha256: CONTENT_SHA256 + } + }, + { + getOwnPropertyDescriptor(target, property) { + if (property === "sourceReference") { + throw new Error("source reference descriptor trap"); + } + return Reflect.getOwnPropertyDescriptor(target, property); + } + } + ); + + expect(() => parseProjectDocument(document)).toThrow("Invalid project document"); + }); + + it("admits null-prototype JSON records without widening the durable field set", () => { + const song = createDemoRehearsalSong(); + const preferences = Object.assign(Object.create(null) as Record, { + selectedPlaybackSource: "bass" + }); + const document = Object.assign(Object.create(null) as Record, { + song, + preferences + }); + + expect(parseProjectDocument(document)).toEqual({ + song, + preferences: { selectedPlaybackSource: "bass" } + }); + }); + + it("continues to admit ordinary JSON-shaped project documents", () => { + const song = createDemoRehearsalSong(); + expect( + parseProjectDocument({ + song, + preferences: { selectedPlaybackSource: "vocals" } + }) + ).toEqual({ + song, + preferences: { selectedPlaybackSource: "vocals" } + }); + }); +}); \ No newline at end of file diff --git a/apps/desktop/src/lib/projectDocument.ts b/apps/desktop/src/lib/projectDocument.ts new file mode 100644 index 000000000..89d90e666 --- /dev/null +++ b/apps/desktop/src/lib/projectDocument.ts @@ -0,0 +1,245 @@ +import { parseRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; + +/** Stable project preference persisted across sessions; never a runtime playback authority. */ +export type SelectedPlaybackSource = "full_mix" | "vocals" | "bass" | "drums" | "other"; + +/** Durable Project Persistence preferences owned by the versioned `.bscope` document. */ +export type ProjectPreferences = { + selectedPlaybackSource: SelectedPlaybackSource; +}; + +/** App-owned audio artifact identity used for process-restart re-admission. */ +export type ProjectSourceReference = { + projectId: string; + artifactName: string; + extension: "wav" | "mp3" | "flac" | "m4a"; + fileSizeBytes: number; + contentSha256: string; +}; + +/** Current renderer-facing project document admitted by the native persistence owner. */ +export type ProjectDocument = { + song: RehearsalSong; + preferences: ProjectPreferences; + sourceReference?: ProjectSourceReference; +}; + +const SELECTED_PLAYBACK_SOURCES = new Set([ + "full_mix", + "vocals", + "bass", + "drums", + "other" +]); +const PROJECT_SOURCE_EXTENSIONS = new Set([ + "wav", + "mp3", + "flac", + "m4a" +]); +const PROJECT_ID_PATTERN = /^project-\d+-\d+$/; +const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/; +const MAX_PROJECT_SOURCE_REFERENCE_BYTES = 100 * 1024 * 1024; + +type OwnDataProperty = + | { ok: true; value: unknown } + | { ok: false }; +type OptionalOwnDataProperty = + | { ok: true; present: false } + | { ok: true; present: true; value: unknown } + | { ok: false; present: false }; + +/** Accept only passive JSON-style records; prototype inspection traps fail closed. */ +function isPlainRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + + try { + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; + } catch { + return false; + } +} + +/** Confirm a record exposes exactly the allowed enumerable own keys; enumeration traps fail closed. */ +function hasOnlyKeys(value: Record, allowedKeys: readonly string[]): boolean { + try { + const keys = Object.keys(value); + return keys.length === allowedKeys.length && keys.every((key) => allowedKeys.includes(key)); + } catch { + return false; + } +} + +/** Confirm required keys exist and every enumerable own key belongs to the declared project schema. */ +function hasRequiredAndOptionalKeys( + value: Record, + requiredKeys: readonly string[], + optionalKeys: readonly string[] +): boolean { + try { + const keys = Object.keys(value); + return ( + requiredKeys.every((key) => keys.includes(key)) && + keys.every((key) => requiredKeys.includes(key) || optionalKeys.includes(key)) + ); + } catch { + return false; + } +} + +/** Read an enumerable own data property without invoking accessors; descriptor traps fail closed. */ +function ownEnumerableDataProperty(value: Record, key: string): OwnDataProperty { + try { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !("value" in descriptor)) { + return { ok: false }; + } + return { ok: true, value: descriptor.value }; + } catch { + return { ok: false }; + } +} + +/** Read an optional enumerable own data property without invoking accessors; descriptor traps fail closed. */ +function optionalOwnEnumerableDataProperty( + value: Record, + key: string +): OptionalOwnDataProperty { + try { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined) { + return { ok: true, present: false }; + } + if (!descriptor.enumerable || !("value" in descriptor)) { + return { ok: false, present: false }; + } + return { ok: true, present: true, value: descriptor.value }; + } catch { + return { ok: false, present: false }; + } +} + +/** Validate path-free app-owned audio identity before admitting it as durable project truth. */ +function parseProjectSourceReference(value: unknown): ProjectSourceReference { + if ( + !isPlainRecord(value) || + !hasOnlyKeys(value, ["projectId", "artifactName", "extension", "fileSizeBytes", "contentSha256"]) + ) { + throw new Error("Invalid project document"); + } + + const projectIdProperty = ownEnumerableDataProperty(value, "projectId"); + const artifactNameProperty = ownEnumerableDataProperty(value, "artifactName"); + const extensionProperty = ownEnumerableDataProperty(value, "extension"); + const fileSizeBytesProperty = ownEnumerableDataProperty(value, "fileSizeBytes"); + const contentSha256Property = ownEnumerableDataProperty(value, "contentSha256"); + if ( + !projectIdProperty.ok || + !artifactNameProperty.ok || + !extensionProperty.ok || + !fileSizeBytesProperty.ok || + !contentSha256Property.ok + ) { + throw new Error("Invalid project document"); + } + + const projectId = projectIdProperty.value; + const artifactName = artifactNameProperty.value; + const extension = extensionProperty.value; + const fileSizeBytes = fileSizeBytesProperty.value; + const contentSha256 = contentSha256Property.value; + if ( + typeof projectId !== "string" || + !PROJECT_ID_PATTERN.test(projectId) || + typeof extension !== "string" || + !PROJECT_SOURCE_EXTENSIONS.has(extension as ProjectSourceReference["extension"]) || + typeof artifactName !== "string" || + artifactName !== `source.${extension}` || + typeof fileSizeBytes !== "number" || + !Number.isSafeInteger(fileSizeBytes) || + fileSizeBytes <= 0 || + fileSizeBytes > MAX_PROJECT_SOURCE_REFERENCE_BYTES || + typeof contentSha256 !== "string" || + !SHA256_HEX_PATTERN.test(contentSha256) + ) { + throw new Error("Invalid project document"); + } + + return { + projectId, + artifactName, + extension: extension as ProjectSourceReference["extension"], + fileSizeBytes, + contentSha256 + }; +} + +/** + * Validate the renderer-visible project document without accepting filesystem paths, + * runtime capability URLs, generation tokens, prototype-bearing records, accessors, + * trapped record enumeration, impossible source byte claims, ambiguous source digests, + * or unknown preference/source-reference fields. + */ +export function parseProjectDocument(value: unknown): ProjectDocument { + if ( + !isPlainRecord(value) || + !hasRequiredAndOptionalKeys(value, ["song", "preferences"], ["sourceReference"]) + ) { + throw new Error("Invalid project document"); + } + + const songProperty = ownEnumerableDataProperty(value, "song"); + const preferencesProperty = ownEnumerableDataProperty(value, "preferences"); + if (!songProperty.ok || !preferencesProperty.ok || !isPlainRecord(preferencesProperty.value)) { + throw new Error("Invalid project document"); + } + + const preferences = preferencesProperty.value; + if (!hasOnlyKeys(preferences, ["selectedPlaybackSource"])) { + throw new Error("Invalid project document"); + } + + const selectedPlaybackSourceProperty = ownEnumerableDataProperty(preferences, "selectedPlaybackSource"); + if (!selectedPlaybackSourceProperty.ok) { + throw new Error("Invalid project document"); + } + const selectedPlaybackSource = selectedPlaybackSourceProperty.value; + if ( + typeof selectedPlaybackSource !== "string" || + !SELECTED_PLAYBACK_SOURCES.has(selectedPlaybackSource as SelectedPlaybackSource) + ) { + throw new Error("Invalid project document"); + } + + const sourceReferenceProperty = optionalOwnEnumerableDataProperty(value, "sourceReference"); + if (!sourceReferenceProperty.ok) { + throw new Error("Invalid project document"); + } + const sourceReference = sourceReferenceProperty.present + ? parseProjectSourceReference(sourceReferenceProperty.value) + : undefined; + + return { + song: parseRehearsalSong(songProperty.value), + preferences: { + selectedPlaybackSource: selectedPlaybackSource as SelectedPlaybackSource + }, + ...(sourceReference ? { sourceReference } : {}) + }; +} + +/** Build the exact current renderer document before crossing the native persistence boundary. */ +export function createProjectDocument( + song: RehearsalSong, + selectedPlaybackSource: SelectedPlaybackSource = "full_mix", + sourceReference?: ProjectSourceReference +): ProjectDocument { + return parseProjectDocument({ + song: parseRehearsalSong(song), + preferences: { selectedPlaybackSource }, + ...(sourceReference ? { sourceReference } : {}) + }); +} diff --git a/apps/desktop/src/lib/projectDocumentBridge.test.ts b/apps/desktop/src/lib/projectDocumentBridge.test.ts new file mode 100644 index 000000000..46b7a69b2 --- /dev/null +++ b/apps/desktop/src/lib/projectDocumentBridge.test.ts @@ -0,0 +1,187 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { + loadProjectDocument, + saveProjectDocument, + type SelectedPlaybackSource +} from "./analysis"; + +type TauriWindow = Window & { + __TAURI_INTERNALS__?: unknown; + __TAURI_INVOKE__?: unknown; +}; + +const tauriWindow = window as TauriWindow; +const SOURCE_SEMANTICS: SelectedPlaybackSource[] = [ + "full_mix", + "vocals", + "bass", + "drums", + "other" +]; +const CONTENT_SHA256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +describe("project document bridge", () => { + beforeEach(() => { + delete tauriWindow.__TAURI_INTERNALS__; + delete tauriWindow.__TAURI_INVOKE__; + }); + + it.each(SOURCE_SEMANTICS)( + "persists the stable %s source semantic without serializing runtime authority", + async (selectedPlaybackSource) => { + const invoke = vi.fn().mockResolvedValue(undefined); + tauriWindow.__TAURI_INVOKE__ = invoke; + const song = createDemoRehearsalSong(); + + await saveProjectDocument({ + song, + preferences: { selectedPlaybackSource } + }); + + expect(invoke).toHaveBeenCalledWith("save_project", { + payload: { + song, + preferences: { selectedPlaybackSource } + } + }); + } + ); + + it("rejects renderer-authored app-owned source evidence before persistence IPC", async () => { + const invoke = vi.fn().mockResolvedValue(undefined); + tauriWindow.__TAURI_INVOKE__ = invoke; + const song = createDemoRehearsalSong(); + + await expect( + saveProjectDocument({ + song, + preferences: { selectedPlaybackSource: "vocals" }, + sourceReference: { + projectId: "project-400-4", + artifactName: "source.wav", + extension: "wav", + fileSizeBytes: 4096, + contentSha256: CONTENT_SHA256 + } + }) + ).rejects.toThrow("Invalid project document"); + + expect(invoke).not.toHaveBeenCalled(); + }); + + it("returns the persisted source semantic and content identity with the reopened song", async () => { + const song = createDemoRehearsalSong(); + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue({ + song, + preferences: { selectedPlaybackSource: "vocals" }, + sourceReference: { + projectId: "project-400-4", + artifactName: "source.flac", + extension: "flac", + fileSizeBytes: 8192, + contentSha256: CONTENT_SHA256 + } + }); + + await expect(loadProjectDocument()).resolves.toEqual({ + song, + preferences: { selectedPlaybackSource: "vocals" }, + sourceReference: { + projectId: "project-400-4", + artifactName: "source.flac", + extension: "flac", + fileSizeBytes: 8192, + contentSha256: CONTENT_SHA256 + } + }); + }); + + it("rejects a revocable playback authority returned across the project boundary", async () => { + const song = createDemoRehearsalSong(); + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue({ + song, + preferences: { + selectedPlaybackSource: "bandscope-playback://project-400-4/vocals?generation=7" + } + }); + + await expect(loadProjectDocument()).rejects.toThrow("Invalid project document"); + }); + + it("rejects user paths, missing digests, and mismatched app-owned source evidence", async () => { + const song = createDemoRehearsalSong(); + for (const sourceReference of [ + { + projectId: "../escape", + artifactName: "source.wav", + extension: "wav", + fileSizeBytes: 4096, + contentSha256: CONTENT_SHA256 + }, + { + projectId: "project-400-4", + artifactName: "../source.wav", + extension: "wav", + fileSizeBytes: 4096, + contentSha256: CONTENT_SHA256 + }, + { + projectId: "project-400-4", + artifactName: "source.mp3", + extension: "wav", + fileSizeBytes: 4096, + contentSha256: CONTENT_SHA256 + }, + { + projectId: "project-400-4", + artifactName: "source.wav", + extension: "wav", + fileSizeBytes: 4096, + contentSha256: CONTENT_SHA256, + sourcePath: "/Users/example/Music/private.wav" + }, + { + projectId: "project-400-4", + artifactName: "source.wav", + extension: "wav", + fileSizeBytes: 4096 + }, + { + projectId: "project-400-4", + artifactName: "source.wav", + extension: "wav", + fileSizeBytes: 4096, + contentSha256: "0123456789abcdef" + }, + { + projectId: "project-400-4", + artifactName: "source.wav", + extension: "wav", + fileSizeBytes: 4096, + contentSha256: "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF" + } + ]) { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue({ + song, + preferences: { selectedPlaybackSource: "full_mix" }, + sourceReference + }); + + await expect(loadProjectDocument()).rejects.toThrow("Invalid project document"); + } + }); + + it("rejects unknown preference fields instead of creating a second writable project contract", async () => { + const song = createDemoRehearsalSong(); + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue({ + song, + preferences: { + selectedPlaybackSource: "bass", + runtimeAuthority: "bandscope-playback://project-400-4/bass?generation=7" + } + }); + + await expect(loadProjectDocument()).rejects.toThrow("Invalid project document"); + }); +}); diff --git a/apps/desktop/src/lib/projectDocumentSaveAuthority.test.ts b/apps/desktop/src/lib/projectDocumentSaveAuthority.test.ts new file mode 100644 index 000000000..9519f3d5d --- /dev/null +++ b/apps/desktop/src/lib/projectDocumentSaveAuthority.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { saveProjectDocument } from "./analysis"; + +type TauriWindow = Window & { + __TAURI_INTERNALS__?: unknown; + __TAURI_INVOKE__?: unknown; +}; + +const tauriWindow = window as TauriWindow; +const CONTENT_SHA256 = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +describe("project document save authority", () => { + beforeEach(() => { + delete tauriWindow.__TAURI_INTERNALS__; + delete tauriWindow.__TAURI_INVOKE__; + }); + + it("fails closed when browser preview has no durable project-save authority", async () => { + await expect( + saveProjectDocument({ + song: createDemoRehearsalSong(), + preferences: { selectedPlaybackSource: "full_mix" } + }) + ).rejects.toThrow("Local project save is not available in browser preview."); + }); + + it("forwards only an explicit project-id selector beside renderer-owned save state", async () => { + const invoke = vi.fn().mockResolvedValue(undefined); + tauriWindow.__TAURI_INVOKE__ = invoke; + const document = { + song: createDemoRehearsalSong(), + preferences: { selectedPlaybackSource: "full_mix" as const } + }; + + await saveProjectDocument(document, "project-400-4"); + + expect(invoke).toHaveBeenCalledWith("save_project", { + payload: document, + projectId: "project-400-4" + }); + }); + + it("rejects renderer-authored source identity before persistence IPC", async () => { + const invoke = vi.fn().mockResolvedValue(undefined); + tauriWindow.__TAURI_INVOKE__ = invoke; + + await expect( + saveProjectDocument({ + song: createDemoRehearsalSong(), + preferences: { selectedPlaybackSource: "full_mix" }, + sourceReference: { + projectId: "project-400-4", + artifactName: "source.wav", + extension: "wav", + fileSizeBytes: 4096, + contentSha256: CONTENT_SHA256 + } + }) + ).rejects.toThrow("Invalid project document"); + + expect(invoke).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index e7e56d311..c07dafd51 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -37,6 +37,7 @@ GitHub is the source of truth for repository governance, PR review, CI/CD, Code - treat files, URLs, models, caches, and release artifacts as untrusted inputs - route orchestration through typed Tauri IPC and a narrow Python subprocess bridge before considering any loopback HTTP surface - bootstrap local audio projects by validating the selected file in Rust, then passing only typed source metadata through the orchestration boundary +- before Python decoders transform source audio, preflight the already-open container handle through the shared `audio_resource_policy` source-rate/channel/duration contract, then rewind it for decoding - keep project and temp/cache bootstrap roots under Tauri-resolved app-owned directories rather than the shared OS temp namespace ## CI/CD and release flow diff --git a/docs/doctoring/audio-resource-policy.md b/docs/doctoring/audio-resource-policy.md new file mode 100644 index 000000000..f87d98fe0 --- /dev/null +++ b/docs/doctoring/audio-resource-policy.md @@ -0,0 +1,35 @@ +# Audio resource policy evidence + +## Scope + +This note records the external evidence used by BandScope's versioned local-audio resource policy. It is implementation doctoring, not a claim that BandScope is certified against any external standard. + +The current Python policy accepts at most 100 MiB of encoded local-audio input and at most 900 seconds of mono decoded audio at 44.1 kHz, which is 39,690,000 samples. The canonical decoded NumPy buffer is additionally bounded to 317,520,000 bytes (39,690,000 samples × 8 bytes), so a decoder cannot stay under the sample ceiling while expanding the admitted in-memory artifact beyond the policy's explicit mono-buffer budget. Request metadata is an early rejection hint only: the decoder boundary still checks the opened descriptor's actual size. Before any `librosa.load(..., sr=..., mono=True, duration=...)` transformation, `soundfile.info` inspects the already-open source handle and the canonical policy rejects malformed headers, source rates below 8 kHz or above 192 kHz, source channel counts outside mono/stereo, and source duration beyond the path's limit; a successful probe rewinds the same handle. Decoder calls request one sample beyond the accepted duration and the returned waveform is then validated as a one-dimensional, non-empty, finite floating-point array at exactly the target rate and within both the accepted sample count and decoded-buffer byte budget before beat tracking, transcription, or Demucs inference. Policy construction also rejects byte, memory, rate, duration, or derived sample-count limits that cannot be represented within the host's bounded integer/sample-count model, so extreme integer configuration cannot escape through Python-to-float conversion overflow. YouTube import uses the same encoded-byte ceiling: yt-dlp `max_filesize`, a progress hook that aborts once `downloaded_bytes` / `total_bytes` / `total_bytes_estimate` exceed 100 MiB, a pre-download reject on announced `filesize` / `filesize_approx`, and a post-download `AudioResourcePolicy` check that deletes the artifact. Before post-download duration/size checks, cleanup, or success metadata can use the yt-dlp result, the completed path is canonicalized and required to remain strictly beneath the current import `out_dir`; a foreign or escaped path fails closed without being deleted. Native local-file and YouTube bootstrap then re-check the filesystem-observed length before storing project state. + +## Evidence-to-control mapping + +| Evidence | BandScope control | +| --- | --- | +| CWE-770 recommends explicit minimum/maximum resource expectations and limiting resources reachable by unprivileged actors. | `AudioResourcePolicy` makes encoded bytes, decoded samples, decoded mono-buffer bytes, sample rate, numeric dtype, shape, finiteness, and checked limit arithmetic explicit fail-closed invariants. CWE-770 is the more specific mapping-friendly weakness beneath the broader CWE-400 resource-consumption class. | +| OWASP ASVS v5.0.0-5.1.1 requires file-handling documentation to define accepted types/extensions and maximum size; v5.0.0-5.2.1 requires accepting only file sizes that can be processed without performance loss or denial of service. | BandScope documents and enforces a finite encoded-byte ceiling before decode, while retaining authoritative descriptor checks at the actual file boundary. ASVS targets web applications/services, so BandScope uses these requirements as security-engineering guidance rather than claiming ASVS conformance for the desktop product. | +| librosa 0.11.0 documents `load(..., duration=...)` as loading only up to the requested duration and returning an ndarray plus the resulting sample rate. | Temporal analysis and stem separation request `max_duration + one sample` as a probe, then reject any returned waveform whose exact decoded sample count or in-memory byte size exceeds the accepted limits. The post-decode check remains authoritative because a duration argument alone is not treated as proof of resource-policy compliance. | +| python-soundfile 0.13.1 documents `soundfile.info(file)` as returning container information, including sample rate, channels, duration, and frame count, without reading the decoded waveform. | `audio_metadata.preflight_audio_metadata` uses the already-open handle for source metadata admission, applies the shared rate/channel/duration policy, and rewinds the handle before `librosa.load`; parser and rewind failures become the canonical payload-free policy error. | +| yt-dlp documents `max_filesize` as a FileDownloader parameter that skips files larger than the configured byte count, and `progress_hooks` as callbacks that receive `downloaded_bytes`, `total_bytes`, and `total_bytes_estimate`. | YouTube import sets `max_filesize` to `DEFAULT_MAX_ENCODED_FILE_BYTES`, aborts from the progress hook when those byte fields exceed the ceiling, validates that the completed path remains inside the per-import output directory, and still revalidates the written file with the canonical policy so a missing or lying size announcement cannot bypass admission. | + +## Residual risk and follow-up + +This policy now bounds Python source-container admission, decode/model entry by decoded sample count and decoded mono-buffer memory, and native local-file bootstrap plus YouTube download/bootstrap encoded-byte admission. In-flight abort also deletes owned `.part`, `.ytdl`, and `-Frag*` siblings that stay inside that import's output directory; paths that escape the directory are ignored. The completed-path containment check is a point-in-time canonical path check and does not claim descriptor/handle-level race freedom if a privileged local actor replaces filesystem entries after validation. The decoded-memory limit covers the admitted canonical NumPy audio artifact only; it does not claim to bound downstream temporary arrays, PyTorch tensors, model weights, or accelerator allocations. Remaining #781 work is explicit per-job CPU/GPU/VRAM admission budgets, cancellation/resource measurements, and whole-product CPU/GPU parity evidence. Do not treat a post-download-only size check as sufficient: in-flight abort and owned-partial deletion must stay in place so an unknown-size transfer cannot fill the cache root. + +## References + +librosa development team. (2025). *librosa.load (librosa 0.11.0)* [Documentation]. https://librosa.org/doc/0.11.0/generated/librosa.load.html + +python-soundfile contributors. (2025). *python-soundfile 0.13.1: `soundfile.info`* [Documentation]. https://python-soundfile.readthedocs.io/en/latest/ + +MITRE Corporation. (2026, April 30). *CWE-770: Allocation of resources without limits or throttling (Version 4.20).* Common Weakness Enumeration. https://cwe.mitre.org/data/definitions/770.html + +OWASP Foundation. (2025, May). *OWASP Application Security Verification Standard 5.0.0.* https://github.com/OWASP/ASVS/tree/v5.0.0_release/5.0 + +yt-dlp contributors. (2026). *FileDownloader parameters (`max_filesize`)* [Source documentation]. https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/downloader/common.py + +yt-dlp contributors. (2026). *YoutubeDL `progress_hooks`* [Source documentation]. https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py diff --git a/docs/doctoring/local-audio-source-materialization.md b/docs/doctoring/local-audio-source-materialization.md new file mode 100644 index 000000000..9e9fa7849 --- /dev/null +++ b/docs/doctoring/local-audio-source-materialization.md @@ -0,0 +1,128 @@ +# Local audio source materialization + +## Problem + +BandScope originally validated an OS-selected local audio file and then let later analysis reopen the canonical external filesystem path. That left analysis and restart dependent on mutable host authority: the selected file could be moved, replaced, truncated, or grown after admission. Project Persistence #962 also needs a durable source identity that does not serialize an arbitrary user filesystem path. + +Resource Admission & Decode therefore owns creation and verification of the app-owned `source.` artifact and the native content identity for that publication. Project Persistence owns the later versioned project reference that consumes this evidence; it does not copy or hash user media itself. + +The hardening sequence exposed distinct defects: + +- source-read and app-owned destination-write failures were initially collapsed into one diagnosis; +- the one-byte over-limit probe was initially written into the disposable stage; +- the bounded copy returned only a byte count, so there was no native identity for the exact bytes written; +- SHA-256 existed in more than one security-sensitive implementation and initially had no reusable reader-only core port; +- a staging receipt alone did not prove that the final published object still contained the same bytes; +- publication verification initially read against the product-wide 100 MiB ceiling instead of the receipt's tighter expected length; +- the production Tauri materializer initially discarded the receipt and stayed on the byte-count-only adapter; +- publication initially used `destination.exists()` followed by overwrite-capable `rename`, creating a check-then-act clobber window; +- even after publication verification existed, Project Persistence still had no typed path-free handoff value for `projectId + artifactName + extension + fileSizeBytes + contentSha256`; +- after that type existed, the production selector still discarded the verified identity instead of retaining it in native state for the persistence owner. + +The canonical #866 branch now repairs those defects through native retention. Production local-file materialization consumes the native receipt, synchronizes the stage, publishes with a same-filesystem no-clobber hard link, removes the private stage name, verifies the published bytes, derives `LocalAudioPublicationIdentity` from that verified receipt, and retains the path-free value in native Tauri state keyed by the locally minted project id before returning bootstrap authority. The strict analysis-runtime `LocalAudioSource` wire remains unchanged. + +The remaining integration is now across the owning persistence boundary rather than the intake copy/hash path: #970 must consume this native identity when constructing durable `sourceReference`, and restart must re-admit the app-owned artifact before fresh playback authority is minted. Platform-atomic no-follow descriptor acquisition, parent-directory crash durability, YouTube durable-source policy, and decoder licensing remain separate open work. + +## Constraints and invariants + +- Local analysis remains local-first; this boundary adds no network authority. +- Renderer input never selects an arbitrary analysis or persistence path. +- The encoded-byte ceiling remains exactly 100 MiB. +- Metadata length before copying is not final evidence when the selected source can change during admission. +- Source-read failure and app-owned write/publication failure remain distinguishable without exposing paths or raw OS errors. +- `Interrupted` reads are retried. +- SHA-256 covers only byte slices whose staging writes succeeded. The one-byte growth probe is not admitted content and is not hashed into the receipt. +- SHA-256 is content-identity/correctness evidence only. This code does not claim CAVP validation, FIPS 140 validation, authenticity, or protection against an actor who can replace both artifact and stored digest. +- Reusable SHA-256 and publication-verification APIs accept caller-owned `Read` values and acquire no path authority. +- Publication verification consumes at most `expected.file_size_bytes + 1` bytes and rejects invalid expected lengths before reading. +- Publication must not overwrite an existing app-owned source name. Same-project hard-link publication fails closed when the destination exists or the filesystem cannot provide that primitive; it does not fall back to overwrite-capable rename. +- The analysis-runtime `LocalAudioSource` contract remains `sourcePath + fileName + extension + fileSizeBytes`. `contentSha256` is not injected into that strict Rust/TypeScript/Python request without a versioned contract change. +- The persistence identity is a distinct contract. It contains exactly `projectId + artifactName + extension + fileSizeBytes + contentSha256`; it contains no `path` or `sourcePath` field. +- The persistence identity accepts only an existing BandScope project-id grammar, canonical lowercase admitted extension, byte size `1..=100 MiB`, and exactly 64 lowercase hexadecimal SHA-256 characters. `artifactName` is derived as `source.` rather than accepted from renderer input. +- Verified persistence identity is retained only in native Tauri state keyed by the minted project id. The renderer does not author or supply that evidence. +- If native identity state cannot be retained, local-source selection fails closed rather than returning bootstrap authority without persistence evidence. +- Portable `symlink_metadata` / open / re-check logic narrows linked-object substitution but does not claim atomic `O_NOFOLLOW` or Windows reparse-point-equivalent semantics. +- The parent project directory is not yet explicitly synchronized after destination-link creation and stage unlink, so power-loss durability of the directory entries is not claimed. + +## Decision record + +1. Keep the external canonical path and revalidate before every analysis — rejected. Restart and persistence would still depend on mutable host authority. +2. Persist the absolute external path — rejected. It widens disclosure and violates #962's path-free direction. +3. Copy the selected file into app-owned `source.` — selected. Later analysis can use BandScope-owned authority. +4. Keep `std::io::copy` and one generic error — rejected. Explicit bounded read/write preserves the ceiling while distinguishing source and destination failures. +5. Hash later in the renderer or from the original path — rejected. Neither is authoritative for bytes actually staged into BandScope storage. +6. Add another SHA-256 implementation in persistence or Active Player — rejected. `bandscope_desktop_core::sha256_hex_reader` is the reader-only Shared Kernel. +7. Treat the staging receipt as publication truth without rereading — rejected. Same-size mutation would evade byte-count checks. +8. Re-read every published object up to 100 MiB — rejected. The native receipt gives a tighter expected length. +9. Leave the Tauri caller on `copy_bounded_local_audio -> u64` — rejected. Production publication must retain native size+digest evidence and verify the publication before bootstrap authority is returned. +10. Check `destination.exists()` and then rename the stage — rejected. On overwrite-capable rename semantics the sequence is racy. +11. Create the destination with `std::fs::hard_link(stage, destination)` and remove the private stage name — selected for the same-filesystem project root. It creates the destination without clobbering an existing name and keeps the synchronized bytes unchanged. +12. Add `contentSha256` to the existing analysis `LocalAudioSource` payload — rejected. Python admission is strict and this would mix persistence evidence with a narrower runtime request. +13. Define a separate path-free `LocalAudioPublicationIdentity` whose artifact name is derived from canonical native evidence — selected. This keeps Resource Admission as the copy/hash authority and gives #970 a serializable persistence input without absolute paths. +14. Return bootstrap authority while leaving the verified identity only in a local stack variable — rejected. The selector now retains the typed identity in native Tauri state keyed by project id before returning; #970 can adopt that native evidence without trusting renderer-authored digest/path data. + +## Implementation and exact evidence + +The cumulative hardening remains test-first where behavior changed: + +- `dbeee9c7407c72f999f584eb0eb9342ddc39fddd` adopted protected `develop@314ddeae7b775a4957594b599358c8255617eb2e` through ordinary non-force ancestry. +- RED `804a2867e877947feaffb1da6c6072e6a49049fe` and fix `0beee45b98e51ba46b571a82c6d0d93db61ea8d6` established exact-limit acceptance and one-byte-over rejection. +- `a2b1bd9e33a69be75f813f005abd37345200ce55` moved successful local-file intake to an app-owned same-project stage; `323a7fac00c4954af12b382802a9d6f8359ef4c5` exported the core port to Tauri. +- Diagnostics RED `131d6d7220985abd207559e6eb5dc122ac989cf4` and fix `ac4adfdb5df82f48aadd5e028433e3336d3ce2ae` separated source-read and destination-write failures and made the one-byte over-limit check read-only. +- Content-identity RED `dc413794fb84c736085ab77b763854ba0f58bdf1` and fix `566cd1f991296e7f3c288cb07a11c2d2effb258a` introduced `LocalAudioCopyReceipt { file_size_bytes, content_sha256 }`. +- Shared-kernel RED `373824c7bbb40f2df1bb2721316680378c104834` and fix `d1ba40683772019577fec4d8c767ff8b23294e38` exposed reader-only `sha256_hex_reader`. +- Publication RED `fdfdd7003b8a9162f846dcf22ffe66a3afd5f47e` and fix `a1c85cbfbdc7051169f097e8ad235e3bbac439d3` introduced `verify_local_audio_publication_receipt`; `20e7faaddd619c6cbd053876ca6de27b9933a4a2` exported it. +- Bounded-verification RED `6a0692ee288d3b126bd0598e07e03c88a702d567` and fix `c65a9fd312f4d67e6d1cad83b80b1213e692c8dd` changed publication verification to stop after expected bytes plus one growth probe. +- Production-integration RED `ed9fe7eba6261753dc0f68e820e2b642703fe2cd` and fix `bdf8f87d5e5c9db423537c7633e7ff4b92bec5b6` moved the Tauri materializer onto native receipt + publication verification. +- No-clobber RED `45b1f72abeded4e478775d31085244621f68c9f0` and fix `eb972e951ef090c92b595c752b18d66f11f6b96e` replaced check-then-rename with same-filesystem hard-link publication. +- Path-free handoff RED `bad908c83bfb89f545f0f2f637d96ac8fdfa3e0e` requires exact camelCase serialization of the five persistence fields, no path fields, and fail-closed rejection of invalid native evidence. +- Path-free handoff fix `87bdeea92d3bb6dc45eb666f422bd8a3d36f3872` adds `LocalAudioPublicationIdentity` and `build_local_audio_publication_identity`; export `344a9a39f32ac40b3e137c76e2cfd46243827bb5` makes the contract available from `bandscope_desktop_core` to the #970 owner. +- An earlier exploratory retention RED `cbfa967b16e94f2d84940665ce38537075a8ce41` was intentionally neutralized by `d8c57ce1d64d0bc9963219740aeaa83d9569a90b` rather than leaving a known failing head; those two commits add no production claim. +- Production native-retention RED `106ae75cad85553e56964a9844ea7a01f6ce456c` requires the materializer to derive the typed identity from the verified receipt, the selector to store it in native state, and Tauri to register that state. +- Native-retention fix `e4e2ba734bc80304a754ce2eb52e473fd9ee3631` returns `LocalAudioSourcePayload + LocalAudioPublicationIdentity` from materialization, stores the identity in `LocalAudioPublicationIdentityState` before bootstrap authority is returned, and registers the native state with the Tauri runtime. + +The SHA-256 implementation is checked against standard known-answer vectors including the empty message, `abc`, the multi-block vector, and one million `a` bytes. Those are correctness regressions, not validation-module evidence. + +## Security Notes + +The selected audio path, file metadata, and media bytes are untrusted. The OS file dialog supplies initial user authority; BandScope uses that path only to canonicalize and open the source. The project-owned artifact is the authority after successful admission. + +The production Tauri materializer synchronizes the stage, creates the destination through a no-clobber same-filesystem hard link, removes the private stage name, requires regular/non-symlink path observations, opens the publication, checks descriptor size, verifies exact receipt equality, and performs a post-verification path check. Publication mismatch or read failure is normalized to the bounded project-workspace diagnosis; source/destination paths, raw OS errors, and audio bytes are not exposed. + +`LocalAudioPublicationIdentity` does not acquire filesystem authority. It converts already verified native evidence into a deterministic, path-free value for the persistence boundary. Invalid project ids, extensions, byte counts, or digest encodings fail closed. Production local-file selection now retains that value in native Tauri state before returning the ordinary bootstrap summary, so the renderer does not need to invent a digest or persist a host path. Durable project serialization and restart re-admission remain #970 responsibilities. + +No new logging, telemetry, network transfer, or raw-media export is introduced. The SHA-256 receipt and publication identity are non-secret content identity. + +## Test and acceptance points + +- exact 100 MiB encoded-byte limit accepted; one byte over rejected; +- empty source rejected; +- source-reader and destination-writer failures remain distinct and path-safe; +- `Interrupted` reads retry without changing identity; +- failed writes cannot return a partial receipt; +- the growth probe is neither staged nor hashed; +- unchanged published bytes reproduce the staging receipt; +- same-size mutation, truncation, growth, or publication-read failure fails closed; +- grown publication stops after expected bytes plus one probe; +- production publication cannot use existence-check plus overwrite-capable rename; +- production Tauri local-file materialization consumes receipt and publication-verification ports, not the compatibility byte-count adapter; +- path-free identity serializes exactly the five persistence fields and cannot serialize `path`/`sourcePath`; +- invalid project ids, uppercase/unsupported extensions, zero/oversized byte counts, and noncanonical SHA-256 encodings are rejected; +- production local-file selection derives identity from the verified receipt and retains it in registered native Tauri state before returning bootstrap authority; +- hosted Rust/Tauri, Windows, macOS, security, SBOM, coverage/package, and independent-review evidence must be reacquired on the final exact #866 head. + +Synthetic arrays or source-text checks do not substitute for production scientific acceptance. Rights-cleared real decoded audio still has to exercise the integrated Windows/macOS intake/decode/analysis/playback path where the relevant commercial claim is made. + +## Remaining risks and follow-up + +The local-file path now has two separate native contracts: `LocalAudioCopyReceipt` proves the exact bytes staged/published, and retained `LocalAudioPublicationIdentity` represents the path-free durable evidence intended for Project Persistence. The next cross-owner step is for #970 to consume that retained identity when writing `sourceReference`; it must not reconstruct digest/path evidence from renderer JSON or re-hash the user's original media. + +After #970 persists `projectId + artifactName + extension + fileSizeBytes + contentSha256`, restart must resolve only the app-owned artifact, re-establish regular/no-link containment, bounded size/SHA-256 and applicable decode admission, reconstruct a fresh bootstrap, and only then let #1160 combine persisted `selectedPlaybackSource` intent with fresh native stem availability. Missing preferred stems fail closed to Full mix. + +When #866 enters #1160 ancestry, the private playable-stem SHA-256 implementation should be deleted in favor of `bandscope_desktop_core::sha256_hex_reader` while preserving stem identity/error tests. YouTube intake still uses its owned cache artifact and needs an explicit durable-source promotion decision. Platform-atomic no-follow acquisition and parent-directory crash durability remain Resource Admission/platform work. Issue #1129 remains the commercial decoder-dependency gate. + +## References + +National Institute of Standards and Technology. (2015). *Secure Hash Standard (SHS)* (FIPS PUB 180-4). https://doi.org/10.6028/NIST.FIPS.180-4 + +National Institute of Standards and Technology. (2023, March 7). *Decision to revise FIPS 180-4, Secure Hash Standard (SHS).* https://csrc.nist.gov/news/2023/decision-to-revise-fips-180-4 diff --git a/docs/engineering/local-project-format.md b/docs/engineering/local-project-format.md index 4c4368f2c..980f5d743 100644 --- a/docs/engineering/local-project-format.md +++ b/docs/engineering/local-project-format.md @@ -1,33 +1,91 @@ # Local Project Format -This document specifies the format and lifecycle of a BandScope `.bscope` project file, focusing on data persistence, manual overrides, and recovery. +This document specifies the format and lifecycle of a BandScope `.bscope` project file, focusing on data persistence, manual overrides, durable rehearsal preferences, source re-admission, and recovery. ## Overview -BandScope projects are saved as `.bscope` files. These files are standard JSON containing the serialized `RehearsalSong` data structure. They allow users to persist the results of audio analysis and their manual corrections (overrides) across sessions. +BandScope projects are saved as `.bscope` files. Current writes use a strict JSON envelope with `projectFormatVersion: 3`. The nested `song` remains the compatibility view used by the desktop rehearsal contract, `preferences` stores durable rehearsal UI intent, and the optional `sourceReference` is the typed handle for locating and verifying an app-owned full-mix artifact after process restart. + +Version 2, version 1, and older raw `RehearsalSong` JSON remain supported inputs. Version 2 is migrated with its existing `preferences` and no invented source reference. Version 1 and legacy song JSON are migrated with `preferences.selectedPlaybackSource = "full_mix"` and no source reference. A migration does not infer a source artifact that the historical file never recorded. ## Schema -The primary data structure for a `.bscope` file is the `RehearsalSong` type from `@bandscope/shared-types`. +The rehearsal content inside `song` is the `RehearsalSong` contract from `@bandscope/shared-types`. -### Top-Level Structure +### Top-Level Structure (version 3) ```json { - "id": "string", - "title": "string", - "sections": [ ... ], - "exportSummary": { - "format": "cue-sheet", - "headline": "string", - "focusSections": ["string"] + "projectFormatVersion": 3, + "song": { + "id": "string", + "title": "string", + "tempo": 120, + "sections": [ ... ], + "exportSummary": { + "format": "cue-sheet", + "headline": "string", + "focusSections": ["string"] + }, + "collaboration": { + "syncMode": "local_only", + "syncNote": "string", + "assignments": [ ... ], + "comments": [ ... ], + "approvals": [ ... ] + } + }, + "preferences": { + "selectedPlaybackSource": "full_mix" + }, + "sourceReference": { + "projectId": "project-400-4", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 4096, + "contentSha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" } } ``` +`selectedPlaybackSource` is a closed durable semantic with exactly these values: `full_mix`, `vocals`, `bass`, `drums`, or `other`. It is not a media URL, local path, generation receipt, or native playback authority. An opaque `bandscope-playback` authority is runtime-only and must never appear in a `.bscope` file. + +`sourceReference` is optional because historical projects and compatibility callers do not have enough evidence to invent one. When present, it is restricted to an opaque BandScope `projectId`, the fixed app-owned artifact name `source.`, one of `wav | mp3 | flac | m4a`, a non-zero byte length, and a canonical lowercase SHA-256 digest of the admitted app-owned source bytes. It contains no source path. `fileSizeBytes` is bounded preflight evidence; it is not sufficient content identity. `contentSha256` must be recomputed by native Resource Admission before an app-owned source is accepted after restart. The current source-reference schema is therefore a prerequisite for process-restart re-admission; it does not by itself prove that Resource Admission has already materialized or reopened the corresponding artifact. + +The native `save_project`/`load_project` commands admit and return the complete typed current document, and the TypeScript Project Persistence adapter exposes `saveProjectDocument`/`loadProjectDocument` with the same closed preference/source-reference domains. Existing song-only `saveProject`/`loadProject` callers remain compatibility adapters and do not invent a source reference. The mounted Active Player still has to compose its selected semantic and current source reference into this bridge, then resolve the reopened semantic through freshly re-admitted native source availability. + +`tempo` and `collaboration` are optional song fields. The native persistence boundary preserves the current shared collaboration contract and its assignment/comment/approval state domains. Role records also preserve optional `harmonicExplanation`, `transpositionPlan`, `transcription`, and integer `practiceProgress` from 0 through 100. These fields are typed project data; unknown fields still fail closed rather than being retained in an untyped JSON bag. + +The project format version is independent of the application package version. Version 3 rejects unknown envelope fields, invalid preference tokens, user-path-shaped source reference fields, mismatched artifact names/extensions, invalid project ids, zero-length source evidence, and non-canonical or missing SHA-256 content identity. A well-formed unsupported future version returns an explicit unsupported-version error before its body is interpreted as current truth. + +Checked-in compatibility evidence: + +- `apps/desktop/core/testdata/project-v1.json` — supported version-1 input. +- `apps/desktop/core/testdata/project-v2.json` — supported version-2 document with an explicit `vocals` preference. +- `apps/desktop/core/tests/project_format_v2_playback_preference.rs` — legacy/v1 migration and closed preference-domain contracts. +- `apps/desktop/core/tests/project_format_v2_fixture.rs` — version-2 fixture migration and current serialization. +- `apps/desktop/core/tests/project_format_v3_source_reference.rs` — current source-reference round trip, v2 migration, path/shape rejection, and canonical SHA-256 requirements. +- `apps/desktop/src/lib/projectDocumentBridge.test.ts` — renderer/native bridge contract for stable source semantics and source-reference admission. +- `apps/desktop/src/lib/projectDocument.plainRecord.test.ts` — passive JSON-record admission, including accessor/proxy rejection without executing getters. + +### Historical migration + +Version 1 had the shape below and did not contain project-level preferences: + +```json +{ + "projectFormatVersion": 1, + "song": { ... } +} +``` + +Version 2 added only the typed preferences section. The ordered v1 → v2 migration created `preferences.selectedPlaybackSource = "full_mix"`; legacy raw-song input followed the same rule. Version 3 retains that preference and adds no source reference unless one is explicitly supplied by the current Resource Admission/Project Persistence contract. Serializing any supported predecessor writes the current version-3 envelope, so reopening the result does not rerun heuristic inference. + +The SHA-256 requirement was tightened while version 3 remained Draft/unreleased in #970. No released BandScope project format has depended on the earlier size-only v3 draft. This avoids creating a second same-version interpretation and keeps the future released v3 contract singular. + ### Sections and Roles -Sections describe structural segments of the song (e.g., Intro, Verse, Chorus). Each section contains a list of roles (instruments or vocals). +Sections describe structural segments of the song (for example Intro, Verse, or Chorus). Each section contains a list of roles. ```json { @@ -45,7 +103,7 @@ Sections describe structural segments of the song (e.g., Intro, Verse, Chorus). ### Manual Overrides -To ensure provenance preservation, BandScope records when a user manually changes an analyzed property. This is stored in the `manualOverrides` array on the `RehearsalRole` object. +BandScope records user corrections in the `manualOverrides` array on a `RehearsalRole` so an analyzed value is not confused with user-owned rehearsal truth. ```json { @@ -66,20 +124,30 @@ To ensure provenance preservation, BandScope records when a user manually change }, "source": "user" } - ], - ... + ] } ``` -By retaining `manualOverrides`, BandScope can distinguish between original model outputs and user corrections, meeting the provenance requirements for the product. +## Security Notes + +When loading `.bscope` files from disk, BandScope applies these constraints: + +1. **Size limit** — a project file may not exceed 5 MiB (`5 * 1024 * 1024` bytes) at the current Tauri persistence boundary. +2. **Strict schema validation** — current and historical envelopes plus the rehearsal song contract reject unknown fields according to their published compatibility rule. Playback preference, source reference, collaboration state, provenance, cue, role, export, and progress domains are typed rather than arbitrary strings. +3. **Bounded processing** — project JSON is parsed as data only. The format contains no executable code or runtime playback URL. +4. **Runtime-authority separation** — a selected playback source is stored only as a stable semantic. Reopening must request a fresh native authority from current resource availability rather than trusting persisted media capability data. +5. **Filesystem-authority separation** — `sourceReference` cannot carry an absolute/relative user path. Native code must derive any app-owned artifact path from the validated project id and fixed artifact basename and validate the artifact without following untrusted path input. +6. **Content-identity separation** — `fileSizeBytes` is not treated as identity. Re-admission must compare the bounded byte length and recompute SHA-256 over the derived app-owned audio before the source is accepted. The persisted digest is required to be exactly 64 lowercase hexadecimal characters, but syntactic validity alone never grants file authority. +7. **Purpose-bound metadata** — the source reference does not persist the user's original filesystem location. Project id, fixed artifact name, byte length, and SHA-256 exist only to locate and verify BandScope-owned audio needed for rehearsal reopen. + +## Current boundary and next migration slices + +Version 3 establishes the durable source-reference schema and renderer/native admission contract. It does **not** complete source re-admission. Current local intake still keeps the selected source/bootstrap authority in process memory and must be changed so Resource Admission materializes the full mix under the app-owned project namespace before a valid `sourceReference` can be written. That publication must compute `contentSha256` from the exact bytes that become app-owned truth. Reopen must then derive that artifact from the validated reference, verify regular/no-link status, compare the recorded byte length, recompute SHA-256, rerun audio admission/decode checks, reconstruct a fresh bootstrap, and only afterward let Active Player resolve `selectedPlaybackSource` against current stem availability. -## Security Constraints +The source artifact itself must not be represented by an arbitrary filesystem path in the project file. A WebView `localStorage`/session store, a serialized `bandscope-playback` URL, or a copied external absolute path would create a second authority and is not an acceptable substitute. If the durable full-mix artifact is absent, differs from the recorded digest, or fails re-admission, the UI must report that state rather than silently presenting a stale stem selection. -When loading `.bscope` files from disk, BandScope applies the following constraints: -1. **Size Limits**: The project file must not exceed an upper bound (currently enforced at 5MB in Tauri backend) to prevent memory exhaustion. -2. **Schema Validation**: The loaded JSON is structurally validated against the `RehearsalSong` contract. -3. **Bounded Processing**: The JSON parsing is standard and safe, avoiding arbitrary code execution or payload expansion attacks. +The remaining Project Persistence work also includes bounded autosave, known-good backup rotation, startup recovery discovery, accessible Restore / Compare / Discard UX, descriptor-bound parent authority, deterministic migration receipts/hashes, downgrade/rollback behavior, and exhaustive interruption/disk-full/power-loss fault injection. ## Extensibility -Future updates to the `.bscope` format should be backward-compatible where possible, adding new fields to the `RehearsalSong` contract rather than breaking existing fields. If structural changes are required, a format version field may be introduced. +Each future `.bscope` version must have an ordered deterministic migration from every supported predecessor, validate a copy before publication, retain the prior known-good artifact until the migrated document opens successfully, and add machine-verifiable fixture/evidence. Unknown fields must either be explicitly preserved by a typed schema or rejected; they must never be silently discarded. diff --git a/docs/plans/2026-03-10-bandscope-cross-platform-build.md b/docs/plans/2026-03-10-bandscope-cross-platform-build.md index 3a02aa3a5..8a0b9f5de 100644 --- a/docs/plans/2026-03-10-bandscope-cross-platform-build.md +++ b/docs/plans/2026-03-10-bandscope-cross-platform-build.md @@ -8,40 +8,44 @@ **Tech Stack:** GitHub Actions, npm, uv, Rust stable toolchain, Python packaging sanity, zip artifacts, SHA-256 checksums. -**Security Notes:** Cross-platform builds are supply-chain and release-integrity controls. The harness must fail if Windows or macOS coverage, artifact upload, checksum generation, or required-check intent drifts out of policy. +## Security Notes -## Attack surface +Cross-platform builds are supply-chain and release-integrity controls. The harness must fail if Windows or macOS coverage, artifact upload, checksum generation, or required-check intent drifts out of policy. + +### Attack surface - Windows and macOS packaging paths - native dependencies and bundled binaries per OS - release artifact generation and upload -## Trust boundary +### Trust boundary - target-OS build workers in GitHub Actions act as release-path verifiers - branch protections depend on named Windows and macOS build jobs -## Mitigations +### Mitigations - add dedicated Windows and macOS build jobs - upload per-OS artifacts and checksums on PR, push, tag, and release events - document required-check intent in repo docs and verify workflow coverage locally -## Test points +### Test points - local supply-chain verification covers workflow presence and trigger scope - workflow uploads artifact and checksum for both OSes - intended required checks include both OS build jobs -## Realistic threats +### Realistic threats - platform-specific bundle assets can be missing even when the Rust shell compiles locally - release upload credentials can be over-scoped if build and publish concerns share the same job -## Remaining risk +### Remaining risk - notarization and signing remain outside the bootstrap harness until platform credentials exist +## Implementation tasks + --- ### Task 1: Add cross-platform build policy docs diff --git a/docs/plans/2026-03-10-bandscope-harness.md b/docs/plans/2026-03-10-bandscope-harness.md index b114c3196..625fd28fd 100644 --- a/docs/plans/2026-03-10-bandscope-harness.md +++ b/docs/plans/2026-03-10-bandscope-harness.md @@ -8,37 +8,41 @@ **Tech Stack:** npm workspaces, Vite, React, Vitest, Tauri scaffold files, Python 3.12+, uv, pytest, ruff, mypy, Dependabot, CycloneDX JSON SBOM, GitHub Actions SHA pinning. -**Security Notes:** The harness must keep security guidance visible and fail-fast. Future work that touches files, URLs, subprocesses, IPC, WebView, updates, models, or cache/export behavior must include a `Security Notes` section and avoid generic exec/read/write capabilities. +## Security Notes -## Attack surface +The harness must keep security guidance visible and fail-fast. Future work that touches files, URLs, subprocesses, IPC, WebView, updates, models, or cache/export behavior must include a `Security Notes` section and avoid generic exec/read/write capabilities. + +### Attack surface - repo docs and plans that define future file, URL, subprocess, IPC, WebView, model, and update behavior -## Trust boundary +### Trust boundary - future product work crosses user-input, process, IPC, storage, and network boundaries even in a local-first app -## Mitigations +### Mitigations - keep security policy in repo docs, not only in chat - fail plans that omit `Security Notes` - fail obvious dangerous implementation patterns early -## Test points +### Test points - docs presence checks - `Security Notes` structure checks - security pattern checks in quickcheck -## Realistic threats +### Realistic threats - future contributors can copy unsafe bootstrap defaults into production features - local checks can silently miss risky workflow or release-script drift if scope is too narrow -## Remaining risk +### Remaining risk - desktop runtime constraints remain provisional until real IPC and backend flows exist +## Implementation tasks + --- ### Task 1: Add repository docs and root config diff --git a/docs/plans/2026-03-10-bandscope-supply-chain.md b/docs/plans/2026-03-10-bandscope-supply-chain.md index bd028984a..071cb5adf 100644 --- a/docs/plans/2026-03-10-bandscope-supply-chain.md +++ b/docs/plans/2026-03-10-bandscope-supply-chain.md @@ -8,42 +8,46 @@ **Tech Stack:** npm workspaces, uv lock, Cargo lock, Dependabot, GitHub Actions, CycloneDX JSON SBOM, supplemental JSON inventory. -**Security Notes:** Supply-chain workflows are part of the public attack surface. The harness must fail if lockfiles, workflow pinning, dependency review, audits, SBOM generation, or supplemental inventory drift out of policy. +## Security Notes -## Attack surface +Supply-chain workflows are part of the public attack surface. The harness must fail if lockfiles, workflow pinning, dependency review, audits, SBOM generation, or supplemental inventory drift out of policy. + +### Attack surface - dependency manifests and lockfiles - GitHub Actions and third-party actions - bundled binaries and model artifacts - release assets and uploaded SBOMs -## Trust boundary +### Trust boundary - package-manager graphs do not fully cover binaries and model artifacts - GitHub workflows and release assets are externally visible supply-chain surfaces -## Mitigations +### Mitigations - commit lockfiles and pin workflow actions by SHA - add dependency review, audit, and SBOM workflows - keep supplemental component inventory in machine-readable form - document intended required checks for develop and main -## Test points +### Test points - local supply-chain verification script - quickcheck path includes supply-chain verification - workflows trigger on develop, main, PR, tag, and release-related events -## Realistic threats +### Realistic threats - over-broad workflow permissions can let PR-modified code affect release surfaces - missing bundled-binary inventory can hide shipped assets outside package-manager graphs -## Remaining risk +### Remaining risk - GitHub-native security signals still depend on repository settings and service availability outside repo control +## Implementation tasks + --- ### Task 1: Add supply-chain policy docs and inventory diff --git a/docs/security/app-security.md b/docs/security/app-security.md index a9983fb97..d7250bbf5 100644 --- a/docs/security/app-security.md +++ b/docs/security/app-security.md @@ -137,8 +137,11 @@ Every boundary crossing requires validation, scope restriction, minimal logging, - Cross-check extension, MIME, and actual decode behavior. - Prefer isolated worker processing for decode and analysis. - Guard against very large files, abnormal duration, and hostile metadata. +- Apply the versioned canonical local-audio resource policy consistently at request preflight and again at the opened-file/decoded-waveform boundary; request metadata is never authoritative for actual resource use. +- Before any decoder resamples, downmixes, or duration-truncates local audio, inspect source-container metadata from the already-open handle with `soundfile.info`, enforce the shared 8 kHz–192 kHz and mono/stereo source contract, reject overlong sources, and rewind the handle before `librosa.load`. +- In the Python analysis boundary, reject decoded audio that is empty, non-finite, wrong-rate, wrong-shaped, or over the accepted sample budget before beat tracking or model inference. Use the one-sample-over decode probe described in `docs/doctoring/audio-resource-policy.md` so an exact-boundary track remains accepted while excess decoded output is observable and fails closed. - Do not add arbitrary filesystem scanning just to find media files. -- When bootstrapping a project around local audio, prefer referencing the validated original file plus app-owned temp/cache/project roots over copying the file until persistence requirements justify the extra storage boundary. +- When bootstrapping a project around local audio, use the OS-selected external file only as untrusted admission input. Stage and sync admitted bytes under the app-owned project root, publish them as `source.`, then reopen and verify the published regular/non-symlink object against the bounded size and SHA-256 receipt before analysis or persistence. Do not persist an arbitrary external absolute path as authority. ### YouTube and remote URL import @@ -147,6 +150,8 @@ Every boundary crossing requires validation, scope restriction, minimal logging, - Validate scheme, host, path, and query before any fetch or handoff. - Do not widen URL intake into a generic remote downloader. - Sanitize remote metadata before display. +- Apply the same canonical 100 MiB encoded-byte ceiling during YouTube download as local-file intake. Abort with yt-dlp `max_filesize` and a progress hook, then delete owned `.part` / `.ytdl` / `-Frag*` siblings that stay inside that import directory. Do not keep a divergent post-download-only 50 MB limit that lets a large transfer fill the cache root first. +- Revalidate the filesystem-observed downloaded length before storing bootstrap state. Treat announced `filesize` / `filesize_approx` as a pre-download hint only. ### Subprocesses and native tools diff --git a/docs/traceability/analysis-dispatch-source-revalidation.md b/docs/traceability/analysis-dispatch-source-revalidation.md new file mode 100644 index 000000000..11d6d29de --- /dev/null +++ b/docs/traceability/analysis-dispatch-source-revalidation.md @@ -0,0 +1,116 @@ +# Analysis-dispatch source revalidation + +## Problem + +Project v3 restart re-admission proves that the persisted `sourceReference` still matches the app-owned `source.` before native bootstrap authority is restored. The first dispatch repair repeated that proof immediately before queue admission, but then released the verified native reader. The Python analysis process subsequently reopened `local_source.sourcePath`, leaving a smaller TOCTOU window in which different bytes could reach decode after the native check. Separately, analysis/feature cache workspaces were keyed from project/path/name/size rather than the retained digest, so cache provenance still depended on the no-clobber publication invariant rather than the same content identity used for admission. + +## Constraints + +- Resource Admission remains the owner of local-audio byte identity; Project Persistence remains the owner of durable `sourceReference`; analysis consumes the retained identity without minting a second digest contract. +- Renderer IPC supplies only the BandScope project id for local audio. It cannot submit a path, byte count, digest, artifact name, `sourceReference`, or native-admission evidence. +- Exact byte count and SHA-256 must survive the Rust-to-Python process boundary without process-global mutation because BandScope allows concurrent analysis jobs. +- The Python decoder must consume the same verified byte snapshot, not a pathname reopened after verification. +- Analysis cache and temporary stem-work namespaces must also be derived from the retained content identity so same-path/same-size content cannot alias reusable evidence. +- Operating-system path and I/O failures remain bounded; raw local paths and native diagnostics do not become buyer-facing errors. +- Deterministic RIFF/WAVE byte strings in this lane are security/unit fixtures only. They are not MIR accuracy, decoder-quality, or production scientific acceptance evidence. + +## RED and repair evidence + +`90f60a744f5dec46f364ae8d3c5e401af68983b7` introduced dispatch-time native revalidation. `ae1f568591c9b9901ef2331f91068a6e1f91d561` composed the retained `LocalAudioPublicationIdentity` with the Project Persistence reverse ACL, and `b84ed0e39d533ef5524d25c7d86bc0fcf0197d16` wired it into `start_analysis_job`. That repair narrowed the stale interval but did not bind decoder bytes. + +`9dc5336d7bbd4673f4ba0722a1548596d3085bfa` adds the decoder-bound RED contracts. They require a same-size replacement to fail before decode and require decoding to continue from already-verified bytes even when the pathname changes after snapshot creation. The predecessor had no `separate_admitted` boundary, so no hosted RED receipt is claimed. + +`93d2c99aef316fa42b8796b3b05bfea2cd46c7ed` adds the explicit admitted-source snapshot path. `65baf71db5ea47b607753a297908483900be9215` then adds a second RED requiring the production `AudioStemSeparator.separate` entrypoint to consume process-scoped native evidence and to reject a partial evidence pair. `e0bec865005e4e4b836fe76af66a6587d9f5743d` implements that fail-closed adapter. + +`404586a2eae752fa329dfc87768b22148ce9411a` adds the Rust-side process-handoff RED. `a0809cdee41100296e478c18653ab1e7f3305559` passes the retained byte count and SHA-256 only on the spawned analysis `Command`, first removing any inherited values so demo/manual jobs cannot accidentally consume ambient evidence. It does not call process-global `std::env::set_var`, so the two allowed in-flight jobs cannot overwrite each other's identity evidence. + +`cbaaf868f7fa6d1050b62eec109bdb54d69e07d0` adds the CLI RED proving that a native-admitted job must not run the earlier temporary `TemporalAnalyzer` pathname probe. `a1136c5270cfbd940d9e3e3cea7cc55b6ce1cdb9` skips that compatibility-only probe whenever native evidence is scoped, leaving the content-bound separator as the first production audio decode path. `e1b50929f7d112cf8fb417ede97c98af6a3c2b41` pins the child-process environment contract; `88beb62d7f1cad5fc141b73da0c7f75ec9d785fe` is formatting-only. + +`19c2112fea48c55a17faf045c81447456cd370b5` adds a cache/temp provenance RED: a successfully revalidated source must receive cache and temporary work roots namespaced by the canonical SHA-256. `063164e93b7ba9d93ec29648c9d2d8d1a203d488` implements that in the native dispatch adapter, before the roots enter the Python request. Existing Python cache/stem-work keying therefore remains compatible while its parent namespace is content-bound. + +## Selected design + +The selected design is an identity-equivalent immutable snapshot rather than cross-platform descriptor inheritance. + +1. `start_analysis_job` obtains the project-keyed native `LocalAudioPublicationIdentity` and revalidates the current app-owned source through the existing no-follow/reparse-aware native opener. +2. That revalidation also derives content-addressed cache/temp roots beneath the already app-owned project workspaces using the canonical SHA-256. +3. The worker receives the same retained identity. `run_analysis_engine` removes inherited BandScope admission variables, then sets exact `file_size_bytes` and `content_sha256` only on that job's child `Command`. +4. The Python CLI skips the compatibility temporal pathname probe when either native evidence variable is present. A partial pair therefore reaches the separator and fails closed rather than silently falling back to an unverified decode. +5. `AudioStemSeparator.separate` validates the canonical evidence pair, opens the selected source once, checks descriptor size, copies exactly the expected number of bytes into a private `SpooledTemporaryFile` while hashing them, performs a one-byte growth probe, and compares SHA-256. +6. Only a matching snapshot is rewound and passed to the existing `decode_mono_audio` `BinaryIO` boundary. Later pathname replacement cannot change the encoded bytes consumed by decoder/MIR/model work for that analysis invocation. + +This keeps BandScope audio truth in BandScope and reuses Resource Admission identity rather than adding a second digest owner. The environment variables are a per-process native-to-analysis capability envelope, not renderer API, durable project schema, provider configuration, or cross-service state. Cache/temp scoping is native-derived and does not require Python to become a second owner of publication identity. + +## Rejected alternatives + +**Trust restart or dispatch verification until decode.** Rejected because CWE-367 describes exactly the failure mode where a resource can change between check and use. + +**Let the renderer carry the digest into the analysis request.** Rejected because renderer data is not Resource Admission authority and would recreate the source-evidence forgery path removed from Project v3 Save. + +**Mutate the desktop process environment before spawning Python.** Rejected because `MAX_IN_FLIGHT_JOBS` permits concurrent jobs; process-global mutation would create a cross-job race. + +**Pass only the transient pathname and rehash it independently in Python.** Rejected because it would duplicate the digest contract and still permit another pathname read after the check. + +**Key cache only by path and byte count.** Rejected because reproducible scientific evidence should not depend on the assumption that a pathname has never been rebound to same-size content. The canonical digest now namespaces cache and stem-work roots before Python sees them. + +**Require one OS descriptor inheritance mechanism across Windows and macOS immediately.** Rejected for this increment because platform handle inheritance semantics differ. The selected bounded snapshot is portable and ties decode bytes to the canonical native content identity without claiming that filesystem ancestry itself is descriptor-bound. + +## Security Notes + +### Attack surface and trust boundary + +The renderer-visible project id remains a selector only. Native `LocalAudioPublicationIdentityState` owns the expected content evidence. The Rust worker scopes that evidence to one analysis child. The Python process may see the transient app-owned pathname, but it cannot promote different bytes: size, exact bounded read, growth probe, and SHA-256 must all match before decode. Reusable cache/temp artifacts are rooted beneath the same digest identity. + +### Mitigations + +The repair combines checks with distinct purposes. Native re-admission confirms the app-owned project/source contract immediately before queue admission. Python then creates a private content snapshot and verifies the same identity at the consuming decode boundary. The decoder reads the verified snapshot itself, eliminating the previous check-then-reopen byte gap. Content-addressed work roots prevent same-path/same-size cache aliasing without duplicating hash computation in Python. + +### Realistic threats + +- the app-owned source path is rebound to different same-size bytes after native revalidation but before Python opens it; +- a child receives a partial or malformed evidence pair and silently falls back to an unverified decode path; +- process-global evidence mutation causes concurrent jobs to consume another project's source identity; +- same-path/same-size replacement aliases an existing analysis or stem-work cache namespace. + +### Safe failure + +Missing native identity, project mismatch, native re-open failure, malformed or partial child evidence, growth, truncation, or same-size mutation fails before separation/model work. Native paths and OS diagnostics are not returned as buyer-facing detail. Existing direct/manual library callers with no native evidence retain the compatibility path; production desktop local-audio jobs always provide evidence. + +### Test points + +- `apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs` covers current-byte native revalidation, digest-scoped cache/temp roots, and per-child evidence scoping without process-global environment mutation. +- `services/analysis-engine/tests/test_audio_admitted_snapshot.py` covers same-size replacement rejection, verified-snapshot decode after pathname change, production `separate` evidence consumption, and partial-evidence rejection. +- `services/analysis-engine/tests/test_cli_native_admission_boundary.py` proves native-admitted jobs do not execute the legacy temporary pathname probe. +- Existing Project Persistence restart tests remain canonical for malformed durable evidence, root substitution, final-component no-follow/reparse behavior, growth, truncation, and exact SHA-256 identity. + +### Remaining risk and next causal work + +Content-byte continuity and cache namespace identity are now designed end to end for the production local-audio worker, but hosted exact-head GREEN and supported-platform real-audio acceptance are still required before this becomes release evidence. + +The Python path open is content-bound, not full filesystem-ancestry authority. A higher ancestor can still be replaced between native checks and Python open; different content fails the digest, but directory-handle-relative authority remains separate hardening if BandScope must prove that the bytes came from the same filesystem object rather than merely the same admitted content. + +`SpooledTemporaryFile` provides bounded, automatically cleaned temporary storage and may roll larger encoded sources to an OS-managed temporary file. That temporary-copy privacy/resource behavior needs supported Windows/macOS fault-injection and crash evidence before release. It is not a durable BandScope project artifact. + +The next product-causal consumer remains #1160: only after fresh full-mix decode/playback authority exists may persisted `selectedPlaybackSource` be reconciled with currently admitted stems, with missing preferred stems falling back to Full mix. + +## Standards traceability + +MITRE CWE-367 defines the relevant weakness as checking resource state and then using a resource whose state can change before use. Its mitigation guidance notes that merely reducing the check/use interval does not remove the underlying identity problem. The selected snapshot instead verifies and then uses the same copied bytes. + +NIST FIPS 180-4 remains the published Secure Hash Standard defining SHA-256. NIST decided to revise FIPS 180-4, but the current NIST publication page still identifies FIPS 180-4 as the published standard; the announced revision has not superseded it. + +Python's `tempfile` documentation identifies `SpooledTemporaryFile` as a cross-platform high-level temporary-file interface with automatic cleanup and context-manager support. BandScope relies on those lifecycle semantics only for the transient snapshot; the cryptographic acceptance rule remains BandScope-owned. + +NIST SP 800-218 v1.1 remains the released SSDF baseline. The repair follows its recurrence-prevention intent by moving verification to the actual consuming boundary instead of relying on a stale earlier check. + +## References + +MITRE. (2026). *CWE-367: Time-of-check time-of-use (TOCTOU) race condition* (CWE 4.20). https://cwe.mitre.org/data/definitions/367.html + +National Institute of Standards and Technology. (2015). *Secure Hash Standard (SHS)* (Federal Information Processing Standards Publication 180-4). https://doi.org/10.6028/NIST.FIPS.180-4 + +National Institute of Standards and Technology. (2023, March 7). *Decision to revise FIPS 180-4, Secure Hash Standard (SHS).* https://www.nist.gov/news-events/news/2023/03/decision-revise-fips-180-4-secure-hash-standard-shs + +Python Software Foundation. (2026). *tempfile — Generate temporary files and directories* (Python 3.14.7 documentation). https://docs.python.org/3/library/tempfile.html + +Scarfone, K., Souppaya, M., & Dodson, D. (2022). *Secure Software Development Framework (SSDF) Version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 diff --git a/docs/traceability/demucs-local-model-admission.md b/docs/traceability/demucs-local-model-admission.md new file mode 100644 index 000000000..44c50656a --- /dev/null +++ b/docs/traceability/demucs-local-model-admission.md @@ -0,0 +1,205 @@ +# Demucs local-model admission traceability + +Status: Draft + +## Problem + +BandScope promises local-first rehearsal analysis and the repository security policy says ordinary local analysis must not acquire a network dependency. The production separator originally called `demucs.pretrained.get_model("htdemucs")` without first proving that the canonical checkpoint already existed locally. + +For the Demucs 4.x API currently consumed by BandScope, `get_model(..., repo=None)` constructs a `RemoteRepo`. `RemoteRepo.get_model` can delegate to `torch.hub.load_state_dict_from_url`, so an absent checkpoint could turn first stem separation into an implicit network download. A first local-only guard then exposed a second integrity gap: it accepted any regular non-symlink file named `955717e8-8726e21a.th`. Demucs itself treats the suffix after `-` as a SHA-256 checksum prefix for locally stored model files, so filename-only admission was weaker than upstream's own local repository contract. + +The next implementation still verified the mutable torch-cache object and then let the upstream resolver reopen that pathname. A replacement or deletion after verification could therefore invalidate the evidence. The current implementation instead copies bytes from the verified descriptor into a private local Demucs repository and calls `get_model(signature, repo=snapshot_root)`. Demucs consequently resolves through `LocalRepo`; a later mutation of the torch-cache pathname cannot change the model bytes being deserialized or reactivate `RemoteRepo` for that load. + +That private snapshot introduced a separate resource-admission gap: a regular cache object with the canonical filename could be arbitrarily large. Checksum mismatch was detected only after copying the object, so corrupted local state could consume unbounded temporary storage before failing. A 128 MiB ceiling repaired the unbounded-copy case, but the copy still streamed until EOF rather than binding materialization to the descriptor size observed at `fstat`. If the file grew after preflight while remaining below the ceiling, extra bytes could still enter the private snapshot before checksum rejection. The current boundary therefore snapshots exactly the descriptor-reported byte count, rejects short reads, and rejects any byte beyond that admitted count before resolver/deserialization. + +The live analysis lock now resolves `torch==2.12.1`. PyTorch changed `torch.load` so releases starting with 2.6 use `weights_only=True` by default when a custom `pickle_module` is not supplied. Native Demucs packages contain more than a plain tensor `state_dict`: upstream loading consumes serialized class/constructor metadata. A compatibility package may therefore raise `pickle.UnpicklingError` when the weights-only unpickler rejects a serialized global. That failure is security-relevant as well as operational: BandScope must not surface internal serialized class names to a buyer, silently switch to `weights_only=False`, or turn a compatibility failure into remote/model fallback. The Signal/MIR boundary converts this incompatibility to the existing bounded local-model-unavailable diagnostic while leaving the release serialization decision with Distribution. + +PyTorch also documents a process-level override, `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD`, that makes an implicit `torch.load` use `weights_only=False` when the call site did not pass the argument. Upstream Demucs 4.x uses that implicit form for native packages. Relying only on PyTorch's safer default therefore left a downgrade path outside BandScope's model-admission code: a truthy inherited environment value could reactivate unrestricted pickle loading before any BandScope exception boundary ran. The current loader rejects that unsafe override before Demucs import/resolution or checkpoint deserialization and returns the same bounded local-model-unavailable diagnostic. + +A commercial review exposed an independent rights blocker. The upstream Demucs issue about distributing pretrained models commercially received an explicit maintainer response that the model weights are not covered by the MIT code license and are provided only for scientific purposes. Technical integrity, local-only loading, a third-party mirror, conversion of the same weights, or a PyTorch compatibility workaround cannot create commercial rights. BandScope issue #1181 owns that release blocker. + +## Constraints + +- BandScope must remain local-first during ordinary analysis. +- Runtime code must not silently download model artifacts. +- A local model cache object is untrusted input: type, identity, byte size, and checksum evidence must be bounded before deserialization. +- The private compatibility snapshot is temporary runtime authority, not a released model artifact or provenance statement. +- A PyTorch weights-only incompatibility must not silently authorize unsafe legacy pickle loading; process environment must not downgrade an implicit Demucs `torch.load` to `weights_only=False`; any broader deserialization policy belongs to a fully admitted immutable release artifact and explicit Distribution decision. +- The upstream pretrained Demucs weights must not be bundled, auto-downloaded, or represented as commercially licensed unless an explicit commercial-use/redistribution grant covering the exact artifact is obtained. +- Model artifacts are supply-chain inputs: usage/redistribution rights, provenance, exact full integrity evidence, package placement, SBOM/supplemental inventory coverage, signing and update/rollback behavior belong to Distribution rather than MIR inference code. +- A missing, modified, oversized, size-racing, incompatible, environment-downgraded, or commercially inadmissible model must fail safely rather than fall back to the retired FFT mask or claim successful separation. +- Unit fixtures may mock a model boundary; release/scientific acceptance still requires rights-cleared real decoded audio and an actually admissible released model artifact. + +## RED evidence + +Commit `716438d1c927bbdea38cb6a78b3a417994992e3d` adds the initial local-only regression. It replaces the upstream resolver with a forbidden call and points torch at an empty hub directory. The predecessor enters `get_model`; the causal fix followed immediately, so no hosted RED failure receipt is claimed. + +Commit `fb9571b5bb351ccb742a5956dbfa82966400b02d` adds the cache-integrity RED. The fixture registers a checkpoint name whose checksum suffix belongs to one byte sequence, writes different bytes under that exact name, and requires the resolver call count to remain zero. The predecessor checked only path shape, file type, and filename. + +Commit `9fd9b562d068dea1e9348584f53ced6d9c6c0553` adds the immutable-snapshot regression. It requires the bytes presented through the private local repository to remain the verified bytes even if the original torch-cache pathname is replaced after snapshot acquisition. + +Commit `7ac4bc1d35ff736966ed556407b6ff56d03942c0` adds the resource-bound RED. A checksum-valid fixture is deliberately larger than a monkeypatched local-model ceiling; the Demucs resolver is forbidden. The predecessor had no checkpoint-size admission rule, so it would continue to resolution rather than fail before model loading. The immediate descendant carries the causal fix; no hosted RED failure receipt is claimed for this intermediate head. + +Commit `f4ef3dc86e34432936b2febb152991af70e57bd1` adds the descriptor-size continuity RED. The fixture presents a stable regular checkpoint whose descriptor preflight reports one byte less than the bytes subsequently readable from that same descriptor and forbids any Demucs resolver call. The predecessor streamed until EOF, so the extra post-preflight byte entered the private snapshot and a checksum-valid full byte sequence could still reach model resolution. The immediate descendant carries the causal fix; no hosted RED failure receipt is claimed for the intermediate head. + +Commit `5789562e716d955c758a7eb728140c5fcb02f779` adds the PyTorch weights-only compatibility RED. A checksum-valid local fixture reaches the mocked Demucs resolver, which raises the same `pickle.UnpicklingError` class used when a weights-only load rejects a serialized global such as `demucs.htdemucs.HTDemucs`. The contract requires the public exception to remain `Stem separation model weights are not installed locally.` and forbids the serialized class name from leaking through that buyer-facing message. The predecessor propagated the unpickling failure. The production descendant followed immediately, so no hosted RED-failure receipt is claimed for the intermediate head. + +Commit `3ae3646087f6fe2ae6a9aa709025720fc40beb6c` adds the environment-downgrade RED. For every documented truthy form of `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD` plus uppercase `TRUE`, a checksum-valid local fixture forbids the Demucs resolver from being called. The predecessor entered `get_model`, so an upstream implicit `torch.load` could have observed the unsafe process override. The production descendant followed immediately, so no hosted RED-failure receipt is claimed for the intermediate head. + +## Selected repair + +Commit `61b629baaef0d6da15967fe272b9d9f109d18eaf` established the first narrow admission guard: the production `htdemucs` checkpoint must already exist locally, be a regular non-symlink object, and unsupported/missing inputs fail with the bounded message `Stem separation model weights are not installed locally.` + +Commit `d0432187eea6ec94a247d78f1c02f69e7185a5a1` parses the canonical lowercase eight-hex checksum suffix and streams SHA-256 over the local object before model resolution. The compatibility regression uses fixture-specific checksum prefixes so unit bytes do not masquerade as the released htdemucs artifact. + +Commit `3662de13e1ffae2ac2337835dd6f317011e81bff` closes the mutable-cache pathname gap. BandScope opens the canonical cache object with no-follow semantics where available, verifies that the opened descriptor is the same regular object observed by `lstat`, copies and hashes that descriptor into a process-private temporary Demucs repository, and invokes `get_model(signature, repo=snapshot_root)`. Upstream `get_model` therefore uses `LocalRepo`; the mutable torch-cache pathname is no longer reopened by the model resolver and `RemoteRepo` is not selected for this load. + +Commit `c21c6c4476f7c9ae937a24dda77eb841515ed315` bounds that compatibility snapshot to 128 MiB. The descriptor must report a positive size no greater than the ceiling before copying, so an already-oversized cache object cannot consume unbounded snapshot storage. + +Commit `0d0c6c3263e9b72b5aec554c1824de3d004b5831` binds snapshot materialization to that admitted descriptor size. The copy reads exactly `descriptor_stat.st_size` bytes, fails on an early EOF, and probes one additional byte without copying it; any post-`fstat` growth therefore fails before Demucs resolution instead of entering the snapshot. SHA-256 verification of those exact bytes against the canonical filename prefix remains required. A checksum mismatch, size violation, file-identity mismatch, size race, or I/O failure removes the owned snapshot and returns the same bounded model-unavailable result before Demucs deserialization. + +Commit `d395c6055bb16cfc4a76f490f16e9e6540590fae` keeps the PyTorch 2.6+ compatibility failure inside that same local-model boundary. `_load_model` catches only `pickle.UnpicklingError` from the admitted `get_model(signature, repo=snapshot_root)` call and converts it to the existing bounded local-model-unavailable `ValueError`. It does not set `weights_only=False`, broaden remote resolution, weaken snapshot checks, or treat incompatible bytes as successful model authority. Other unexpected exceptions remain visible to engineering rather than being swallowed by a broad catch. + +Commit `0d9fb9f983a093fe3868106945677dfa58d10bba` rejects PyTorch's documented no-weights-only process override before Demucs import/resolution. The guard recognizes the documented truthy values case-insensitively and does not mutate global process environment or rewrite upstream loader code. An unsafe inherited override therefore cannot turn the admitted compatibility path into unrestricted pickle deserialization; it fails with the existing bounded model-unavailable diagnostic. + +The 128 MiB ceiling is a defensive compatibility resource limit, not a claim about the exact commercial artifact. Distribution #1180 must replace this cache-compatibility assumption with an immutable admitted artifact whose exact byte size, full digest/signature, serialization contract, package placement, and update/rollback compatibility are release inputs. The exact packaged artifact must be demonstrated under the release PyTorch/model-loader stack rather than assuming that either `weights_only=True` or `weights_only=False` is safe or compatible. + +The commercial-rights finding is not treated as a code bug that can be patched by changing a package label. #1181 makes the upstream pretrained weights a release-blocking legal/product prerequisite. Signal/MIR may keep this technical fail-closed boundary in Draft, but Distribution must not turn those weights into a commercial BandScope artifact without rights evidence. + +## Alternatives considered + +### Keep `get_model("htdemucs")` with no explicit local repository + +Rejected. An absent checkpoint can select `RemoteRepo`, and a mutable cache pathname can be reopened after BandScope's own verification. + +### Trust the canonical filename without checking bytes + +Rejected. Upstream `LocalRepo` interprets the checksum-bearing filename as integrity evidence. Filename-only admission is insufficient across a deserialization boundary. + +### Verify the cache and then let upstream reopen it + +Rejected. It leaves a verification-to-use pathname race. Copying from the verified descriptor into a private repository binds the bytes used by the resolver to the bytes BandScope admitted. + +### Copy until EOF under only a generic maximum + +Rejected. A generic maximum prevents unbounded storage but does not preserve the exact descriptor-size observation that authorized the snapshot. A file that grows after `fstat` but remains below the ceiling would contribute unadmitted bytes before checksum rejection. Exact-count copy plus an extra-byte probe keeps resource and identity evidence aligned. + +### Force `weights_only=False` when current PyTorch rejects the package + +Rejected for the compatibility cache path. PyTorch documents that legacy pickle loading can execute arbitrary functions encoded by the checkpoint. The current eight-hex filename suffix and private snapshot establish local compatibility integrity, not the full release provenance needed to authorize a code-bearing object graph. Distribution may choose a native checkpoint only after exact rights/provenance, immutable full integrity evidence, loader isolation and removal conditions are documented under #1180. + +### Rely on PyTorch's default without guarding its environment override + +Rejected. Upstream Demucs does not pass `weights_only` explicitly at the native package call site, and PyTorch documents that a truthy `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD` changes such calls to `weights_only=False`. A secure default is not an invariant if inherited process state can reverse it. BandScope rejects the downgrade before entering the third-party loader rather than modifying global environment or patching Demucs. + +### Broadly catch every model-loader exception + +Rejected. A broad catch would hide implementation defects and incompatible scientific behavior. The current repair handles the identified `pickle.UnpicklingError` compatibility boundary while preserving fail-fast engineering visibility for unrelated failures. + +### Download or bundle the checkpoint from this MIR/Project Persistence lane + +Rejected. Ordinary analysis must not gain a network dependency, and model acquisition/package provenance belongs to Distribution. More importantly, #1181 currently prevents treating the upstream pretrained weights as a commercially admissible BandScope release input. + +### Rely on a third-party rehost or converted copy carrying an MIT label + +Rejected. The upstream maintainer explicitly distinguished model weights from MIT-licensed source code. A mirror, conversion, or downstream label does not establish broader rights. + +### Replace the model with a commercially admissible separator + +Viable. The replacement must have traceable model-weight/training-data rights and meet BandScope's real-audio source-separation and rehearsal-quality contract. License safety must not silently regress to heuristic stems. + +### Fall back to heuristic FFT masks + +Rejected. The retired heuristic is not a scientifically acceptable substitute for source separation and must not turn unavailable model authority into false rehearsal confidence. + +## Security Notes + +### Attack surface + +The model-loading boundary crosses the local Python process into third-party Demucs/torch deserialization. Cache pathname state, opened model bytes, descriptor size, temporary snapshots, serialized object graphs, inherited PyTorch loader environment, loader behavior, and release model artifacts are security-, availability-, scientific-integrity-, and supply-chain-sensitive inputs. + +### Trust boundary + +Signal/MIR may consume a technically admitted local model for Draft analysis, but it does not own remote acquisition, commercial-use/redistribution rights, or release packaging. The private snapshot binds one load to the regular descriptor, its admitted byte count, and verified local bytes; it does not make those bytes commercially admissible. The eight-hex checksum is upstream compatibility integrity evidence, not BandScope release provenance. PyTorch's weights-only policy is a loader security boundary, not a model-rights or scientific-acceptance statement, and BandScope requires that inherited process state cannot downgrade that policy on this implicit upstream call. #1180 owns Distribution artifact delivery and #1181 owns the pretrained-weight rights blocker. + +### Realistic threats + +- an absent model initiates an unexpected network fetch; +- an unsupported model name expands the resolver surface; +- a symlink/non-regular object is presented under the expected cache pathname; +- modified bytes retain a trusted-looking checkpoint filename; +- a cache object is replaced between verification and model use; +- a cache descriptor grows or shrinks after size preflight and changes the bytes copied into the private repository; +- a corrupted canonical-name object is extremely large and exhausts temporary storage before checksum rejection; +- a legacy serialized package is incompatible with the locked PyTorch weights-only default and leaks internal class/global names through an error; +- inherited `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD` state silently turns an implicit upstream load into unrestricted pickle deserialization; +- an operator responds to compatibility failure by enabling unsafe legacy pickle loading on a merely compatibility-admitted cache object; +- a technically valid upstream checkpoint is shipped or advertised commercially despite the stated scientific-purpose restriction; +- a third-party mirror or converted artifact is mistaken for a new commercial license grant. + +### Mitigations + +- exact allowlist for the currently supported `htdemucs` checkpoint name; +- regular-file, no-follow, and descriptor identity checks; +- positive-size and 128 MiB compatibility ceiling before snapshotting; +- exact descriptor-size snapshotting with early-EOF and extra-byte rejection, so post-`fstat` shrink/growth fails closed; +- streaming SHA-256 verification against the canonical Demucs checksum prefix; +- private temporary local repository built from the verified descriptor bytes; +- explicit `repo=snapshot_root`, keeping upstream model resolution on `LocalRepo` instead of `RemoteRepo`; +- reject a truthy `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD` before the upstream loader can deserialize a native package; +- bounded `pickle.UnpicklingError` handling without setting `weights_only=False` or exposing serialized class details; +- bounded failure before Demucs deserialization/use for missing, modified, oversized, size-racing, environment-downgraded, or otherwise inadmissible cache state; +- no heuristic-success fallback; +- #1181 blocks commercial packaging/auto-download/rights claims until explicit rights or an admissible replacement exists; +- #1180 retains ownership of immutable release artifact, full digest/signature, serialization policy, inventory, package, signing, and updater/rollback evidence. + +### Remaining risk + +The current path is still a compatibility bridge around a developer/runtime torch cache, not a commercial release artifact boundary. The eight-hex suffix is truncated upstream integrity evidence, not a repository-owned full SHA-256, signature, provenance receipt, or exact package manifest. The 128 MiB ceiling is deliberately a generic safety limit rather than the exact size of an admitted release artifact. + +Demucs/torch deserialization still consumes a trusted technical snapshot in its native checkpoint format. Current PyTorch may reject legacy object graphs under the safer weights-only default; BandScope now fails closed rather than weakening that default or allowing PyTorch's documented no-weights-only environment override to weaken it on the implicit Demucs call. A commercially admitted release should prefer a non-code-executing or materially narrower model format where scientifically equivalent, or bind unavoidable native deserialization to immutable package/signature provenance, an explicitly documented allowed object graph/loader policy, isolation and a removal condition. The upstream pretrained `htdemucs` weights remain blocked for commercial release by #1181 even if every technical integrity and compatibility check passes. + +### Test points + +- absent local checkpoint: upstream resolver call count remains zero; +- checksum-mismatched cached checkpoint: resolver call count remains zero; +- checksum-matching fixture: resolver receives only the private snapshot repository; +- original cache pathname replaced after snapshot: private snapshot bytes remain unchanged; +- checkpoint larger than the active resource ceiling: resolver call count remains zero; +- descriptor preflight smaller than readable bytes: extra bytes do not enter the snapshot and resolver call count remains zero; +- weights-only incompatibility: `pickle.UnpicklingError` becomes the bounded local-model-unavailable diagnostic and serialized class names are absent from the public message; +- truthy `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD`: resolver/deserialization call count remains zero and the bounded local-model-unavailable diagnostic is returned; +- unsupported model name and symlink/non-regular object: fail closed; +- commercial release: exact rights evidence exists for the immutable artifact or the upstream weights are absent from release inputs; +- released admissible model: exact full digest/signature, exact size, serialization/loader policy, inventory, package/signing/notarization, rollback, and offline Windows/macOS real-audio acceptance are linked. + +## Effect + +Ordinary missing-model execution no longer begins an implicit model download. Modified, oversized, or size-racing cache objects fail before Demucs resolution, and the model resolver consumes a private snapshot derived from exactly the descriptor byte count BandScope admitted rather than reopening the mutable torch-cache pathname or accepting later growth. The PyTorch 2.6+ weights-only compatibility failure is bounded without silently enabling legacy pickle loading, and inherited PyTorch environment state cannot opt the implicit Demucs load back into unrestricted pickle mode. These controls establish a technical local-first compatibility boundary; they do not prove that the upstream package loads successfully under the current locked stack, establish scientific accuracy, or authorize commercial use of the upstream weights. + +## Follow-up + +1. Resolve #1181: obtain explicit commercial-use/redistribution rights for the exact upstream weights or select/train a commercially admissible replacement with traceable training-data/model rights. +2. Under #1180, establish the admitted model's exact version, exact byte size, full digest/signature, serialization/loader contract, package location, supplemental inventory/SBOM/NOTICE, signing/notarization, and update/rollback policy. +3. Exercise the exact released model with the exact locked PyTorch/loader stack. If a native checkpoint is retained, document the allowed object graph and loader/isolation policy; do not treat a blanket `weights_only=False` compatibility toggle or environment override as an admission control. +4. Replace torch-cache compatibility discovery with a Distribution-owned immutable local artifact path/manifest. Retain the descriptor-bound/private-load principle and no-remote-fallback invariant. +5. Evaluate whether a lower-risk model serialization format can replace native checkpoint deserialization without sacrificing supported-platform behavior or scientific accuracy; document the decision and removal condition if not. +6. Exercise the exact packaged artifact on supported Windows and macOS using rights-cleared real audio, recognized source-separation metrics, and explicit uncertainty/claim boundaries under #770. + +## References + +Défossez, A., Usunier, N., Bottou, L., & Bach, F. (2021). Music source separation in the waveform domain. *Transactions of the International Society for Music Information Retrieval, 4*(1), 197–208. https://doi.org/10.5334/tismir.76 + +Rouard, S., Massa, F., & Défossez, A. (2023). Hybrid transformers for music source separation. *Proceedings of the IEEE International Conference on Acoustics, Speech and Signal Processing (ICASSP)*. https://doi.org/10.1109/ICASSP49357.2023.10097003 + +Défossez, A. (2022). Re: License of pre-trained models (Issue comment 1134828611). *facebookresearch/demucs* (Issue #327). https://github.com/facebookresearch/demucs/issues/327#issuecomment-1134828611 + +Gawarecki, M. (2024, November 4). BC-breaking change: `torch.load` is being flipped to use `weights_only=True` by default in the nightlies after #137602. *PyTorch Developer Mailing List*. https://dev-discuss.pytorch.org/t/bc-breaking-change-torch-load-is-being-flipped-to-use-weights-only-true-by-default-in-the-nightlies-after-137602/2573 + +Meta Platforms, Inc. (2023). `demucs.pretrained`: loading pretrained models. *facebookresearch/demucs*. https://github.com/facebookresearch/demucs/blob/v4.0.1/demucs/pretrained.py + +Meta Platforms, Inc. (2023). `demucs.repo`: remote and local model repositories. *facebookresearch/demucs*. https://github.com/facebookresearch/demucs/blob/v4.0.1/demucs/repo.py + +PyTorch Contributors. (2026). Miscellaneous environment variables. *PyTorch documentation*. https://docs.pytorch.org/docs/stable/miscellaneous_environment_variables.html + +PyTorch Contributors. (2026). Serialization semantics: `torch.load` with `weights_only=True`. *PyTorch documentation*. https://docs.pytorch.org/docs/stable/notes/serialization.html#torch-load-with-weights-only-true diff --git a/docs/traceability/project-format-v2-playback-preference.md b/docs/traceability/project-format-v2-playback-preference.md new file mode 100644 index 000000000..332e95b00 --- /dev/null +++ b/docs/traceability/project-format-v2-playback-preference.md @@ -0,0 +1,91 @@ +# Project Format v2 Playback Preference Traceability + +> Historical slice. Version 2 established the durable Active Player selection semantic. Current writes are `projectFormatVersion: 3`; see `docs/traceability/project-format-v3-source-reference.md`. This document preserves the v2 decision/evidence chain and must not be read as the current on-disk contract. + +## Problem + +Project Persistence version 1 stored only the rehearsal `song`, while Active Player needed one durable source semantic: `full_mix | vocals | bass | drums | other`. Persisting a mounted `bandscope-playback` URL would have been incorrect because that URL is a revocable native runtime authority, not project truth. + +The first v2 compatibility surface also exposed only a `RehearsalSong` through Tauri, so a renderer could not yet carry an explicit stable selection through the canonical Project Persistence admission boundary. + +## Constraints + +- #970/#962 remains the single Project Persistence owner; #1160 is an Active Player/UI consumer and must not create another localStorage/session/file writer. +- Historical v1 and legacy raw-song parsing remains strict. Missing historical selection evidence migrates deterministically to `full_mix`. +- Playback preference is a closed semantic. Native playback URLs, filesystem paths, generation tokens, and capability receipts remain outside durable preference state. +- Renderer IPC input is untrusted and must pass typed native admission before filesystem mutation. +- Unsupported future versions fail explicitly before their body is interpreted as current truth. + +## RED → fix evidence + +- RED `86207ea0459f1a6e27e80f571ad5d6462a0d6fab` added `apps/desktop/core/tests/project_format_v2_playback_preference.rs`. The predecessor lacked the current-document API and typed preference. The test required deterministic v1/legacy migration to `full_mix`, round-trip preservation of all five semantics, rejection of unknown and `bandscope-playback` values, and construction without runtime authority. +- Causal implementation `be4ce61f9a865229aad9b46ad27adb79b1028258` introduced `project_format` as the then-current migration boundary while delegating historical song validation to the existing strict parser. +- Review-surface repair `e95b1db4495df5d9c721271f9b8edc54840eb004` restored the historical core source to `apps/desktop/core/src/lib.rs` and kept the new public surface in a small crate-root adapter rather than carrying a large file move. +- Golden fixture `4aa18fa8cbe5e59cf3f1e195f9a20e51c36e4da7` added `project-v2.json` with `vocals`; `73dc9a7314c0e20938fc767c207e4102e1bbf106` verified round-trip preservation. +- Evidence-trigger RED `770942f006c80724a5cac970d17acae6da4a9d5b` showed the focused Windows lane omitted the new format inputs. `72434d1026fe0a409bf291d91ead64d8b13f7959` added those paths without reducing its Rust test command. +- IPC-admission RED `ed5dd9a05a4ceead5a48119d854d5fc06a7e0a1c` required strict renderer-shaped `{ song, preferences }` admission. `7711b4f938d6dd95dbd58a31595a3a7760834bdb` implemented it and `4f076ce7c2a03b455409a318d045f526492497f6` repaired the missing public re-export. +- After the later v3 advance, fresh review found this v2 test still hard-coded serialized version `2` and instantiated `ProjectDocumentPayload` without the new optional field. `ace91a29e540919d02716dd492e290f9743422a8` made output assertions use `CURRENT_PROJECT_FORMAT_VERSION`, verified that v1/v2/legacy migration does not invent `sourceReference`, and preserved v2 as an input contract rather than current output truth. + +## Historical decision + +Version 2 introduced: + +```json +{ + "projectFormatVersion": 2, + "song": { "...": "validated RehearsalSong" }, + "preferences": { + "selectedPlaybackSource": "full_mix" + } +} +``` + +`selectedPlaybackSource` accepts exactly `full_mix`, `vocals`, `bass`, `drums`, or `other`. V1 and legacy inputs migrate to `full_mix` because they contain no durable evidence for a stem selection. A stored semantic never grants playback authority; reopen must resolve it against fresh native resource availability. + +## Alternatives rejected + +- **Persist the current `bandscope-playback` URL** — generation-bound capability is revocable runtime state. +- **Put the selection inside `song`** — it is project/UI preference, not MIR/rehearsal-song truth. +- **Use arbitrary strings or raw JSON** — malformed/future/runtime-only values would be accepted as domain truth. +- **Split song and preference admission** — one durable document would gain two inconsistent trust boundaries. +- **Infer the latest generated stem during migration** — historical artifacts contain no evidence for that claim. +- **Create a WebView persistence store** — that would create a second writer capable of disagreeing with the crash-safe project artifact. + +## Current effect + +The v2 decision survives in current v3 as the same closed `preferences.selectedPlaybackSource` domain and deterministic historical migration rule. Tauri `save_project`/`load_project` now admit/return the typed current document rather than the old song-only compatibility view, so the historical bridge gap described above has been superseded. + +Version 3 adds a separate optional path-free app-owned `sourceReference`. That field is deliberately not a playback authority and is not inferred for v2/v1/legacy projects. #970 now re-admits the app-owned full mix on restart against the persisted size and SHA-256 evidence and binds production analysis decode to a verified private byte snapshot. The remaining Active Player work is to reconcile durable `selectedPlaybackSource` intent with fresh Full mix/current-stem audible authorities under #1160. + +## Security Notes + +### Attack surface and trust boundary + +`.bscope` bytes and renderer IPC values are untrusted local input. Playback preference is admitted through Project Persistence only. Native resource admission remains the authority for playback capabilities. + +### Validation and fail-closed behavior + +The historical v2 envelope and current preference DTO use `deny_unknown_fields`; selection is a five-value enum; the rehearsal song remains strict typed data. Unknown root/preference values and literal `bandscope-playback://...` values fail before publication. Current v3 adds a separately typed source-reference boundary rather than weakening this preference contract. + +### Mitigations + +Keep durable playback intent as the closed five-value semantic, reject runtime capability strings and unknown fields at both renderer and native admission, migrate evidence-free historical projects deterministically to `full_mix`, and require fresh Resource Admission/Active Player authority before a stored stem preference becomes playable. + +### Realistic threats + +- a crafted project stores a filesystem path or revocable playback URL as if it were durable playback truth; +- a future or malformed preference token is accepted and later interpreted differently by renderer and native code; +- a historical project is migrated by guessing a stem selection that the artifact never recorded; +- a valid persisted stem preference is replayed after restart without checking whether that stem is currently admitted and audible. + +### Logging and privacy + +Preference/migration errors are bounded validation errors and need not echo project paths, song/collaboration content, media URLs, credentials, or audio metadata. The preference itself contains no locator. + +### Test points + +`project_format_v2_playback_preference.rs` continues to cover v1/legacy migration, every valid source token, invalid/revocable tokens, typed construction without runtime authority, and renderer-shaped admission. Its current-output assertions are version-aware so v2 remains verified as a supported predecessor instead of pretending to be the current writer. + +### Remaining risk + +The preference schema and full-mix restart/content identity path are no longer the blocker. Remaining work is primarily Active Player and release evidence: reconcile the durable selection against freshly admitted Full mix and current stem artifacts, fail closed to Full mix when a preferred stem is unavailable, complete mounted Save/Reopen and audible E2E on supported Windows/macOS packages, and retain crash/recovery/downgrade evidence and independent exact-head review before merge/release. diff --git a/docs/traceability/project-format-v3-source-reference.md b/docs/traceability/project-format-v3-source-reference.md new file mode 100644 index 000000000..a9eb5d1ef --- /dev/null +++ b/docs/traceability/project-format-v3-source-reference.md @@ -0,0 +1,134 @@ +# Project format v3: app-owned audio source reference + +## Problem + +Project format v2 can persist the Active Player selection semantic, but it cannot identify the admitted full-mix artifact needed after the desktop process restarts. The current mounted reopen path therefore recovers the song while clearing its bootstrap/source authority. Persisting the existing absolute `sourcePath` or a revocable `bandscope-playback` URL would make a user filesystem path or runtime capability part of durable project truth. + +The first v3 source-reference draft narrowed location authority correctly but retained only `fileSizeBytes` as content evidence. Byte length is not content identity: different audio bytes can have the same size. Treating size equality as sufficient re-admission evidence would let a replaced or corrupted app-owned source satisfy the durable reference and undermine rehearsal reproducibility. + +## Constraints + +- Project Persistence owns the `.bscope` schema and migrations; Resource Admission owns audio admission/materialization; Active Player owns playback selection and fresh runtime authority resolution. +- Historical projects must migrate deterministically. Missing evidence must stay missing rather than being inferred. +- A durable source handle must not contain a user filesystem path, WebView storage key, generation token, or runtime playback URL. +- Renderer and file input are untrusted and must remain passive JSON data. +- The source handle has to be sufficient for native re-admission to derive and verify an app-owned artifact without cross-service SQL or another writable authority. +- The v3 format is still Draft/unreleased work in #970, so tightening the v3 source-reference contract before merge is preferable to publishing an underspecified same-version schema and then maintaining it as compatibility debt. + +## RED evidence + +`3191f3865a78cf7a19babe3e611a3d07903787de` added the native contract test `project_format_v3_source_reference.rs`. The predecessor could not compile or admit the required `ProjectSourceReferencePayload` because the current format was still version 2 and `ProjectDocumentPayload` had no source-reference field. + +`6acd761f8a25b904352b2ae4eebcbc4f61ec5a48` extended the renderer/native bridge test with the same source-reference shape. The predecessor TypeScript parser admitted only `song` and `preferences`, so the new current-document payload was rejected. + +A fresh post-change sweep found a separate migration-test regression before hosted CI could be treated as evidence: `project_format_v2_playback_preference.rs` still hard-coded serialized version `2` and directly constructed `ProjectDocumentPayload` without the new optional field. `ace91a29e540919d02716dd492e290f9743422a8` repairs those expectations to `CURRENT_PROJECT_FORMAT_VERSION`, verifies historical migrations do not invent `sourceReference`, and restores the typed constructor without weakening v2 input compatibility. + +A later scientific/reproducibility review found that the initial v3 shape still admitted a source reference whose only content evidence was byte length. RED `39fb18192f55f2f28fdf97d8d213efad75f987e2`, refined in `ac5a080576a5ed40e0e997c6bb0ba37b90f1455d`, requires a durable source reference to carry content identity and rejects missing, shortened, uppercase, or non-hex digest representations. The predecessor accepted the digest-free shape, so this is a causal contract failure rather than a documentation-only finding. + +Renderer RED `16e54784c720e048d29d545643c5928b6d1265d5` applies the same requirement across the WebView/native boundary. A renderer response that omits or weakens content identity must be rejected before it can become durable project truth. + +## Selected design + +Version 3 adds an optional `sourceReference`: + +```json +{ + "projectId": "project-400-4", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 4096, + "contentSha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +} +``` + +The contract accepts only: + +- the existing opaque `project--` namespace minted by BandScope; +- `artifactName` exactly equal to `source.`; +- one admitted extension: `wav`, `mp3`, `flac`, or `m4a`; +- a positive byte length. The renderer additionally requires a JavaScript safe integer so it cannot silently round persisted byte evidence; +- a canonical lowercase 64-hex-character SHA-256 digest of the app-owned source bytes. + +`fileSizeBytes` remains useful as a bounded preflight and diagnostic signal but is not accepted as content identity. `contentSha256` is the durable equality check that Resource Admission recomputes over the re-opened app-owned artifact before creating fresh runtime authority. FIPS 180-4 defines SHA-256 as part of the Secure Hash Standard; NIST's current CAVP secure-hashing material, updated in August 2026, continues to list SHA-256 under FIPS 180-4. NIST has announced a future revision of FIPS 180-4, but that revision has not replaced the current final standard. + +The field is optional because v2/v1/legacy projects cannot prove that an app-owned source artifact exists. Their ordered migration writes version 3 with no invented reference. `selectedPlaybackSource` remains independent: it is rehearsal intent, while `sourceReference` identifies only the app-owned full-mix artifact required to rebuild native availability. + +The path-free shape is also a security boundary, not merely a portability choice. CWE-22 treats attacker-influenced relative/absolute pathnames as a path-traversal class, while CWE-59 covers file access that follows a link or shortcut to an unintended resource. The current reopen path derives the fixed artifact below the validated app-owned project root rather than trusting a persisted path, rejects linked/reparsed final components and substituted roots, verifies bounded size and SHA-256, and only then restores native source identity. This does not yet make every ancestor lookup descriptor-bound against concurrent replacement; that residual filesystem-identity risk remains separately tracked. + +## Rejected alternatives + +**Persist the original absolute path.** Rejected because it leaks local filesystem information, becomes stale when the file moves, gives the project document filesystem authority, and reintroduces a path-traversal-shaped input at reopen. + +**Persist `bandscope-playback://...`.** Rejected because the URL is a revocable runtime capability whose generation and availability are session-specific. + +**Persist the original file name and reconstruct a path heuristically.** Rejected because it retains unnecessary user metadata and is ambiguous. The fixed `source.` artifact name is narrower and deterministic. + +**Use byte length as content identity.** Rejected because distinct byte sequences can have identical length. Size remains a bounded preflight, not proof that the source used for rehearsal decisions is the same admitted artifact. + +**Use a non-canonical or variable-length digest string.** Rejected because multiple textual forms enlarge the durable contract without benefit. The project format stores one canonical lowercase SHA-256 representation. + +**Infer a source reference while migrating v2.** Rejected because the old document carries no evidence that Resource Admission materialized an app-owned source. Fabricating one would turn a migration into a guess. + +## GREEN implementation chain + +- `7e315daec207c1b09ea018353abaa1c34955d7b0` — version 3 envelope, deterministic v2/v1/legacy migration, strict native source-reference validation, and current serialization. +- `90ae48e1911113cc82c5ae99a4b0f1717a0ed075` — exports the new source-reference contract from the GUI-independent crate root. +- `f54be004887c11cd7a00065b7db86510e5c83ee8` — renderer current-document source-reference type and validation. +- `04b4a93dbd7ecf5c6d3bdf4434f7908d06ffd73b` — keeps optional source-reference descriptor inspection exception-safe instead of allowing proxy traps to escape the public validation contract. +- `c1cdcd036749a0a9231682db9446e5fbbe410d40` — verifies accessor/proxy-backed source-reference input is rejected without executing getters. +- `5203c2846dd2d12a02ad54204e9c6b5197d1177f` — updates the engineering format document to the code-current v3 contract and migration boundary. +- `ace91a29e540919d02716dd492e290f9743422a8` — repairs stale v2-output expectations and typed-constructor compilation after the version advance without weakening v2 input compatibility. +- `c2117f2a41e2c1db84aba6332c069dda59b5cad2` — requires canonical lowercase SHA-256 content identity in the native v3 source-reference contract. +- `7e853c5d6c40a35128afcf356536d2ca147ad109` — requires the same SHA-256 evidence in renderer admission and keeps digest/property inspection passive and fail closed. + +Subsequent #970 descendants adopted Resource Admission #866, inject retained publication identity into v3 Save, re-admit the exact app-owned bytes on restart, and pass the retained identity through the native-to-analysis boundary so production decode consumes a verified private byte snapshot. Hosted exact-head checks remain authoritative for repository GREEN; predecessor results are not transferable. The test-first/root-cause record also follows the released NIST SSDF 1.1 principle of integrating secure-development practices into the SDLC and addressing vulnerability root causes rather than treating a passing downstream check as the sole control. NIST published SSDF 1.2 only as SP 800-218 Rev. 1 Initial Public Draft in December 2025; this traceability therefore treats v1.1 as the released reference and the v1.2 draft as non-normative tracking input. + +## Security Notes + +### Attack surface and trust boundary + +`.bscope` JSON and renderer IPC values are untrusted. `sourceReference` crosses into Project Persistence as data only. It does not grant permission to open an arbitrary path. Native Resource Admission remains the only owner allowed to derive and admit the corresponding app-owned audio artifact. + +### Allowlist and validation + +Native and TypeScript boundaries reject unknown source-reference fields. Project ids use the existing BandScope minted-id grammar. Artifact names are derived from the admitted extension and cannot contain path traversal. The extension is closed to the existing audio allowlist. Byte evidence must be positive; the renderer additionally rejects unsafe integers. `contentSha256` must be exactly 64 lowercase hexadecimal characters. The string is evidence to be verified, not trusted merely because its syntax is valid. + +### Mitigations + +Project Persistence stores only the path-free identity tuple, derives the fixed app-owned artifact from the validated project namespace, and delegates byte truth to Resource Admission. Restart re-admission rechecks project/root constraints, regular/no-link semantics, exact size and SHA-256 before native identity is restored. Production analysis then carries that retained evidence to the child process and decodes a verified private snapshot rather than trusting a later pathname reopen. + +### Realistic threats + +- a crafted `.bscope` attempts path traversal or substitutes an arbitrary artifact name or extension; +- an app-owned source is replaced with different same-size bytes after the project was saved; +- a symlink/reparse point or substituted project root redirects reopen outside the intended project namespace; +- a renderer fabricates digest/size evidence and tries to impersonate native Resource Admission state; +- a valid durable preference is mistaken for current playback authority without re-admitting the corresponding Full mix or stem. + +### Safe failure + +Malformed references fail before project publication or before a reopened document is accepted by the renderer bridge. Historical inputs migrate without a reference rather than fabricating an authority. Re-admission fails closed if the derived artifact is absent, non-regular, linked/reparsed at the governed boundary, has the wrong size, has a SHA-256 mismatch, or fails the applicable admission/decode path. CWE-59 specifically makes link resolution before file access part of the threat model, so lexical containment plus matching digest syntax is not sufficient acceptance evidence. + +### Logging and privacy + +The durable reference intentionally excludes the original local path and original file name. The SHA-256 digest is content-derived metadata and must be treated as purpose-bound project integrity evidence rather than a user identifier. Error reporting should continue using bounded/redacted buyer copy and must not add the derived app-owned path to renderer-visible diagnostics unless there is a separate explicit diagnostic contract. + +### Test points + +`project_format_v3_source_reference.rs` covers current round-trip, v2 migration without invention, project-id/path/artifact/extension/size rejection, unknown `sourcePath` rejection, and canonical SHA-256 requirements. `project_format_v2_playback_preference.rs` keeps legacy/v1/v2 compatibility explicit while asserting current-version output and absent invented source evidence. `projectDocumentBridge.test.ts` covers the renderer/native payload boundary, including digest presence and canonical representation. `projectDocument.plainRecord.test.ts` covers passive record semantics and getter/proxy rejection. Current Project Persistence integration tests additionally cover restart re-admission, root/link substitution, byte mutation/growth/truncation, and the native retained-identity handoff. + +### Remaining risk + +The v3 schema, retained Save handoff, restart exact-content re-admission and production analysis byte continuity are implemented in the Draft #970 ancestry, but release evidence is incomplete. Higher-ancestor filesystem identity is not fully descriptor-bound against concurrent replacement on all supported platforms. Active Player #1160 must still reconcile persisted `selectedPlaybackSource` with fresh Full mix/current-stem audible authorities and fail closed to Full mix when the preferred stem is absent. Cleanup/retention, mounted Save/Reopen, crash/power-loss, downgrade/application rollback, and rights-cleared Windows/macOS real-audio acceptance remain required before release readiness. + +## References + +National Institute of Standards and Technology. (2015). *Secure Hash Standard (SHS)* (Federal Information Processing Standards Publication 180-4). https://doi.org/10.6028/NIST.FIPS.180-4 + +MITRE. (2026). *CWE-22: Improper limitation of a pathname to a restricted directory ('Path Traversal')* (CWE Version 4.20). https://cwe.mitre.org/data/definitions/22.html + +MITRE. (2026). *CWE-59: Improper link resolution before file access ('Link Following')* (CWE Version 4.20). https://cwe.mitre.org/data/definitions/59.html + +Scarfone, K., Souppaya, M., & Dodson, D. (2022). *Secure Software Development Framework (SSDF) Version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 + +Booth, H., Ogata, M., Kent, K., Souppaya, M., & Dodson, D. (2025). *Secure Software Development Framework (SSDF) Version 1.2: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218 Rev. 1, Initial Public Draft). National Institute of Standards and Technology. https://csrc.nist.gov/pubs/sp/800/218/r1/ipd diff --git a/docs/traceability/project-persistence-shared-song-contract.md b/docs/traceability/project-persistence-shared-song-contract.md new file mode 100644 index 000000000..3969384b2 --- /dev/null +++ b/docs/traceability/project-persistence-shared-song-contract.md @@ -0,0 +1,79 @@ +# Project Persistence Shared-Song Contract Traceability + +## Problem + +The desktop shared contract already permits collaboration data and role-level rehearsal fields, but the native Project Persistence DTO on #970 did not preserve them. Because the native DTO uses `deny_unknown_fields`, a renderer-valid `RehearsalSong` containing collaboration, `harmonicExplanation`, `transpositionPlan`, `transcription`, or `practiceProgress` could be rejected at save/load. Follow-up review found inverse drift modes as well: unrestricted Rust strings / unconstrained progress could accept invalid domain values, Rust `Option` would silently accept explicit JSON `null` where the TypeScript validator accepts only omission or a value of the declared type, and several older native fields still admitted arbitrary strings where the shared contract defines closed domains. + +## Constraints + +- #970/#962 remains the canonical Project Persistence owner; #1160 is evidence/consumer work, not a second durable storage authority. +- Preserve finite-positive tempo validation, strict unknown-field rejection, legacy/v1/v2 compatibility fixtures, the current versioned migration boundary, and atomic publication/recovery behavior. +- Do not serialize volatile `bandscope-playback` authorities or user filesystem paths into `.bscope` files. +- Do not replace the current file wholesale with an older #1160 snapshot because it predates later #970 persistence hardening. +- Closed-domain validation must mirror the current shared renderer contract rather than inventing persistence-only values. + +## RED → fix evidence + +- Structural RED `93e9e80fa13d93692fdbd8d7d9acd10714ee8e8d` requires parse/serialize preservation of current collaboration and role fields. `819d8af80e425dc5627d86659a5fc97ec90c2767` adds typed native DTOs while retaining tempo/unknown-field invariants. +- `6bcdf160a7e95cc540d96e49e25868c19a438106` proves invalid collaboration sync/status tokens and `practiceProgress = 101` fail closed; `a1cf37ea98db2f8024ca710d563d879c04204961` closes those domains with enums and a 0–100 integer bound. +- `ed61d1c5f10e2baa4290fb40d692b82fb7dde500` proves explicit `null` is not omission for collaboration, collaboration `roleId`, and role explanation/transposition/transcription fields. `8b4ae848ec360a5af42b50076af15b643ae5275e` implements present-value deserialization; `ed9abedf0e5069fa93780fa3440ca91500cbdd93` extends it to optional `scoreAttachments`. +- `2b0a47e6305b7b7a3e87857335d0f36dfabc9712` adds negative closed-domain cases; `96d66ed6f5fad918b0ddef8a1e6494b76f8bafd0` replaces unrestricted strings with exact serde enums. Manual overrides use a user-only harmony payload so outer `source: "user"` cannot mask model-owned nested provenance. `f8c30150375b39d54e1775d941f6515d2686410c` exercises every valid section-form, confidence, provenance, role-type, cue-kind, rehearsal-priority, and export-format token. +- `d7886876b285f16ceda83ff5e0dd848e31cf7f97` extends the repository Security Notes verifier from plans to traceability records; `0185267ab819dd4b9ac1352f5fce1df8e2a7a782` adds the required Project Persistence security boundary. +- Governance RED `45037f9fe5aa7c265d28c0da33fdedcf26f3ac49` proves a later peer section cannot supply missing Security Notes evidence and requires the project-format document to use the canonical heading. `907f3a7f70db0ac9fce11839ee215600cec15abe` bounds extraction at the next same-level heading; `65e422cd4d39014f2586ca86616f25b4b17e5e82` adds the nested-heading case and `d1ba145d9cdd7126df240a02d0c07f253a80d3c3` closes the remaining parent-heading escape without excluding legitimate nested subsections. `3883f342ac427fbed35fe2a88c4c6e7dd2f6a499` keeps the verifier Ruff import ordering canonical. +- `a7c86be8e20895e3baebee44d33ef765e0837b5f` requires the buyer-visible limit to name the exact `5 * 1024 * 1024` ceiling as 5 MiB. `04e19ef6d19aced87e22015e4ec165cbce89f1d0` fixes the native diagnostic and `73d6a80183c19166b75be05f9286bee3769069e0` aligns the engineering format documentation without changing the byte threshold. +- Later project-format work preserves these shared-song rules while advancing current writes to version 3. `ace91a29e540919d02716dd492e290f9743422a8` repairs stale v2-output assertions so legacy/v1/v2 remain predecessor compatibility inputs rather than being mistaken for current output. + +The shared renderer authority is `packages/shared-types/src/index.ts` on protected `develop`. Relevant domains are section form `intro | verse | pre-chorus | chorus | bridge | outro | tag | pickup | stop | handoff`; confidence `low | medium | high`; provenance `model | user`; cue kind `lyric | count | transition`; role type `instrument | vocal | hand`; rehearsal priority `low | medium | high`; export format `cue-sheet | chart-summary`; manual override field `harmony` with outer and value provenance fixed to `user`; collaboration sync `local_only | planned_cloud`; assignment status `todo | in_progress | ready | blocked`; comment status `open | resolved`; approval status `pending | approved | changes_requested`; and optional integer `practiceProgress` from 0 through 100. Optional fields use omission, not explicit `null`, as the absent representation. + +## Alternatives rejected + +- **Copy the #1160 Rust snapshot:** it would overwrite later #970 persistence invariants and violate owner/consolidation boundaries. +- **Store new fields as `serde_json::Value`:** it weakens the fail-closed schema and turns compatibility into an untyped bag. +- **Keep shared closed domains as `String`:** malformed or future tokens could be persisted as current domain values. +- **Use general provenance for manual overrides:** the shared contract requires the override and its harmony value to be explicitly user-owned. +- **Clamp invalid practice progress:** silent coercion hides corruption or contract drift. +- **Treat explicit `null` as omission:** the renderer does not, so doing so natively creates cross-language disagreement. +- **Keep `5MB` for a binary ceiling:** 5 × 1024 × 1024 bytes is 5 MiB; buyer-visible diagnostics must name the actual unit. +- **Search for Security Notes keywords until end-of-file:** unrelated later sections could make an incomplete security record pass the verifier. Extraction must respect Markdown section hierarchy. + +## Current effect + +A current shared rehearsal song crosses Project Persistence without dropping the covered fields. Collaboration/progress state, omission-versus-null semantics, and closed section/role/confidence/provenance/cue/export/manual-override domains are typed rather than arbitrary strings. The project ceiling remains exactly 5,242,880 bytes. + +Current `.bscope` writes are now `projectFormatVersion: 3`, not v1. V3 retains the closed stable playback preference and adds an optional path-free app-owned `sourceReference`; legacy raw-song, v1, and v2 inputs migrate deterministically without inventing source evidence. The source-reference schema is separate from shared-song MIR/rehearsal truth. + +The Security Notes verifier now treats the requested heading as a real Markdown section: nested subsections remain inside it, while the next peer or parent heading terminates the evidence window. A later Operations/Decision section therefore cannot satisfy missing mitigation/test/risk requirements by keyword coincidence. + +Transcription-number semantics still require an evidence-driven cross-language contract: the shared validator currently type-checks `onset`, `offset`, and `velocity` as JavaScript numbers rather than defining rehearsal-specific numeric bounds, so persistence must not invent such bounds without product/scientific evidence. + +## Security Notes + +### Attack surface + +`.bscope` content is untrusted local file input. Save targets, recovery journals, staged/backup/displaced files, file metadata, collaboration payloads, role-level rehearsal data, renderer project JSON, and the optional app-owned source reference cross trust boundaries. Documentation evidence itself is also a governance input: a permissive parser could misclassify incomplete Security Notes as compliant. This remains local-first and adds no network authority. + +### Trust boundary + +Native Project Persistence is the durable storage authority. It admits the versioned envelope, applies `deny_unknown_fields`, validates finite-positive tempo and closed domains, rejects explicit `null` where omission is required, and keeps volatile playback capabilities and user paths out of durable truth. Resource Admission—not Project Persistence—owns the future derivation/re-admission of an app-owned audio artifact from a validated v3 source reference. The repository verifier owns only documentation-policy evidence and must not infer required content from outside the actual Security Notes section. + +### Mitigations + +Typed allowlists are used instead of arbitrary JSON/string bags. Reads are bounded to 5 MiB and use no-follow/native-identity checks. Saves stage and sync complete bytes before publication, preserve data-file permissions without executable/special bits, and use target-scoped recovery journals plus parent-directory synchronization. Current source references are path-free and limited to a BandScope project id, fixed `source.` artifact name, admitted extension, and positive byte evidence; malformed references fail before publication. Security Notes extraction stops at the next heading whose level is the same as or higher than the Security Notes heading, preserving legitimate nested subsections while excluding unrelated later evidence. + +### Safe failure and logging/privacy + +Malformed/unsupported envelopes, invalid shared-domain tokens, explicit-null drift, unsafe paths, source-reference mismatch, identity mismatch, oversized files, and ambiguous recovery state return bounded product errors without echoing project content, local paths, collaboration text, credentials, or secret-shaped values. Failure must retain known-good data or retryable recovery state once mutation begins; it must not coerce corrupt values, fabricate source evidence, or fall back to direct overwrite. A malformed or incomplete documentation section fails verification instead of borrowing keywords from later content. + +### Test points + +Executable coverage includes shared-song parse/serialize parity, closed-domain positive/negative cases, omission-versus-null behavior, progress bounds, legacy/v1/v2 migration, v3 source-reference round trip/rejection, exact 5 MiB diagnostics, symlink/reparse and ancestor checks, native file identity, first-save/no-clobber behavior, existing-target replacement, stage cleanup, permission normalization, Windows replacement/recovery, macOS/Windows case-alias recovery, completed rollback, stale-journal cleanup, and passive renderer object admission. `scripts/checks/verify_security_notes.py` treats traceability records as governed Security Notes documents; regression coverage proves both peer-heading and parent-heading boundaries and the canonical `## Security Notes` heading in the project-format document. + +### Realistic threats + +Relevant threats are malformed/future project payloads being treated as current truth; a local directory participant racing or pre-creating recovery names; link/reparse redirection; file replacement between preflight and publication; interruption during replacement/rollback; permissive modes exposing rehearsal data to another local account; executable renderer object shapes crossing the adapter; stale playback/user-path authority being persisted as project truth; and incomplete security documentation being accepted because required words appear later in an unrelated section. These controls do not claim protection against a fully compromised OS or attacker with equivalent account authority. + +### Remaining risk + +Version 3 defines only the source-reference schema. Resource Admission still references the selected external absolute source and retains bootstrap state only in process memory; mounted project load still clears that bootstrap. #970/#962 must materialize the admitted full mix under the app-owned project namespace, write the reference only after successful materialization, re-admit it after process restart, and add stronger content identity if required for reproducibility. #1160 then has to compose the stored semantic with fresh native availability and fail closed to Full mix when a stem is unavailable. + +Parent authority is still path-based after lexical validation rather than descriptor-bound. Autosave, known-good backup rotation, global startup recovery, deterministic migration receipts/hashes, downgrade behavior, exhaustive interruption/power-loss injection, mounted Save/Reopen UX, and packaged Windows/macOS real-audio acceptance remain #962/release work and must not be described as shipped or fully crash-safe without exact-head evidence. diff --git a/docs/traceability/project-v2-ipc-bridge.md b/docs/traceability/project-v2-ipc-bridge.md new file mode 100644 index 000000000..06864a460 --- /dev/null +++ b/docs/traceability/project-v2-ipc-bridge.md @@ -0,0 +1,68 @@ +# Project v2 IPC Bridge Traceability + +> Historical bridge slice. The bridge introduced for v2 remains in use, but current Project Persistence writes version 3 and can carry an optional path-free `sourceReference`. See `project-format-v3-source-reference.md` for the current source-reference boundary. + +## Problem + +Project Persistence had a strict v2 document and durable `preferences.selectedPlaybackSource`, but the production desktop bridge initially admitted and returned only the `RehearsalSong` compatibility view. Active Player therefore lacked one typed Save/Reopen path for `full_mix | vocals | bass | drums | other` without creating another WebView store or persisting a revocable `bandscope-playback` authority. + +Later review found that renderer admission accepted custom-prototype objects, then that Proxy own-key/descriptor traps and accessor-backed fields could escape the stable validation contract or execute application-controlled getters. Those executable JavaScript shapes cannot originate from parsed JSON and have no durable `.bscope` meaning. + +A further browser-preview review found a separate buyer-truth defect: when Tauri was absent, the browser fallback returned success for `save_project` even though no project bytes were persisted anywhere. Preview/browser tests could therefore observe a false successful-save outcome that production desktop persistence never performed. + +After Resource Admission entered #970 ancestry, a second handoff defect remained: native selection retained a verified `LocalAudioPublicationIdentity`, but Save neither accepted an explicit project selector nor injected that retained identity into v3 immediately before serialization. The mounted App also lost the aggregate id between local-audio analysis and Save. + +## Constraints + +- Project Persistence remains the only durable `.bscope` authority. +- Resource Admission remains the owner of app-owned local-audio bytes, bounded byte evidence and SHA-256 publication identity. +- Renderer may select only an already-minted BandScope project id for Save; it may not submit a path, artifact name, byte count, digest, or `sourceReference`. +- Multiple project aggregates can coexist, so a global last-selected-project shortcut is not valid authority. +- Playback selection persists only as a stable semantic; filesystem paths, native capability URLs, generation tokens and discovery receipts remain outside preference state. +- Song-only callers remain compatibility adapters and deterministically default to `full_mix` when they do not own a selection. +- Unknown fields, prototype-bearing records, accessors, enumeration/descriptor traps and runtime-authority strings fail closed before persistence IPC; native admission repeats the typed boundary. +- Browser preview has no durable project-file authority and must fail closed for Save/Load rather than simulate successful persistence. +- The bridge does not itself make a reopened stem playable. Stored intent must be combined with freshly re-admitted native audio availability. + +## RED → fix evidence + +- `ecc2904f55516806b51baa4bbafeef9d700b058c` added the renderer bridge RED for all five stable semantics, round-trip load, runtime-authority rejection and unknown preference fields. +- `30bfa590df61a2b031076af81010f3e5f31372ea` added the TypeScript Project Persistence anti-corruption boundary; `64613fbb604c4ddc6d156c84bc520dd8d40cef19` wired `saveProjectDocument`/`loadProjectDocument` through the existing Tauri command boundary. +- `7f9d118b08038fd5473b71f0a1243136b39e04bc` changed native `save_project` to strict current-document admission and `load_project` to return the typed current document. `327c83f86c1ed213a1f6a58d382715e744ab9831` immediately reverted an unrelated transient score-root edit found during review. +- `3db1096baa52de34baa7fea4c1638185914d22b7` added the custom-prototype RED; `7cc4869560155039ff1e2e10d171505885dc39e3` restricted admission to ordinary/null-prototype JSON records, and `3f4ce38c2be533a7b8bc90cd67b702d624cd3d1a` closed its edge coverage. +- `a71439d82932f671d8079c5f7c78b401679dcb6b` added Proxy/accessor REDs. `bc8e144355353e6311425afe734dfcf8e282ccd5` made exact-key enumeration exception-safe and required own enumerable data properties; `bc7e6c5877da9af6c9a349ea6e6c78c55eecec4e` added nested selection-accessor coverage. +- The later v3 source-reference extension preserves the same passive-record boundary: `f54be004887c11cd7a00065b7db86510e5c83ee8` adds the renderer source-reference contract, `04b4a93dbd7ecf5c6d3bdf4434f7908d06ffd73b` closes optional descriptor traps, and `c1cdcd036749a0a9231682db9446e5fbbe410d40` verifies source-reference getters/traps are not executed. +- Browser-persistence RED `6eea76fdb138838d61e8af0d23ea69d99012de21` requires Save without a Tauri invoke bridge to reject instead of reporting a success that wrote no bytes. Fix `cb7f4fd956278d1273e6be3f2df367171baadf9e` makes the browser fallback fail closed with `Local project save is not available in browser preview.` while leaving native Tauri persistence unchanged. +- Native handoff RED `cd3c67de8c9d35355aa30950733a1cf24a5d23fc` requires `save_project` to accept only an explicit optional project id, read `LocalAudioPublicationIdentityState`, use the typed Resource Admission → Project Persistence ACL, and avoid a global last-selected shortcut. Fix `ffdac30e63c4faa7264416bbeec8570a0c6543ff` adds `project_document_with_retained_source_reference`, performs exact native lookup by project id, revalidates through `project_source_reference_from_publication_identity`, and injects the result before serialization. +- Renderer-selector RED `570894b91f69b0e24c26090309fe5ee5d414f514` requires `saveProjectDocument(document, projectId)` to send only `{ payload, projectId }`. Fix `979ac4d3a948ab76a01e076fa29bece6161489d6` adds that optional selector while retaining the renderer-authored `sourceReference` rejection. +- Mounted-flow RED `28d94d0e9566030c53370829484f808f6763fbcf` requires an analyzed OS-selected local project to Save with its exact minted project id. Fix `06afcbe030ef1fb8d6b2097be0e40bc4d5c7c03a` tracks the local publication project id separately from generic bootstrap state, binds it to the submitted analysis result, clears it on load/failure/YouTube replacement, and passes it only when saving that local result. This avoids binding an old result to a newer selection and avoids sending YouTube ids that have no `LocalAudioPublicationIdentityState` entry. + +## Alternatives rejected + +Persisting the opaque playback URL was rejected because its authority is intentionally revocable. `localStorage` was rejected as a second writable project truth. Adding selection to `RehearsalSong` was rejected because it is UI/project preference, not MIR evidence. Arbitrary class/Proxy/accessor objects were rejected because executable object behavior has no `.bscope` semantics. Replacing compatibility APIs outright was rejected because unrelated callers do not necessarily own Active Player state. Pretending browser preview persisted a project was rejected because it produces unverifiable buyer-facing success and can make browser E2E pass without exercising the desktop durability boundary. A native or renderer-global last-selected project was rejected because it is stale-race-prone and cannot distinguish multiple aggregates. Sending a full `sourceReference` from the WebView was rejected because it would let renderer data impersonate Resource Admission evidence. + +## Security Notes + +**Attack surface.** Renderer IPC and reopened `.bscope` JSON are untrusted. Playback capabilities and any source locator are also untrusted and must not become durable authority merely because the renderer sees them. + +**Trust boundary.** TypeScript validates the current project document before invoke and after load. For local-audio Save, the WebView can add only the already-minted project id selector. Tauri performs exact lookup in native `LocalAudioPublicationIdentityState`, revalidates the identity through the Project Persistence ACL, and injects the path-free `sourceReference` before serialization. Rust repeats strict typed admission before filesystem mutation and after bounded read. Active Player/resource admission mints runtime playback authority later. A browser preview without the Tauri bridge is outside the durable project-file boundary and cannot claim Save success. + +**Mitigations.** Exact-key checks are exception-safe; plain-record checks reject custom prototypes; persisted values are read through own enumerable data-property descriptors; getters and descriptor traps do not become project data. The closed five-value preference, `parseRehearsalSong`, Rust `deny_unknown_fields`, bounded reads and crash-safe publication remain layered controls. Version 3's `sourceReference` is separately typed and path-free rather than being smuggled into this preference field. Browser fallback rejects project Save/Load instead of creating a second in-memory persistence truth. Local result-to-project association is captured at analysis submission rather than read from whatever source happens to be selected at Save time. + +**Realistic threats.** A crafted renderer object can attempt to execute getters or Proxy traps during validation; stale global project selection can bind one result to another aggregate; a browser preview can falsely report persistence that never wrote bytes; a renderer can try to submit path/digest authority that belongs to native Resource Admission; or a reopened project can carry valid durable intent whose current audio/stem authority no longer exists. + +**Test points.** Bridge tests cover all five preferences, load round trip, runtime-authority/unknown-field rejection, source-reference admission, invalid path-shaped reference fields, renderer-authored source-reference rejection before IPC, browser-preview Save fail-closed behavior, explicit project-id selector forwarding, native retained-identity lookup/injection, and mounted local-audio analysis → Save identity continuity. `projectDocument.plainRecord.test.ts` covers custom prototypes, proxy traps, accessor non-invocation, null-prototype acceptance and ordinary JSON records. Native format tests cover historical migration and current v3 source-reference shape. + +**Logging/privacy.** Rejected object contents, trap text, local paths and project payloads are not forwarded as validation output. The public renderer error remains bounded rather than echoing attacker-controlled exceptions. + +**Remaining risk.** The bridge and full-mix restart/content-identity path are now present in #970, but the persisted playback preference is still intent rather than fresh audible authority. #1160 must re-admit current Full mix/stem resources, reconcile the stored selection, and fall back to Full mix when a preferred stem is absent. Supported Windows/macOS mounted Save/Reopen, crash/recovery, downgrade/rollback and real-audio E2E remain release evidence gaps. + +## Current effect and remaining risk + +The desktop IPC and Project Persistence now speak the same typed current document; the historical song-only Tauri gap is closed. Current writes are v3, not v2. The document can carry both a stable playback preference and an optional path-free app-owned `sourceReference`. Browser preview no longer reports a successful project Save when it has no durable file authority. + +Resource Admission #866 materializes OS-selected local audio into app-owned `project_root/source.`, verifies the published bytes against a bounded size+SHA-256 receipt, builds a path-free `LocalAudioPublicationIdentity`, and retains that identity in native state before renderer bootstrap authority is returned. Project Persistence #970 ordinarily adopted that implementation, exposes the typed `project_source_reference_from_publication_identity` ACL, injects the exact retained identity into v3 Save when the mounted local-analysis result supplies its minted project id, and on restart re-admits the app-owned artifact against persisted size and SHA-256 evidence before native source identity is restored. Production analysis then decodes a private snapshot verified against the retained evidence rather than trusting a later pathname reopen. + +The principal remaining product gap is therefore Active Player authority reconciliation rather than durable full-mix identity. #1160 may resolve persisted `selectedPlaybackSource` only after fresh Full mix and current stem authorities exist and must fail closed to Full mix when a preferred stem is no longer available. + +Packaged Windows/macOS Save/Reopen, crash/power-loss, autosave/recovery, downgrade/application rollback, current-stem re-admission, audible E2E and independent exact-head review evidence remain release gates. diff --git a/docs/traceability/project-v3-source-restart-readmission.md b/docs/traceability/project-v3-source-restart-readmission.md new file mode 100644 index 000000000..b17d77c6b --- /dev/null +++ b/docs/traceability/project-v3-source-restart-readmission.md @@ -0,0 +1,133 @@ +# Project v3 source restart re-admission + +## Problem + +Project format v3 can persist a path-free `sourceReference` after Resource Admission has materialized and verified the app-owned full-mix artifact. On restart, persisted evidence must not become filesystem or playback authority merely because its JSON shape is valid. A replaced, truncated, extended, or same-size-mutated `source.` must not silently regain rehearsal authority. + +The Save path keeps the original user path out of durable project truth and stores `projectId`, fixed `artifactName`, admitted `extension`, bounded `fileSizeBytes`, and canonical lowercase `contentSha256`. Restart therefore needs three distinct steps: validate durable evidence before any filesystem lookup, resolve only an already-existing app-local project aggregate without provisioning a replacement directory, then re-establish native content identity only from an app-owned descriptor whose bytes reproduce the exact persisted receipt. + +A later mounted round-trip review found a separate persistence loss after native re-admission had already succeeded. The renderer compatibility `loadProject()` returned only `document.song`; `App` then cleared the restored project selector and later called `saveProject(song)` with the default `full_mix` preference and no native project id. An ordinary Open Project → Save Project sequence could therefore publish a new `.bscope` document without the verified `sourceReference` and could silently replace a persisted stem preference such as `vocals` with `full_mix`. + +## Constraints + +- Resource Admission owns audio byte admission and `LocalAudioPublicationIdentity`; Project Persistence owns the durable v3 document; Active Player owns fresh playback authority. +- Persisted JSON is evidence, not permission to open a path. +- Durable fields must be validated before an opener receives any derived artifact path. +- The Tauri-provided app-local base and the project root below it must already exist as real directories rather than symlinks/reparse points. Reopen must never call the provisioning path for that project root. +- The final artifact descriptor must come from the native platform opener so O_NOFOLLOW/reparse-point and file-identity primitives are not copied into the core reverse ACL. +- Size is a bounded preflight, not content identity. SHA-256 equality is required for the opened bytes. +- The verifier must stop after the expected byte length plus a one-byte growth probe rather than hashing an unexpectedly large object. +- Historical projects without `sourceReference` remain without source authority; migration does not invent evidence or provision a project root. +- Renderer save IPC may select only the already-restored BandScope project id. It must not author a path, digest, artifact name, byte count, or `sourceReference`. +- The mounted renderer must preserve the complete reopened Project Persistence intent needed for a subsequent save. A song-only compatibility view cannot be treated as the durable aggregate. +- A content-identity match alone does not prove descriptor-bound parent-directory containment, future path stability, audio decodability, or current playable-stem availability. Those remain explicit runtime responsibilities before playback authority is issued. + +## RED evidence + +`f1d307d415787f137660eb982614fa1f9d37f6e7` introduced the executable core content-identity contract. It requires exact persisted WAV bytes to regain native publication identity and rejects same-size mutation, growth, truncation, forged artifact identity, malformed project id, and non-canonical extension evidence. + +The later native-opener RED `66ed5ec328d498bae59af2814b20a16884f30bae` required restart code to stop at the canonical no-follow opener boundary rather than reconstructing an ambient pathname. That first contract deliberately could not compile on its predecessor because no project-root re-admission adapter existed. During the fix the responsibility was placed in the GUI-independent Project Persistence/Resource Admission ACL rather than duplicating platform open primitives in Tauri. + +`ece508c6bddd42da06ba0a0278c1baf9d1fd2949` added the next realistic filesystem RED: reopen must resolve an already-existing regular project directory, reject a missing project root without creating it, and refuse a linked project directory. The first RED intentionally referenced a not-yet-existing read-side resolver; `f7e868564ac4fb88953b660709b96dee40b604e9` introduced that resolver and `b2fb79833ca917084777c206808d0a67a7be1bdc` bound the executable tests to its final Tauri adapter module. + +`9cd4681ccc8fb1f1ed9e5cacc9f6da5e12086f06` then required the production `load_project` command itself to receive native app/state authority and invoke one restart adapter before returning a persisted v3 document. That predecessor had the reusable core ACL but no production call site, so the contract failed by construction until the following production fix. + +`1c8bc3d0668d505dbd94ebe23d58584270d6b09b` adds the app-local-base authority regression. It constructs a valid project directory below a real app-local fixture, exposes that fixture only through a symlinked base path, and requires reopen root resolution to reject the linked base instead of treating the ordinary child directory reached through it as app-owned authority. The predecessor checked only the final project child and therefore admitted that redirection. + +`9ceeb2faa73317e591a1741a0d246b82f9311423` adds the mounted Open→Save regression. It supplies a reopened v3 document carrying `preferences.selectedPlaybackSource = vocals` and `sourceReference.projectId = project-500-5`, then requires the Save action to call the persistence bridge with that same preference and exact native project selector. The predecessor `App` called the song-only `loadProject()` compatibility wrapper, cleared `jobResultPublicationProjectId`, and later saved with the `full_mix` default, so the new contract fails on that predecessor without relying on a mock-only success path. + +The deterministic PCM/WAV-like bytes used by the core and native filesystem contracts are unit fixtures only. They validate bounded content identity and filesystem authority composition, not MIR or decoder quality. They are not production scientific acceptance; rights-cleared real decoded audio remains required for release acceptance. + +## Selected design + +`823cd4aea009a3d0904cc9710971c70389dd6ad4` added `re_admit_local_audio_publication(reference, reader)`, with `54390ce88fa6f082171682dc6d32ac5aa4a8cfe3` exporting the reverse content ACL. Hardening through `e1158119a73a357956d042bb3d0bd977ababef8d` proves malformed evidence is rejected before reading and native read failures collapse to the bounded workspace diagnosis. + +`f36996f251e0fdbe300df6f525d2b64fff785f3a` adds `re_admit_local_audio_publication_from_project_root(project_root, reference, open_file)` and the transient `ReAdmittedLocalAudioPublication` value object. `c7e112fa4da9f28ad886cdd21afa83ac6a7a3846` exports that ACL from the canonical desktop-core root. The adapter validates the durable reference first, requires the supplied project root basename to match the same `projectId`, derives the lookup only from the validated fixed `source.` identity, and then asks the injected native opener for a descriptor. Only that opened stream is hashed and compared with the persisted bounded receipt. + +Native opener coverage through `909d54f64889b977dc1b7e7eba10999f503005a9` verifies an exact regular app-owned source can be re-admitted, traversal-like durable artifact evidence is rejected before an opener is invoked, and a Unix symlink at the final `source.wav` component is refused by no-follow handle acquisition. Core coverage in `b975843d57a6642fe54c36e242693473f8d25852` also proves a project-root mismatch fails before filesystem authority is requested. + +The read-side resolver introduced at `f7e868564ac4fb88953b660709b96dee40b604e9` is deliberately distinct from `app_owned_root`. It validates the BandScope project id, derives the app-local child, requires that child to already exist as a real directory rather than a symlink or Windows reparse point, and never invokes `create_dir_all`. `a839b5b495ccdb70ee3c37e253d58f438f6c58be` additionally validates the Tauri-provided app-local base itself before joining the project id, so a directly linked/reparse app-local base cannot redirect reopen into another subtree. Cache and temp workspaces remain provisionable runtime resources, but production reopen creates them only after the persisted source has passed project-root and exact-byte re-admission. + +Production integration `0f20b072a245feca59c72ac29b21968b41982f46` wires this sequence into `load_project`. After recovery and bounded project parsing, a v3 document with `sourceReference` resolves the existing app-local project root, reopens the fixed source through `project_persistence::open_project_file`, verifies exact byte length and SHA-256, provisions cache/temp runtime roots, and atomically acquires both native state locks before restoring `LocalAudioPublicationIdentityState` and the matching `ProjectBootstrapSummaryPayload`. A legacy document without `sourceReference` returns without inventing source authority. `ddeff8b48b59ef9e43804d9cd6a569ee2c4aefbb` is formatting-only follow-up for the new resolver. + +The restored bootstrap keeps `source_path` transient in native memory. The durable document still contains no filesystem path, and renderer save IPC remains unable to author a digest, artifact name, byte count, or `sourceReference`. + +`9a9151d1a5420c83218ac220d29cb144c9e3b45d` repairs the mounted renderer round trip without weakening that boundary. `App` now consumes the complete `loadProjectDocument()` result, keeps only the durable `selectedPlaybackSource` intent plus the path-free `sourceReference.projectId` selector needed for later native lookup, and passes them back to `saveProject`. The renderer still never reconstructs or submits the digest, artifact name, byte count, or path. New analysis results initialize the persistence preference to `full_mix`; reopened projects preserve the preference that was actually stored. + +## Rejected alternatives + +**Trust the persisted digest after schema validation.** Rejected because a syntactically valid digest only states what bytes are expected; it does not prove the current app-owned artifact still contains those bytes. + +**Reuse `app_owned_root` during load.** Rejected because that function calls `create_dir_all`. A missing or replaced project aggregate must make reopen fail, not cause the read path to manufacture a directory that did not back the persisted evidence. + +**Trust a linked app-local base because its project child is a regular directory.** Rejected because the child check occurs after ancestor traversal. A stable-looking app-local path can otherwise redirect native reopen into a different subtree before the child metadata is inspected. + +**Accept `artifactName` as a pathname.** Rejected because typed durable data is still untrusted. The adapter first reconstructs the canonical Resource Admission identity and derives the fixed artifact name from the admitted extension; forged path-like text fails before the opener is invoked. + +**Copy O_NOFOLLOW/reparse-point logic into the core reverse ACL.** Rejected because `project_persistence::open_project_file` already owns the supported-platform final-component handle primitive and native file-identity checks. The reverse ACL injects that authority instead of creating a second core security implementation. + +**Compare only file size.** Rejected because same-size replacement is a realistic integrity failure and is explicitly covered by the executable contract. + +**Hash until EOF without the persisted bound.** Rejected because a corrupted or replaced object could force unnecessary I/O before mismatch is known. The existing verifier reads the expected bytes and one growth probe. + +**Keep using the song-only `loadProject()` wrapper and infer save authority later.** Rejected because song data does not contain the native project selector or the versioned playback-source preference. A global “last opened project” shortcut would become ambiguous as soon as more than one aggregate has native state and would collapse Project Persistence authority into renderer session history. + +**Persist renderer-authored `sourceReference` during resave.** Rejected because it would let the WebView author filesystem/content identity. The mounted layer carries only the already-validated project id; native retained identity remains the source-reference authority. + +**Issue playback authority immediately after hash equality.** Rejected because content identity does not establish descriptor-bound parent location authority, future path stability, decoder acceptance, or current playable-stem availability. + +## Security Notes + +### Attack surface and trust boundary + +The `.bscope` document and renderer-visible data are untrusted. `sourceReference` crosses Project Persistence as passive evidence. The reverse ACL validates every durable identity field before any filesystem opener is called. Tauri derives the app-local project base from its native path API; the read-side resolver requires that base and the exact project child to pre-exist without direct link/reparse indirection, and the core ACL requires that child to remain bound to the same BandScope project id. + +The mounted renderer receives the validated document but does not become the source-reference authority. For a later save it retains only the opaque project id selector and the versioned playback-source preference. Native state resolves that selector back to the verified `LocalAudioPublicationIdentity` and injects the path-free source reference immediately before serialization. + +### Allowlist and validation + +The Resource Admission identity builder validates the BandScope project-id grammar, admitted extension allowlist, fixed `source.` artifact name, positive bounded size, and canonical lowercase 64-hex SHA-256 representation. The project-root adapter reuses those canonical rules and additionally rejects a root whose final component does not equal the validated project id. The Tauri read-side resolver refuses a missing, linked, or reparse app-local base/project directory rather than provisioning it. + +### Mitigations + +Project Persistence supplies path-free durable evidence; the read-side resolver selects only an already-existing project aggregate below a directly non-linked app-local base; the project-root ACL validates the evidence and derives one fixed source path; the injected native opener establishes supported-platform final-component no-follow/reparse and file-identity authority; Resource Admission verifies the opened bytes against the persisted bounded receipt. Native publication and bootstrap state are restored only after all those steps succeed. A reopened mounted save reuses only the verified native project selector and preserves the stored playback-source intent; it does not copy source evidence out of the document and send it back as renderer-authored authority. + +### Safe failure + +Malformed durable evidence, forged artifact names, cross-project root substitution, a missing or directly linked/reparse app-local base or project root, opener failure, size changes, growth, truncation, and SHA-256 mismatch all return the bounded project-workspace diagnosis. No failed re-admission restores native publication/bootstrap state or playback capability. A source-bearing document that cannot restore its native identity does not reach the mounted renderer and therefore cannot later be resaved as if its source authority were still valid. + +### Logging and privacy + +The reverse ACL never receives the original user-selected path. SHA-256 remains purpose-bound integrity metadata. Buyer-facing failure does not expose the derived app-owned path or raw operating-system error unless a separate diagnostics contract explicitly authorizes that disclosure. + +### Test points + +`apps/desktop/core/tests/local_audio_restart_readmission.rs` covers exact-byte success, same-size mutation, growth, truncation, forged artifact identity, malformed durable identity, bounded read failure, exact fixed-path derivation, and cross-project-root rejection. `apps/desktop/src-tauri/tests/project_persistence_open_authority.rs` composes the root ACL with the canonical native opener and proves the read-side project-root resolver accepts an existing regular aggregate, refuses a missing aggregate without creating it, and rejects Unix directory symlinks. `apps/desktop/src-tauri/tests/project_root_existing_authority.rs` adds the direct app-local-base redirection regression. `apps/desktop/src-tauri/tests/local_audio_publication_contract.rs` requires production `load_project` to restore source authority before returning the document and forbids the provisioning `app_owned_root(..., "projects", ...)` path inside that command. `apps/desktop/src/App.project-save-source-authority.test.tsx` covers both newly analyzed local-audio save authority and the mounted reopen→resave contract that preserves the exact native project selector plus non-default playback-source intent. Existing Resource Admission tests remain canonical for bounded copy/publication receipts, known-answer SHA-256 vectors, maximum-size enforcement, and staging/publication failure separation. + +### Realistic threats + +Relevant threats are local project corruption after reported Save, same-size replacement of `source.`, truncation or append caused by interrupted or external writes, tampered `.bscope` identity fields, attempts to smuggle traversal-like artifact names, substitution or deletion of the persisted project root, direct link/reparse redirection of the app-local base or project root, final-component link/reparse redirection, and semantic evidence loss during an otherwise successful Open→Save round trip. Hash equality is not treated as protection against a privileged attacker who can modify both the project document and app-owned artifact; that stronger local-compromise model requires separate platform storage and integrity controls. + +### Remaining risk + +Production `load_project` restores verified full-mix publication identity and native bootstrap state. Before analysis queue admission, the retained identity is revalidated again; the analysis child copies that admitted source into a private snapshot, verifies exact byte count and SHA-256, and decodes the same snapshot. The earlier admitted-audio descriptor→decoder pathname-replacement gap is therefore closed for the analysis path. + +The remaining mounted buyer gap is audible playback authority, not persistence content identity. A reopened `selectedPlaybackSource` is still durable intent only. #1160 must compose it with freshly admitted Full mix/current-stem media authority, fail closed to Full mix when a preferred stem is unavailable, and prove stale/replaced media cannot retain audible authority. The current #970 branch also does not expose a reconstructed transient bootstrap object back through the ProjectDocument IPC contract, so mounted consumers that require transient project/cache/temp paths must obtain fresh native capability through their owning adapter rather than persisting those paths. + +Descriptor-bound parent-directory authority remains a known gap: direct app-local-base/project-root checks and final-component O_NOFOLLOW/reparse protection do not prevent concurrent replacement of those directories or redirection through an ancestor above the checked base. A directory-handle-relative design or equivalent supported-platform primitive is required for that stronger guarantee. Restart fault injection, mounted Active Player source reconciliation, rights-cleared Windows/macOS real-audio acceptance, and broader localization/accessibility evidence remain required before release readiness. + +## Standards traceability + +NIST FIPS 180-4 remains the published Secure Hash Standard defining SHA-256. NIST has decided to revise FIPS 180-4, including removal of SHA-1 and updated guidance, but the replacement has not superseded FIPS 180-4 as of this record. + +The implementation follows the released NIST SSDF 1.1 principle of addressing root causes through explicit development and verification controls. NIST SP 800-218 Rev. 1 / SSDF 1.2 remains an Initial Public Draft; its public-comment period closed on January 30, 2026, so it is tracked as a draft rather than substituted for the released 1.1 baseline. + +## References + +National Institute of Standards and Technology. (2015). *Secure Hash Standard (SHS)* (Federal Information Processing Standards Publication 180-4). https://doi.org/10.6028/NIST.FIPS.180-4 + +National Institute of Standards and Technology. (2023, March 7). *Decision to revise FIPS 180-4, Secure Hash Standard (SHS).* https://www.nist.gov/news-events/news/2023/03/decision-revise-fips-180-4-secure-hash-standard-shs + +Scarfone, K., Souppaya, M., & Dodson, D. (2022). *Secure Software Development Framework (SSDF) Version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 + +Booth, H., Ogata, M., Kent, K., Souppaya, M., & Dodson, D. (2025). *Secure Software Development Framework (SSDF) Version 1.2: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218 Rev. 1, Initial Public Draft). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218r1.ipd diff --git a/docs/traceability/pytorch-runtime-environment-admission.md b/docs/traceability/pytorch-runtime-environment-admission.md new file mode 100644 index 000000000..68f2eec65 --- /dev/null +++ b/docs/traceability/pytorch-runtime-environment-admission.md @@ -0,0 +1,112 @@ +# PyTorch runtime-environment admission traceability + +Status: Draft + +## Problem + +BandScope's local Demucs compatibility path is intended to enter PyTorch only after BandScope has decided that the local model boundary is admissible. Current PyTorch documentation exposes two inherited process-environment controls that can widen execution before or during that third-party boundary: + +- `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD` makes an implicit `torch.load` use `weights_only=False`. Demucs 4.x uses an implicit `torch.load` for native packages, so this can reactivate unrestricted pickle deserialization. +- `TORCH_DEVICE_BACKEND_AUTOLOAD=1` makes `import torch` automatically import out-of-tree backend extensions. The Demucs path imports torch as part of model loading, so inherited process state can expand the code-import surface before BandScope has admitted the checkpoint. + +Neither variable is model evidence. An inherited shell, launcher, test harness, or host environment must not be able to weaken BandScope's local model-admission boundary. + +## Constraints + +- The Draft `htdemucs` path is CPU-oriented and does not require out-of-tree backend autoload. +- BandScope must not mutate the parent process environment as a hidden compatibility workaround. +- The compatibility path must fail closed before importing Demucs/torch when a documented unsafe environment control is active. +- Failure must use the existing bounded local-model-unavailable diagnostic and must not reveal loader internals. +- A future accelerator/backend design must be explicit, packaged, versioned, and admitted by Distribution rather than enabled through inherited autoload state. +- These runtime controls do not establish commercial model rights, immutable artifact provenance, or scientific acceptance. #1180 and #1181 retain those owner boundaries. + +## RED evidence + +Commit `3ae3646087f6fe2ae6a9aa709025720fc40beb6c` sets each documented truthy form of `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD` and requires the Demucs resolver call count to remain zero. The predecessor entered `get_model`, so the upstream implicit `torch.load` could observe the downgrade. Fix `0d9fb9f983a093fe3868106945677dfa58d10bba` followed immediately; no hosted RED-failure receipt is claimed for the intermediate head. + +Commit `4d0b16b6ace0bad9ef5b91fc996034b1ae4001c8` sets `TORCH_DEVICE_BACKEND_AUTOLOAD=1` and requires the model resolver call count to remain zero. PyTorch documents that this value causes out-of-tree backend extensions to be imported when `torch` is imported. The predecessor had no pre-import guard for this environment control. Fix `000fdb57e212be5f08a328bb677be4e0ae1ebb24` followed immediately; no hosted RED-failure receipt is claimed for the intermediate head. + +## Selected repair + +The Signal/MIR loader checks the two documented unsafe inherited environment conditions before importing `demucs.pretrained`: + +- documented truthy `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD` values are rejected case-insensitively; +- `TORCH_DEVICE_BACKEND_AUTOLOAD=1` is rejected exactly as documented by PyTorch. + +The repair does not clear or rewrite global environment variables, monkeypatch PyTorch, enable a different backend, broaden remote resolution, or change the existing private checkpoint snapshot. It simply refuses to enter the third-party loader when inherited runtime state would widen the code-execution surface. + +## Alternatives considered + +### Delete the environment variables inside BandScope + +Rejected. Mutating process-global environment is surprising stateful behavior and can race with other code in the process. The local compatibility path does not own the user's shell or parent launcher configuration. + +### Permit backend autoload because the current model uses CPU + +Rejected. CPU use makes the autoload unnecessary, not safe. Automatic import of installed out-of-tree backend extensions expands the execution surface without contributing to BandScope's current CPU inference contract. + +### Depend on PyTorch's safer defaults + +Rejected. PyTorch explicitly documents environment controls that alter those defaults. A security boundary that can be reversed by inherited process state is not a stable BandScope invariant. + +### Add an implicit accelerator fallback + +Rejected. Accelerator support must be explicit and reproducible across supported platform packages. An inherited environment flag is not a versioned capability contract and cannot substitute for CPU/MLX/CUDA/OpenCL parity evidence. + +## Security Notes + +### Attack surface + +The attack surface includes BandScope's analysis child process, inherited environment variables, Python module import, installed PyTorch out-of-tree backend extensions, Demucs model resolution, and native checkpoint deserialization. + +### Trust boundary + +The analysis child process may inherit ordinary environment state, but that state is not trusted to authorize broader Python/native code loading. Signal/MIR owns the fail-closed pre-import compatibility guard. Distribution owns which backends, model artifacts, loader versions, signatures, and package contents are admitted in a commercial release. + +### Realistic threats + +- a parent launcher sets `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD`, causing an implicit Demucs `torch.load` to fall back to unrestricted pickle; +- a parent launcher sets `TORCH_DEVICE_BACKEND_AUTOLOAD=1`, causing `import torch` to import installed out-of-tree backend extensions before model admission; +- a compatibility workaround becomes an undocumented production dependency and later differs across Windows/macOS packages; +- a bounded local-model failure leaks serialized class or backend implementation details to the buyer. + +### Mitigations + +- reject documented unsafe environment states before Demucs/torch import; +- keep the existing bounded local-model-unavailable diagnostic; +- do not mutate process-global environment or silently enable another backend; +- preserve the private descriptor-bound local model snapshot and local-only resolver; +- require Distribution-owned explicit backend/model package admission for release behavior; +- require supported-platform tests to exercise negative inherited-environment cases for any retained implicit third-party loader behavior. + +### Remaining risk + +This guard only covers the documented PyTorch environment controls that materially affect the current local model-loading path. A commercially admitted release still needs a complete environment/package execution model, immutable model provenance, an explicit serialization policy, and isolation/removal conditions for any native checkpoint deserialization. The current upstream pretrained weights also remain commercially blocked by #1181 independent of runtime hardening. + +### Test points + +- each documented truthy `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD` value fails before resolver/deserialization; +- `TORCH_DEVICE_BACKEND_AUTOLOAD=1` fails before Demucs/torch import and resolver use; +- normal environment state still reaches the existing local-only snapshot path; +- failure text remains the bounded local-model-unavailable diagnostic; +- exact packaged Windows/macOS release tests cover inherited environment downgrade/autoload cases if the native PyTorch loader remains; +- no environment guard is counted as MIR accuracy, real-audio scientific acceptance, or commercial-rights evidence. + +## Effect + +Inherited PyTorch process state can no longer opt BandScope's Draft local Demucs path into unrestricted implicit pickle loading or automatic import of out-of-tree backend extensions. The loader fails before entering Demucs/torch when either documented widening condition is active. This narrows runtime execution but does not make the upstream checkpoint commercially admissible or scientifically accepted. + +## Follow-up + +1. #1180 must define the release model/backend environment contract, including negative tests for loader environment downgrades and any intentionally packaged accelerator extensions. +2. Prefer an artifact/loader format with a materially narrower code-execution surface when scientific parity is demonstrated. +3. Keep #1181 as the independent commercial-rights prerequisite for upstream pretrained weights. +4. Run rights-cleared real-audio source-separation acceptance on exact supported Windows/macOS packages under #770 before any release-quality claim. + +## References + +PyTorch Contributors. (2025, June 17). *Miscellaneous environment variables*. PyTorch documentation. https://docs.pytorch.org/docs/main/miscellaneous_environment_variables.html + +PyTorch Contributors. (2026). *Serialization semantics: torch.load with weights_only=True*. PyTorch documentation. https://docs.pytorch.org/docs/stable/notes/serialization.html#torch-load-with-weights-only-true + +Meta Platforms, Inc. (2023). *demucs.states: model serialization/loading*. facebookresearch/demucs. https://github.com/facebookresearch/demucs/blob/v4.0.1/demucs/states.py diff --git a/scripts/checks/verify_security_notes.py b/scripts/checks/verify_security_notes.py index 821a5e940..0bde13588 100644 --- a/scripts/checks/verify_security_notes.py +++ b/scripts/checks/verify_security_notes.py @@ -1,9 +1,10 @@ -"""Verify that design-plan documents include a complete Security Notes section.""" +"""Verify that security-sensitive design and traceability documents include Security Notes.""" +import re from pathlib import Path SECURITY_NOTES_TEXT = "Security Notes" -PLAN_DIR = Path("docs/plans") +SECURITY_NOTE_DIRS = (Path("docs/plans"), Path("docs/traceability")) REQUIRED_SUBSECTIONS = [ "attack surface", "trust boundary", @@ -12,32 +13,51 @@ "realistic threats", "remaining risk", ] +MARKDOWN_HEADING = re.compile(r"^(#{1,6})\s+(.+?)\s*$") def security_notes_section(content: str) -> str: - """Extract the lowercased Security Notes section from a plan document.""" - lowered = content.lower() - marker = SECURITY_NOTES_TEXT.lower() - start = lowered.find(marker) - if start == -1: + """Extract only the lowercased Security Notes section from a governed document.""" + lines = content.splitlines() + start_index: int | None = None + heading_level: int | None = None + + for index, line in enumerate(lines): + match = MARKDOWN_HEADING.match(line.strip()) + if match is None: + continue + heading_text = match.group(2).rstrip("#").strip() + if heading_text.casefold() == SECURITY_NOTES_TEXT.casefold(): + start_index = index + heading_level = len(match.group(1)) + break + + if start_index is None or heading_level is None: return "" - end_candidates = [] - for delimiter in ["\n---", "\n## approaches considered", "\n## decision"]: - end = lowered.find(delimiter, start + len(marker)) - if end != -1: - end_candidates.append(end) + end_index = len(lines) + for index in range(start_index + 1, len(lines)): + match = MARKDOWN_HEADING.match(lines[index].strip()) + if match is not None and len(match.group(1)) <= heading_level: + end_index = index + break + + return "\n".join(lines[start_index:end_index]).lower() - if not end_candidates: - return lowered[start:] - return lowered[start : min(end_candidates)] +def governed_documents() -> list[Path]: + """Return plan and traceability documents governed by the Security Notes contract.""" + return [ + path + for directory in SECURITY_NOTE_DIRS + for path in sorted(directory.glob("*.md")) + ] def main() -> int: """Return a failing exit code when Security Notes or required subsections are missing.""" missing: list[str] = [] - for path in sorted(PLAN_DIR.glob("*.md")): + for path in governed_documents(): content = path.read_text(encoding="utf-8") if SECURITY_NOTES_TEXT not in content: missing.append(str(path)) diff --git a/services/analysis-engine/src/bandscope_analysis/__init__.py b/services/analysis-engine/src/bandscope_analysis/__init__.py index 3867248e8..ce4beb801 100644 --- a/services/analysis-engine/src/bandscope_analysis/__init__.py +++ b/services/analysis-engine/src/bandscope_analysis/__init__.py @@ -1,6 +1,36 @@ """BandScope analysis engine package.""" -from .api import get_analysis_status +import logging +from importlib import import_module + from .health import build_health_report +_STEM_SAFE_FAILURE_LOG_MESSAGES = frozenset( + { + "Stem separation failed because the source file was missing.", + "Stem separation unavailable because Demucs or torch is not installed.", + "Stem separation rejected invalid audio source data.", + "Stem separation failed with a runtime error.", + "Stem separation failed unexpectedly.", + "Stem separation failed before analysis job completion.", + } +) + + +class _ApiDiagnosticPrivacyFilter(logging.Filter): + """Redact traceback payloads only for known stem safe-failure diagnostics.""" + + def filter(self, record: logging.LogRecord) -> bool: + """Preserve unrelated diagnostics while redacting owned safe-failure tracebacks.""" + if record.getMessage() in _STEM_SAFE_FAILURE_LOG_MESSAGES: + record.exc_info = None + record.exc_text = None + return True + + +_api_logger = logging.getLogger("bandscope_analysis.api") +_api_logger.addFilter(_ApiDiagnosticPrivacyFilter()) +_api_module = import_module(".api", __name__) +get_analysis_status = _api_module.get_analysis_status + __all__ = ["build_health_report", "get_analysis_status"] diff --git a/services/analysis-engine/src/bandscope_analysis/api.py b/services/analysis-engine/src/bandscope_analysis/api.py index b376de293..217e27a42 100644 --- a/services/analysis-engine/src/bandscope_analysis/api.py +++ b/services/analysis-engine/src/bandscope_analysis/api.py @@ -14,6 +14,7 @@ import numpy as np +from bandscope_analysis.audio_resource_policy import DEFAULT_AUDIO_RESOURCE_POLICY from bandscope_analysis.health import HealthReport, build_health_report from bandscope_analysis.roles import RoleExtractor from bandscope_analysis.sections import extract_sections @@ -306,8 +307,12 @@ def validate_analysis_job_request(payload: object) -> AnalysisJobRequest: raise ValueError("Invalid analysis job request: invalid field 'localSource.fileName'") if extension not in {"wav", "mp3", "flac", "m4a"}: raise ValueError("Invalid analysis job request: invalid field 'localSource.extension'") - if not isinstance(file_size_bytes, int) or file_size_bytes <= 0: - raise ValueError("Invalid analysis job request: invalid field 'localSource.fileSizeBytes'") + try: + file_size_bytes = DEFAULT_AUDIO_RESOURCE_POLICY.validate_encoded_file_bytes(file_size_bytes) + except ValueError as error: + raise ValueError( + "Invalid analysis job request: invalid field 'localSource.fileSizeBytes'" + ) from error normalized: AnalysisJobRequest = { "sourceKind": source_kind, diff --git a/services/analysis-engine/src/bandscope_analysis/audio_decode.py b/services/analysis-engine/src/bandscope_analysis/audio_decode.py new file mode 100644 index 000000000..e558ac8b2 --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/audio_decode.py @@ -0,0 +1,76 @@ +"""Bounded PCM decode port for the Resource Admission & Decode context. + +The current adapter still delegates to ``librosa`` and therefore remains a +transitional boundary while #1129 removes the libsndfile-backed runtime graph. +Consumers must call this port rather than selecting decoder fallbacks +independently. + +Security Notes: +- The caller-authorized binary handle, container metadata, decoder output, and + third-party decoder exceptions are untrusted. +- Source metadata is admitted before decode and the resulting PCM is revalidated + against the same versioned policy before it can enter MIR or model work. +- Decoder details remain exception causes only; the surfaced failure is the + payload-free canonical resource-policy error. +- This port adds no path, network, subprocess, or credential authority. +""" + +from __future__ import annotations + +import warnings +from typing import BinaryIO, cast + +import librosa +import numpy as np +from numpy.typing import NDArray + +from bandscope_analysis.audio_metadata import preflight_audio_metadata +from bandscope_analysis.audio_resource_policy import ( + DEFAULT_AUDIO_RESOURCE_POLICY, + AudioResourcePolicy, + AudioResourcePolicyError, +) + +AudioMonoArray = NDArray[np.float32] + + +def _malformed_decode_error() -> AudioResourcePolicyError: + """Build the stable payload-free decoder failure.""" + return AudioResourcePolicyError("malformed_header") + + +def decode_mono_audio( + source: BinaryIO, + *, + policy: AudioResourcePolicy = DEFAULT_AUDIO_RESOURCE_POLICY, +) -> tuple[AudioMonoArray, int]: + """Admit and decode one caller-owned source to bounded mono float32 PCM.""" + preflight_audio_metadata(source, policy) + + try: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning, module=r"^audioread") + warnings.filterwarnings("ignore", category=FutureWarning, module=r"^audioread") + decoded, sample_rate = librosa.load( + source, + sr=policy.target_sample_rate, + mono=True, + duration=policy.decode_probe_duration_seconds, + ) + except AudioResourcePolicyError: + raise + except Exception as error: + raise _malformed_decode_error() from error + + try: + pcm = np.ravel(np.asarray(decoded, dtype=np.float32)) + except (OverflowError, TypeError, ValueError) as error: + raise _malformed_decode_error() from error + + try: + policy.validate_decoded_audio(pcm, sample_rate) + except AudioResourcePolicyError: + raise + except Exception as error: + raise _malformed_decode_error() from error + return cast(AudioMonoArray, pcm), int(sample_rate) diff --git a/services/analysis-engine/src/bandscope_analysis/audio_metadata.py b/services/analysis-engine/src/bandscope_analysis/audio_metadata.py new file mode 100644 index 000000000..51e0a6176 --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/audio_metadata.py @@ -0,0 +1,43 @@ +"""Bounded source-container metadata preflight for local audio decoders. + +Security Notes: +- The selected audio bytes and container headers are untrusted. +- This module reads metadata from an already-open caller-owned handle only; it + does not open paths, decode PCM, follow URLs, or allocate a waveform. +- Malformed headers, unsupported source rates/channels, and overlong sources + fail closed with the payload-free canonical policy error. +- A successful probe rewinds the handle so the downstream decoder receives the + same source from its beginning. +""" + +from __future__ import annotations + +from typing import BinaryIO + +import soundfile # type: ignore[import-untyped] # soundfile has no py.typed marker. + +from bandscope_analysis.audio_resource_policy import ( + DEFAULT_AUDIO_RESOURCE_POLICY, + AudioResourcePolicy, + AudioResourcePolicyError, +) + + +def preflight_audio_metadata( + fileobj: BinaryIO, + policy: AudioResourcePolicy = DEFAULT_AUDIO_RESOURCE_POLICY, +) -> None: + """Validate source metadata without decoding PCM and rewind the handle.""" + try: + fileobj.seek(0) + info = soundfile.info(fileobj) + fileobj.seek(0) + policy.validate_source_metadata( + frames=info.frames, + sample_rate=info.samplerate, + channels=info.channels, + ) + except AudioResourcePolicyError: + raise + except Exception as error: + raise AudioResourcePolicyError("malformed_header") from error diff --git a/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py b/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py new file mode 100644 index 000000000..5badde63b --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py @@ -0,0 +1,276 @@ +"""Canonical resource admission policy for local audio analysis. + +The policy is intentionally independent of individual analyzers. Expensive +feature code consumes a decoded artifact only after encoded-file and decoded +output checks agree on the same versioned limits. This prevents temporal, +separation, chord, and register features from silently inventing incompatible +resource ceilings. + +Security Notes: +- Encoded byte counts are validated before decode/allocation work when the + opened file descriptor can provide an authoritative size. +- Decoded audio is revalidated because container metadata and decoder behavior + are untrusted; accepted artifacts are finite, mono, floating-point, at the + configured sample rate, and within configured sample and memory budgets. +- Decoders receive a one-sample-over-budget probe duration so a longer source is + rejected instead of being silently truncated to the accepted duration. +- Policy arithmetic rejects unrepresentable limits before float/sample-count + conversion so malformed configuration cannot escape the stable failure mode. +- Resource rejections expose only a stable reason and policy version; messages + remain payload-free and never include source paths or audio content. +""" + +from __future__ import annotations + +import math +import sys +from dataclasses import dataclass +from typing import Any, NoReturn, cast + +import numpy as np +from numpy.typing import NDArray + +AUDIO_RESOURCE_POLICY_VERSION = "1" +DEFAULT_TARGET_SAMPLE_RATE = 44_100 +DEFAULT_MIN_SOURCE_SAMPLE_RATE = 8_000 +DEFAULT_MAX_SOURCE_SAMPLE_RATE = 192_000 +DEFAULT_MIN_SOURCE_CHANNELS = 1 +DEFAULT_MAX_SOURCE_CHANNELS = 2 +DEFAULT_MAX_ENCODED_FILE_BYTES = 100 * 1024 * 1024 +DEFAULT_MAX_DURATION_SECONDS = 15 * 60 +DEFAULT_MAX_DECODED_AUDIO_BYTES = ( + DEFAULT_TARGET_SAMPLE_RATE * DEFAULT_MAX_DURATION_SECONDS * np.dtype(np.float64).itemsize +) +_POLICY_ERROR = "Audio input violates the audio resource policy." + + +class AudioResourcePolicyError(ValueError): + """Payload-free resource rejection with stable machine-readable provenance.""" + + def __init__(self, reason: str) -> None: + """Record a stable rejection reason and the policy version that produced it.""" + super().__init__(_POLICY_ERROR) + self.reason = reason + self.policy_version = AUDIO_RESOURCE_POLICY_VERSION + + +def _reject(reason: str) -> NoReturn: + """Fail closed without echoing untrusted resource metadata.""" + raise AudioResourcePolicyError(reason) + + +@dataclass(frozen=True) +class AudioResourcePolicy: + """Versioned limits applied before and after local audio decoding. + + Args: + max_encoded_file_bytes: Maximum non-empty encoded source size. + target_sample_rate: Required sample rate of the canonical decoded mono + artifact. + max_duration_seconds: Maximum decoded duration represented as a sample + ceiling at ``target_sample_rate``. + max_decoded_audio_bytes: Maximum in-memory byte size of the canonical + decoded mono NumPy buffer. + min_source_sample_rate: Minimum source-container sample rate accepted + before resampling. + max_source_sample_rate: Maximum source-container sample rate accepted + before resampling. + min_source_channels: Minimum source-container channel count accepted + before downmixing. + max_source_channels: Maximum source-container channel count accepted + before downmixing. + """ + + max_encoded_file_bytes: int = DEFAULT_MAX_ENCODED_FILE_BYTES + target_sample_rate: int = DEFAULT_TARGET_SAMPLE_RATE + max_duration_seconds: float = float(DEFAULT_MAX_DURATION_SECONDS) + max_decoded_audio_bytes: int = DEFAULT_MAX_DECODED_AUDIO_BYTES + min_source_sample_rate: int = DEFAULT_MIN_SOURCE_SAMPLE_RATE + max_source_sample_rate: int = DEFAULT_MAX_SOURCE_SAMPLE_RATE + min_source_channels: int = DEFAULT_MIN_SOURCE_CHANNELS + max_source_channels: int = DEFAULT_MAX_SOURCE_CHANNELS + + def __post_init__(self) -> None: + """Reject invalid policy configuration before it can weaken admission.""" + if ( + isinstance(self.max_encoded_file_bytes, bool) + or not isinstance(self.max_encoded_file_bytes, int) + or self.max_encoded_file_bytes <= 0 + or self.max_encoded_file_bytes > sys.maxsize - 1 + ): + raise ValueError(_POLICY_ERROR) + if ( + isinstance(self.target_sample_rate, bool) + or not isinstance(self.target_sample_rate, int) + or self.target_sample_rate <= 0 + or self.target_sample_rate > sys.maxsize - 1 + ): + raise ValueError(_POLICY_ERROR) + if isinstance(self.max_duration_seconds, bool) or not isinstance( + self.max_duration_seconds, int | float + ): + raise ValueError(_POLICY_ERROR) + if ( + isinstance(self.max_decoded_audio_bytes, bool) + or not isinstance(self.max_decoded_audio_bytes, int) + or self.max_decoded_audio_bytes <= 0 + or self.max_decoded_audio_bytes > sys.maxsize - 1 + ): + raise ValueError(_POLICY_ERROR) + for source_bound in ( + self.min_source_sample_rate, + self.max_source_sample_rate, + self.min_source_channels, + self.max_source_channels, + ): + if ( + isinstance(source_bound, bool) + or not isinstance(source_bound, int) + or source_bound <= 0 + or source_bound > sys.maxsize - 1 + ): + raise ValueError(_POLICY_ERROR) + if ( + self.min_source_sample_rate > self.max_source_sample_rate + or self.min_source_channels > self.max_source_channels + ): + raise ValueError(_POLICY_ERROR) + try: + duration_seconds = float(self.max_duration_seconds) + except (OverflowError, ValueError): + raise ValueError(_POLICY_ERROR) from None + if not math.isfinite(duration_seconds) or duration_seconds <= 0.0: + raise ValueError(_POLICY_ERROR) + decoded_samples = self.target_sample_rate * duration_seconds + if ( + not math.isfinite(decoded_samples) + or decoded_samples < 1.0 + or decoded_samples > sys.maxsize - 1 + ): + raise ValueError(_POLICY_ERROR) + + @property + def max_decoded_samples(self) -> int: + """Return the maximum mono sample count allowed after decoding.""" + return int(self.target_sample_rate * float(self.max_duration_seconds)) + + @property + def decode_probe_duration_seconds(self) -> float: + """Return a bounded decoder duration that includes one rejection probe sample.""" + return (self.max_decoded_samples + 1) / self.target_sample_rate + + def validate_encoded_file_bytes(self, file_size: object) -> int: + """Validate an authoritative encoded file size before decoding. + + Args: + file_size: Byte count obtained from the already-open source file. + + Returns: + The validated integer byte count. + + Raises: + AudioResourcePolicyError: If the value is not a positive integer + within policy. + """ + if isinstance(file_size, bool) or not isinstance(file_size, int) or file_size <= 0: + _reject("malformed_header") + if file_size > self.max_encoded_file_bytes: + _reject("encoded_file_too_large") + return file_size + + def validate_source_metadata( + self, + frames: object, + sample_rate: object, + channels: object, + ) -> None: + """Validate source-container metadata before any decode transformation. + + Args: + frames: Number of source frames reported by the container parser. + sample_rate: Source sample rate in Hz before resampling. + channels: Source channel count before downmixing. + + Raises: + AudioResourcePolicyError: If metadata is malformed or outside the + source bounds. + """ + if isinstance(frames, bool) or not isinstance(frames, int) or frames <= 0: + _reject("malformed_header") + if ( + isinstance(sample_rate, bool) + or not isinstance(sample_rate, int) + or sample_rate < self.min_source_sample_rate + or sample_rate > self.max_source_sample_rate + ): + _reject("sampling_rate_unsupported") + if ( + isinstance(channels, bool) + or not isinstance(channels, int) + or channels < self.min_source_channels + or channels > self.max_source_channels + ): + _reject("channel_count_unsupported") + try: + source_duration_seconds = float(frames) / float(sample_rate) + except (OverflowError, ValueError): + _reject("malformed_header") + if source_duration_seconds > float(self.max_duration_seconds): + _reject("duration_exceeded") + + def validate_decoded_audio( + self, + audio: object, + sample_rate: object, + ) -> NDArray[np.floating[Any]]: + """Revalidate the canonical decoded artifact before feature analysis. + + Args: + audio: Candidate mono NumPy array returned by the decoder. + sample_rate: Decoder-reported sample rate in Hz. + + Returns: + The original validated NumPy floating-point array without copying it. + + Raises: + AudioResourcePolicyError: If dtype, shape, sample rate, sample + count, memory use, or finiteness does not satisfy this policy. + """ + if ( + not isinstance(audio, np.ndarray) + or audio.ndim != 1 + or audio.size == 0 + or not np.issubdtype(audio.dtype, np.floating) + ): + _reject("malformed_header") + if ( + isinstance(sample_rate, bool) + or not isinstance(sample_rate, int) + or sample_rate != self.target_sample_rate + ): + _reject("sampling_rate_unsupported") + if audio.size > self.max_decoded_samples: + _reject("decoded_sample_count_exceeded") + if audio.nbytes > self.max_decoded_audio_bytes: + _reject("memory_budget_exceeded") + if not np.isfinite(audio).all(): + _reject("malformed_header") + return cast(NDArray[np.floating[Any]], audio) + + +DEFAULT_AUDIO_RESOURCE_POLICY = AudioResourcePolicy() + +__all__ = [ + "AUDIO_RESOURCE_POLICY_VERSION", + "AudioResourcePolicy", + "AudioResourcePolicyError", + "DEFAULT_AUDIO_RESOURCE_POLICY", + "DEFAULT_MAX_DECODED_AUDIO_BYTES", + "DEFAULT_MAX_DURATION_SECONDS", + "DEFAULT_MAX_ENCODED_FILE_BYTES", + "DEFAULT_MAX_SOURCE_CHANNELS", + "DEFAULT_MAX_SOURCE_SAMPLE_RATE", + "DEFAULT_MIN_SOURCE_CHANNELS", + "DEFAULT_MIN_SOURCE_SAMPLE_RATE", + "DEFAULT_TARGET_SAMPLE_RATE", +] diff --git a/services/analysis-engine/src/bandscope_analysis/chords/chord_recognizer.py b/services/analysis-engine/src/bandscope_analysis/chords/chord_recognizer.py index 8f6466924..2d414afc8 100644 --- a/services/analysis-engine/src/bandscope_analysis/chords/chord_recognizer.py +++ b/services/analysis-engine/src/bandscope_analysis/chords/chord_recognizer.py @@ -396,7 +396,7 @@ def recognize(self, y: np.ndarray, sr: int = 22050) -> list[TrackedChord]: Returns: List of TrackedChord dicts with start_time, end_time, chord, and confidence. """ - if len(y) == 0: + if y.size == 0: return [] y_harmonic = self._separate_harmonic(y) diff --git a/services/analysis-engine/src/bandscope_analysis/cli.py b/services/analysis-engine/src/bandscope_analysis/cli.py index 6838ee711..1ddd9ce19 100644 --- a/services/analysis-engine/src/bandscope_analysis/cli.py +++ b/services/analysis-engine/src/bandscope_analysis/cli.py @@ -4,6 +4,7 @@ import json import logging +import os import sys from datetime import UTC, datetime @@ -12,6 +13,9 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +_ADMITTED_AUDIO_BYTES_ENV = "BANDSCOPE_ADMITTED_AUDIO_BYTES" +_ADMITTED_AUDIO_SHA256_ENV = "BANDSCOPE_ADMITTED_AUDIO_SHA256" + def failed_cli_response(message: str) -> dict[str, object]: """Return a typed CLI failure envelope for malformed stdin payloads.""" @@ -28,6 +32,14 @@ def failed_cli_response(message: str) -> dict[str, object]: } +def _native_admission_is_scoped() -> bool: + """Return whether this child process carries native audio identity evidence.""" + return ( + os.environ.get(_ADMITTED_AUDIO_BYTES_ENV) is not None + or os.environ.get(_ADMITTED_AUDIO_SHA256_ENV) is not None + ) + + def main() -> int: """Read a job payload from stdin and print a structured job response to stdout.""" # Read all input from stdin first @@ -75,10 +87,12 @@ def main() -> int: request = payload.get("request") - # Temporary: Inject temporal analyzer call if it's a local file, just to prove it works - # before full orchestrator integration + # Compatibility-only probe for direct/manual callers. Native desktop jobs + # carry Resource Admission evidence and must not reopen the mutable pathname + # before the content-bound separation/decode path consumes that evidence. if ( - isinstance(request, dict) + not _native_admission_is_scoped() + and isinstance(request, dict) and request.get("sourceKind") == "local_audio" and "localSource" in request ): @@ -90,7 +104,7 @@ def main() -> int: try: temporal_analyzer = TemporalAnalyzer() features = temporal_analyzer.analyze(audio_path) - logging.info(f"Extracted BPM: {features['bpm']}") + logging.info("Extracted BPM: %s", features["bpm"]) except Exception: logging.warning( "Temporal analysis failed for %s; continuing with safe fallback.", diff --git a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py index c36e0f1fc..0537ada10 100644 --- a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py +++ b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py @@ -1,4 +1,4 @@ -"""Local audio source separation using a bundled Demucs model. +"""Local audio source separation using a local Demucs model. Replaces the previous FFT band-masking heuristic — which scored around -39 dB SI-SDR on a realistic mix (i.e. not real separation) — with Demucs (htdemucs), a @@ -9,9 +9,29 @@ Security Notes: - Treats the selected audio file as untrusted input: the path is normalized and verified to be a file, and a maximum byte size is enforced before decode. -- Inference runs locally on CPU with no network access. The model weights are - loaded from the local Demucs cache or a configured bundled path; offline - weight bundling is tracked in the supplemental component inventory. +- Native-admitted sources carry exact byte-count + SHA-256 evidence in the + per-analysis child-process environment. Partial or malformed evidence fails + closed. Those bytes are copied once from the opened descriptor into a private + spooled file, verified against the evidence, and decoded from that same + snapshot. A later pathname replacement therefore cannot change the bytes + entering MIR/model work. +- Decoded audio is revalidated against the same versioned resource policy before + Demucs/model work so overlong, malformed, or non-finite decoder output fails + closed instead of being silently truncated or normalized. +- Empty, non-finite, or float32-overflowed model stems fail closed before they + can become successful silence or downstream rehearsal evidence. +- Inference does not intentionally acquire model weights from the network. The + canonical htdemucs checkpoint must already exist as a bounded regular file in + the local torch checkpoint cache and reproduce the checksum prefix encoded in + its canonical filename. BandScope copies the verified descriptor bytes into a + private temporary local Demucs repository and resolves the checkpoint by its + signature there, so upstream deserialization cannot reopen or download from + the mutable cache pathname. Release bundling, full digest/signature provenance, + and model-rights evidence remain Distribution work. +- PyTorch/Demucs checkpoint incompatibility fails with the same bounded local-model + diagnostic rather than exposing serialized class names or internal loader details. + Process environment cannot opt this boundary back into unrestricted pickle loading + or automatic import of out-of-tree torch backend extensions. - Does not log or persist raw audio, separated stems, or full source paths. - Fails with bounded, filename-scoped errors so callers can surface a safe failure without leaking local directory structure. @@ -20,23 +40,25 @@ from __future__ import annotations import contextlib +import hashlib import logging import os +import pickle +import stat import sys -import warnings +import tempfile from dataclasses import dataclass from pathlib import Path from typing import Any, cast -import librosa import numpy as np -from bandscope_analysis.temporal.analyzer import ( - KNOWN_LIBROSA_NUMBA_WARNING_FILTERS, - MAX_ANALYSIS_DURATION_SECONDS, - MAX_AUDIO_FILE_BYTES, - TARGET_SR, +from bandscope_analysis.audio_decode import decode_mono_audio +from bandscope_analysis.audio_resource_policy import ( + DEFAULT_MAX_DURATION_SECONDS, + AudioResourcePolicy, ) +from bandscope_analysis.temporal.analyzer import MAX_AUDIO_FILE_BYTES, TARGET_SR from .model import AudioSeparationResult, AudioStemArray, AudioStemName, AudioStemPayload @@ -45,6 +67,20 @@ # Demucs htdemucs emits these four sources; this is the canonical stem set. _STEM_ORDER: tuple[AudioStemName, ...] = ("vocals", "bass", "drums", "other") _EMPTY_RANGE_EPS = 1e-9 +_MODEL_OUTPUT_ERROR = "Stem separation produced invalid audio." +_ADMITTED_SOURCE_CHANGED_ERROR = "Stem separation source changed before decode." +_LOCAL_MODEL_UNAVAILABLE_ERROR = "Stem separation model weights are not installed locally." +_ADMITTED_AUDIO_BYTES_ENV = "BANDSCOPE_ADMITTED_AUDIO_BYTES" +_ADMITTED_AUDIO_SHA256_ENV = "BANDSCOPE_ADMITTED_AUDIO_SHA256" +_TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD_ENV = "TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD" +_TORCH_DEVICE_BACKEND_AUTOLOAD_ENV = "TORCH_DEVICE_BACKEND_AUTOLOAD" +_TORCH_TRUTHY_ENV_VALUES = frozenset({"1", "y", "yes", "true"}) +_SNAPSHOT_MEMORY_BYTES = 8 * 1024 * 1024 +_COPY_CHUNK_BYTES = 64 * 1024 +_MAX_LOCAL_DEMUCS_CHECKPOINT_BYTES = 128 * 1024 * 1024 +_DEMUCS_LOCAL_CHECKPOINTS = { + "htdemucs": "955717e8-8726e21a.th", +} def _contains_parent_path_segment(path: Path) -> bool: @@ -57,13 +93,144 @@ def _contains_parent_path_segment(path: Path) -> bool: return any(part == ".." for part in normalized_path_text.split("/")) +def _valid_sha256_hex(value: object) -> bool: + """Return whether value is one canonical lowercase SHA-256 hex digest.""" + return ( + isinstance(value, str) + and len(value) == 64 + and value == value.lower() + and all(character in "0123456789abcdef" for character in value) + ) + + +def _unsafe_torch_pickle_override_enabled() -> bool: + """Return whether PyTorch would downgrade an implicit load to unrestricted pickle.""" + raw_value = os.environ.get(_TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD_ENV, "") + return raw_value.strip().lower() in _TORCH_TRUTHY_ENV_VALUES + + +def _torch_backend_autoload_enabled() -> bool: + """Return whether importing torch would auto-load out-of-tree backend extensions.""" + return os.environ.get(_TORCH_DEVICE_BACKEND_AUTOLOAD_ENV, "").strip() == "1" + + +def _checkpoint_signature_and_checksum(checkpoint_name: str) -> tuple[str, str] | None: + """Return the canonical Demucs signature/checksum encoded in a checkpoint name.""" + stem = Path(checkpoint_name).stem + if "-" not in stem: + return None + signature, checksum_prefix = stem.rsplit("-", 1) + for value in (signature, checksum_prefix): + if ( + len(value) != 8 + or value != value.lower() + or any(character not in "0123456789abcdef" for character in value) + ): + return None + return signature, checksum_prefix + + +def _admitted_audio_evidence_from_environment() -> tuple[int, str] | None: + """Read the native-owned evidence scoped to one analysis process. + + The desktop process sets both variables on the child ``Command`` rather than + mutating its own environment, so concurrent analysis jobs cannot overwrite + one another's evidence. Missing evidence preserves compatibility for direct + library callers; a partial pair is treated as a broken trust handoff. + """ + raw_size = os.environ.get(_ADMITTED_AUDIO_BYTES_ENV) + digest = os.environ.get(_ADMITTED_AUDIO_SHA256_ENV) + if raw_size is None and digest is None: + return None + if raw_size is None or digest is None: + raise ValueError(_ADMITTED_SOURCE_CHANGED_ERROR) + try: + expected_size = int(raw_size, 10) + except ValueError as error: + raise ValueError(_ADMITTED_SOURCE_CHANGED_ERROR) from error + if str(expected_size) != raw_size or expected_size <= 0 or not _valid_sha256_hex(digest): + raise ValueError(_ADMITTED_SOURCE_CHANGED_ERROR) + return expected_size, digest + + +def _snapshot_local_demucs_checkpoint(model_name: str, snapshot_root: Path) -> str | None: + """Copy one verified cache descriptor into a private local Demucs repository. + + The mutable torch cache pathname is used only to acquire the source descriptor. + The descriptor must represent the same regular file observed by ``lstat``; + ``O_NOFOLLOW`` is requested where the host exposes it. The admitted object is + size-bounded before the copy, and the snapshot must reproduce exactly the + descriptor size observed at admission. Short reads or later growth fail + closed before resolver/deserialization. The copied bytes must also reproduce + the checksum prefix encoded in the canonical checkpoint filename. Demucs + later deserializes only the private snapshot. + """ + checkpoint_name = _DEMUCS_LOCAL_CHECKPOINTS.get(model_name) + identity = ( + _checkpoint_signature_and_checksum(checkpoint_name) if checkpoint_name is not None else None + ) + if checkpoint_name is None or identity is None: + return None + signature, checksum_prefix = identity + + snapshot_path = snapshot_root / checkpoint_name + try: + import torch + + checkpoint_path = Path(torch.hub.get_dir()) / "checkpoints" / checkpoint_name + path_stat = os.lstat(checkpoint_path) + if not stat.S_ISREG(path_stat.st_mode): + return None + + open_flags = os.O_RDONLY + open_flags |= getattr(os, "O_CLOEXEC", 0) + open_flags |= getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(checkpoint_path, open_flags) + try: + descriptor_stat = os.fstat(descriptor) + if ( + not stat.S_ISREG(descriptor_stat.st_mode) + or descriptor_stat.st_dev != path_stat.st_dev + or descriptor_stat.st_ino != path_stat.st_ino + or descriptor_stat.st_size <= 0 + or descriptor_stat.st_size > _MAX_LOCAL_DEMUCS_CHECKPOINT_BYTES + ): + return None + + digest = hashlib.sha256() + with os.fdopen(descriptor, "rb", closefd=False) as checkpoint_file: + with snapshot_path.open("xb") as snapshot_file: + remaining = descriptor_stat.st_size + while remaining: + chunk = checkpoint_file.read(min(_COPY_CHUNK_BYTES, remaining)) + if not chunk: + raise ValueError("local Demucs checkpoint changed during snapshot") + digest.update(chunk) + snapshot_file.write(chunk) + remaining -= len(chunk) + if checkpoint_file.read(1): + raise ValueError("local Demucs checkpoint changed during snapshot") + snapshot_file.flush() + os.fsync(snapshot_file.fileno()) + finally: + os.close(descriptor) + + if not digest.hexdigest().startswith(checksum_prefix): + snapshot_path.unlink(missing_ok=True) + return None + except (ImportError, OSError, TypeError, ValueError): + snapshot_path.unlink(missing_ok=True) + return None + return signature + + @dataclass(frozen=True) class AudioSeparationConfig: """Resource and model settings for local stem separation.""" target_sample_rate: int = TARGET_SR max_file_bytes: int = MAX_AUDIO_FILE_BYTES - max_duration_seconds: float = float(MAX_ANALYSIS_DURATION_SECONDS) + max_duration_seconds: float = float(DEFAULT_MAX_DURATION_SECONDS) model_name: str = "htdemucs" device: str = "cpu" # Demucs splits long audio into overlapping segments internally, bounding @@ -75,16 +242,57 @@ class AudioStemSeparator: """Split a selected local mix into canonical stems for downstream analysis.""" def __init__(self, config: AudioSeparationConfig | None = None) -> None: - """Initialize the local stem separator (model is loaded lazily).""" + """Initialize the local stem separator and its canonical resource policy.""" self.config = config or AudioSeparationConfig() + self.resource_policy = AudioResourcePolicy( + max_encoded_file_bytes=self.config.max_file_bytes, + target_sample_rate=self.config.target_sample_rate, + max_duration_seconds=self.config.max_duration_seconds, + ) self._model: Any = None def separate(self, audio_path: str | Path) -> AudioSeparationResult: - """Separate local audio into vocals, bass, drums, and other stems.""" + """Separate one local source under the active native-admission contract.""" + evidence = _admitted_audio_evidence_from_environment() path = self._resolve_audio_file(audio_path) - audio, sample_rate = self._load_audio(path) + if evidence is None: + audio, sample_rate = self._load_audio(path) + else: + audio, sample_rate = self._load_admitted_audio( + path, + expected_file_size_bytes=evidence[0], + expected_content_sha256=evidence[1], + ) + return self._separate_loaded_audio(audio, sample_rate) + + def separate_admitted( + self, + audio_path: str | Path, + *, + expected_file_size_bytes: int, + expected_content_sha256: str, + ) -> AudioSeparationResult: + """Separate bytes that reproduce native Resource Admission evidence. + + The source pathname is resolved and opened once. Before any decoder or + model call, the opened bytes are copied into a private spooled snapshot + while exact encoded length and SHA-256 are checked. Decode then consumes + that snapshot rather than reopening the pathname. + """ + path = self._resolve_audio_file(audio_path) + audio, sample_rate = self._load_admitted_audio( + path, + expected_file_size_bytes=expected_file_size_bytes, + expected_content_sha256=expected_content_sha256, + ) + return self._separate_loaded_audio(audio, sample_rate) + + def _separate_loaded_audio( + self, audio: AudioStemArray, sample_rate: int + ) -> AudioSeparationResult: + """Separate one already-decoded admitted mono signal.""" if audio.size == 0: - raise ValueError(f"Stem separation decode failed for {path.name}") + raise ValueError("Stem separation decode failed for selected audio") stem_arrays = self._separate_signal(audio, sample_rate) stems: AudioStemPayload = { @@ -119,25 +327,30 @@ def _separate_signal( """Run the Demucs model on mono audio and return canonical mono stems. This is the single boundary to the neural model; it converts the mono - signal to the stereo tensor Demucs expects, applies the model on CPU, and - downmixes each source back to a mono float array. + signal to the stereo tensor Demucs expects, applies the model on the + configured device, and downmixes each source back to a mono host array. """ model = self._load_model() sources = self._apply_model(model, audio) return {name: _as_float_array(sources[name]) for name in _STEM_ORDER} def _load_model(self) -> Any: - """Lazily load and cache the Demucs model. + """Lazily load the canonical Demucs model from one verified local snapshot. Demucs (and torch) are installed only on platforms with current torch wheels (see pyproject platform markers); elsewhere separation fails with a - clear error the pipeline already surfaces safely. - - The first load fetches model weights, whose download progress torch may - print to stdout — that would corrupt the CLI's JSON stdout protocol, so - stdout is redirected to stderr while the model is obtained. + clear error the pipeline already surfaces safely. The canonical cache + checkpoint is copied from its verified descriptor into a private local + repository. Passing that repository explicitly keeps Demucs on LocalRepo + and prevents RemoteRepo/network fallback or a second open of the cache path. + PyTorch 2.6+ weights-only incompatibility is treated as an unavailable + admitted model; BandScope does not force unsafe legacy pickle loading here, + including through PyTorch's process-level no-weights-only override or + out-of-tree backend autoload. """ if self._model is None: + if _unsafe_torch_pickle_override_enabled() or _torch_backend_autoload_enabled(): + raise ValueError(_LOCAL_MODEL_UNAVAILABLE_ERROR) try: from demucs.pretrained import ( # type: ignore[import-not-found, unused-ignore] get_model, @@ -147,10 +360,22 @@ def _load_model(self) -> Any: "Stem separation is not available on this platform (demucs/torch not installed)" ) from error - with contextlib.redirect_stdout(sys.stderr): - model = get_model(self.config.model_name) - model.eval() - self._model = model + with tempfile.TemporaryDirectory(prefix="bandscope-demucs-model-") as snapshot_dir: + snapshot_root = Path(snapshot_dir) + model_signature = _snapshot_local_demucs_checkpoint( + self.config.model_name, + snapshot_root, + ) + if model_signature is None: + raise ValueError(_LOCAL_MODEL_UNAVAILABLE_ERROR) + + try: + with contextlib.redirect_stdout(sys.stderr): + model = get_model(model_signature, repo=snapshot_root) + except pickle.UnpicklingError as error: + raise ValueError(_LOCAL_MODEL_UNAVAILABLE_ERROR) from error + model.eval() + self._model = model return self._model def _apply_model(self, model: Any, audio: AudioStemArray) -> dict[str, np.ndarray[Any, Any]]: @@ -172,7 +397,13 @@ def _apply_model(self, model: Any, audio: AudioStemArray) -> dict[str, np.ndarra progress=False, )[0] out = out * ref_std + ref_mean - return {name: out[i].mean(0).numpy() for i, name in enumerate(model.sources)} + stems: dict[str, np.ndarray[Any, Any]] = {} + for index, name in enumerate(model.sources): + stem = out[index].mean(0) + if self.config.device != "cpu": + stem = stem.cpu() + stems[name] = stem.numpy() + return stems def _resolve_audio_file(self, audio_path: str | Path) -> Path: """Normalize and validate the selected source path.""" @@ -190,39 +421,75 @@ def _resolve_audio_file(self, audio_path: str | Path) -> Path: return path def _load_audio(self, path: Path) -> tuple[AudioStemArray, int]: - """Load bounded mono audio without logging or exposing the full source path.""" + """Load bounded mono audio through the compatibility decoder authority.""" try: with path.open("rb") as fileobj: file_size = os.fstat(fileobj.fileno()).st_size - if file_size > self.config.max_file_bytes: - raise ValueError( - "Audio file is too large for stem separation: " - f"{file_size} bytes (max {self.config.max_file_bytes} bytes)" - ) - - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", category=DeprecationWarning, module=r"^audioread" - ) - warnings.filterwarnings("ignore", category=FutureWarning, module=r"^audioread") - for category, message, module in KNOWN_LIBROSA_NUMBA_WARNING_FILTERS: - warnings.filterwarnings( - "ignore", - category=category, - message=message, - module=module, - ) - y, sr = librosa.load( - fileobj, - sr=self.config.target_sample_rate, - mono=True, - duration=self.config.max_duration_seconds, - ) + if file_size <= 0: + raise ValueError(f"Stem separation decode failed for {path.name}") + try: + self.resource_policy.validate_encoded_file_bytes(file_size) + except ValueError as error: + raise ValueError("Audio file is too large for stem separation") from error + y, sr = decode_mono_audio(fileobj, policy=self.resource_policy) + except ValueError: + raise + except Exception as error: + raise ValueError(f"Stem separation decode failed for {path.name}") from error + + if y.size == 0: + raise ValueError(f"Stem separation decode failed for {path.name}") + return _as_float_array(y), int(sr) + + def _load_admitted_audio( + self, + path: Path, + *, + expected_file_size_bytes: int, + expected_content_sha256: str, + ) -> tuple[AudioStemArray, int]: + """Snapshot and decode exactly the bytes admitted by native Resource Admission.""" + if ( + not isinstance(expected_file_size_bytes, int) + or isinstance(expected_file_size_bytes, bool) + or expected_file_size_bytes <= 0 + or not _valid_sha256_hex(expected_content_sha256) + ): + raise ValueError(_ADMITTED_SOURCE_CHANGED_ERROR) + try: + self.resource_policy.validate_encoded_file_bytes(expected_file_size_bytes) + except ValueError as error: + raise ValueError(_ADMITTED_SOURCE_CHANGED_ERROR) from error + + try: + with path.open("rb") as fileobj: + actual_size = os.fstat(fileobj.fileno()).st_size + if actual_size != expected_file_size_bytes: + raise ValueError(_ADMITTED_SOURCE_CHANGED_ERROR) + with tempfile.SpooledTemporaryFile( + max_size=_SNAPSHOT_MEMORY_BYTES, + mode="w+b", + ) as snapshot: + digest = hashlib.sha256() + remaining = expected_file_size_bytes + while remaining: + chunk = fileobj.read(min(_COPY_CHUNK_BYTES, remaining)) + if not chunk: + raise ValueError(_ADMITTED_SOURCE_CHANGED_ERROR) + snapshot.write(chunk) + digest.update(chunk) + remaining -= len(chunk) + if fileobj.read(1) or digest.hexdigest() != expected_content_sha256: + raise ValueError(_ADMITTED_SOURCE_CHANGED_ERROR) + snapshot.seek(0) + y, sr = decode_mono_audio(snapshot, policy=self.resource_policy) except ValueError: raise except Exception as error: raise ValueError(f"Stem separation decode failed for {path.name}") from error + if y.size == 0: + raise ValueError(f"Stem separation decode failed for {path.name}") return _as_float_array(y), int(sr) def _fit_length(self, audio: AudioStemArray, target_length: int) -> AudioStemArray: @@ -235,7 +502,12 @@ def _fit_length(self, audio: AudioStemArray, target_length: int) -> AudioStemArr def _as_float_array(values: object) -> AudioStemArray: - """Convert decoder and model output to a finite one-dimensional float array.""" - array = np.ravel(np.asarray(values, dtype=np.float32)) - finite = np.nan_to_num(array, copy=False, nan=0.0, posinf=0.0, neginf=0.0) - return cast(AudioStemArray, finite) + """Convert one finite, non-empty decoder/model output into mono float32 audio.""" + try: + with np.errstate(over="ignore", invalid="ignore"): + array = np.ravel(np.asarray(values, dtype=np.float32)) + except (OverflowError, TypeError, ValueError) as error: + raise ValueError(_MODEL_OUTPUT_ERROR) from error + if array.size == 0 or not np.isfinite(array).all(): + raise ValueError(_MODEL_OUTPUT_ERROR) + return cast(AudioStemArray, array) diff --git a/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py b/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py index 7fe5ae6f7..ee8aec403 100644 --- a/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py +++ b/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py @@ -4,7 +4,6 @@ import logging import os -import warnings from pathlib import Path from typing import Any @@ -12,18 +11,36 @@ import numpy as np from numpy.typing import NDArray +from bandscope_analysis.audio_decode import decode_mono_audio +from bandscope_analysis.audio_resource_policy import ( + DEFAULT_AUDIO_RESOURCE_POLICY, + DEFAULT_MAX_DURATION_SECONDS, + AudioResourcePolicy, +) + from .model import TemporalFeatures logger = logging.getLogger(__name__) -# Standard sample rate for BandScope analysis -TARGET_SR = 44100 -MAX_AUDIO_FILE_BYTES = 100 * 1024 * 1024 # 100 MiB -MAX_ANALYSIS_DURATION_SECONDS = 15 * 60 # 15 minutes +# Compatibility aliases retained for callers/tests while the canonical values +# are owned by AudioResourcePolicy. The decode-duration alias intentionally +# includes one rejection-probe sample so an overlong source is detected rather +# than silently truncated at the accepted rehearsal duration. +TARGET_SR = DEFAULT_AUDIO_RESOURCE_POLICY.target_sample_rate +MAX_AUDIO_FILE_BYTES = DEFAULT_AUDIO_RESOURCE_POLICY.max_encoded_file_bytes +MAX_ANALYSIS_DURATION_SECONDS = DEFAULT_AUDIO_RESOURCE_POLICY.decode_probe_duration_seconds KNOWN_LIBROSA_NUMBA_WARNING_FILTERS = ( (DeprecationWarning, r".*pkg_resources is deprecated.*", r".*librosa.*"), (FutureWarning, r".*Numba.*", r".*numba.*"), ) +_SAFE_TEMPORAL_FAILURE_MESSAGES = frozenset( + { + "Audio file is too large for temporal analysis", + "Audio input violates the audio resource policy.", + } +) +_MISSING_AUDIO_MESSAGE = "Audio source is unavailable for temporal analysis." +_GENERIC_TEMPORAL_FAILURE_MESSAGE = "Temporal analysis failed." # ponytail: assumes 4/4; upgrade to meter estimation or a madmom DBN if other meters matter. BEATS_PER_BAR = 4 @@ -56,11 +73,34 @@ def _estimate_downbeats( return [float(bt) for i, bt in enumerate(beat_times) if (i - best_phase) % beats_per_bar == 0] +def _safe_temporal_failure_message(error: Exception) -> str: + """Return an allowlisted diagnostic without relaying decoder payload text.""" + message = str(error) + if message in _SAFE_TEMPORAL_FAILURE_MESSAGES: + return message + return _GENERIC_TEMPORAL_FAILURE_MESSAGE + + class TemporalAnalyzer: - """Analyzes temporal features (BPM, beats) from audio files.""" + """Analyze bounded temporal features (BPM and beat grids) from local audio.""" + + def __init__(self, resource_policy: AudioResourcePolicy | None = None) -> None: + """Create an analyzer bound to one canonical local-audio resource policy. + + Args: + resource_policy: Explicit policy for tests or specialized callers. + The default preserves the public module-level byte ceiling while + taking sample-rate and accepted rehearsal duration from the + canonical policy layer. + """ + self.resource_policy = resource_policy or AudioResourcePolicy( + max_encoded_file_bytes=MAX_AUDIO_FILE_BYTES, + target_sample_rate=TARGET_SR, + max_duration_seconds=DEFAULT_MAX_DURATION_SECONDS, + ) def analyze(self, audio_path: str | Path) -> TemporalFeatures: - """Decode audio and extract temporal features. + """Decode bounded audio and extract temporal features. Args: audio_path: Path to the audio file. @@ -71,54 +111,23 @@ def analyze(self, audio_path: str | Path) -> TemporalFeatures: path = Path(audio_path) path_str = str(path) if not path.exists() or not path.is_file(): - raise FileNotFoundError(f"Audio file not found: {path_str}") + raise FileNotFoundError(_MISSING_AUDIO_MESSAGE) - logger.info(f"Loading and decoding audio: {path_str}") + logger.info("Loading and decoding bounded local audio.") try: with path.open("rb") as fileobj: file_size = os.fstat(fileobj.fileno()).st_size - if file_size > MAX_AUDIO_FILE_BYTES: - raise ValueError( - f"Audio file is too large for temporal analysis: {file_size} bytes " - f"(max {MAX_AUDIO_FILE_BYTES} bytes)" - ) - - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", category=DeprecationWarning, module=r"^audioread" - ) - warnings.filterwarnings("ignore", category=FutureWarning, module=r"^audioread") - - # Keep the loader's known third-party churn quiet without hiding - # unrelated decoder warnings that tests and callers should see. - for category, message, module in KNOWN_LIBROSA_NUMBA_WARNING_FILTERS: - warnings.filterwarnings( - "ignore", - category=category, - message=message, - module=module, - ) - # Load audio, converting to mono and standardizing sample rate - y, sr = librosa.load( - fileobj, - sr=TARGET_SR, - mono=True, - duration=MAX_ANALYSIS_DURATION_SECONDS, - ) - - # Ensure it's a 1D float array for librosa - if not isinstance(y, np.ndarray): - raise ValueError("Expected numpy array from librosa.load") - - y_array: NDArray[np.floating[Any]] = y + try: + self.resource_policy.validate_encoded_file_bytes(file_size) + except ValueError as error: + raise ValueError("Audio file is too large for temporal analysis") from error + y_array, sr = decode_mono_audio(fileobj, policy=self.resource_policy) + duration = float(librosa.get_duration(y=y_array, sr=sr)) logger.info("Extracting tempo and beat tracking...") - # Use librosa's robust beat tracker tempo, beat_frames = librosa.beat.beat_track(y=y_array, sr=sr) - - # Convert frame indices to time (seconds) beat_times: NDArray[np.floating[Any]] = librosa.frames_to_time(beat_frames, sr=sr) # Place downbeats on the strongest-onset bar phase (looks at the audio, @@ -139,6 +148,6 @@ def analyze(self, audio_path: str | Path) -> TemporalFeatures: "audio_path": path_str, } - except Exception as e: - logger.error(f"Failed to analyze audio {path_str}: {e}") - raise ValueError(f"Temporal analysis failed: {e}") from e + except Exception as error: + logger.error("Temporal analysis failed (%s).", type(error).__name__) + raise ValueError(_safe_temporal_failure_message(error)) from error diff --git a/services/analysis-engine/src/bandscope_analysis/transcription/api.py b/services/analysis-engine/src/bandscope_analysis/transcription/api.py index f2a732d31..3318da65e 100644 --- a/services/analysis-engine/src/bandscope_analysis/transcription/api.py +++ b/services/analysis-engine/src/bandscope_analysis/transcription/api.py @@ -3,13 +3,15 @@ from __future__ import annotations import io -import warnings from dataclasses import dataclass import librosa import numpy as np from numpy.typing import NDArray +from bandscope_analysis.audio_decode import decode_mono_audio +from bandscope_analysis.audio_resource_policy import AudioResourcePolicy + TARGET_SR = 22050 MAX_STEM_BYTES = 50 * 1024 * 1024 MAX_TRANSCRIPTION_DURATION_SECONDS = 120 @@ -17,6 +19,12 @@ HOP_LENGTH = 512 MIN_NOTE_DURATION_SECONDS = 0.05 MIN_SIGNAL_PEAK = 1e-5 +TRANSCRIPTION_RESOURCE_POLICY = AudioResourcePolicy( + max_encoded_file_bytes=MAX_STEM_BYTES, + target_sample_rate=TARGET_SR, + max_duration_seconds=MAX_TRANSCRIPTION_DURATION_SECONDS, + max_decoded_audio_bytes=(TARGET_SR * MAX_TRANSCRIPTION_DURATION_SECONDS + 1) * 8, +) @dataclass @@ -42,16 +50,8 @@ def transcribe_bass_stem(stem_data: bytes) -> list[NoteEvent]: if len(stem_data) > MAX_STEM_BYTES: raise ValueError("Stem data is too large for transcription.") - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning, module=r"^audioread") - y, sr = librosa.load( - io.BytesIO(stem_data), - sr=TARGET_SR, - mono=True, - duration=MAX_TRANSCRIPTION_DURATION_SECONDS, - ) - - y_array = np.asarray(y, dtype=np.float32) + source = io.BytesIO(stem_data) + y_array, sr = decode_mono_audio(source, policy=TRANSCRIPTION_RESOURCE_POLICY) if y_array.size == 0 or float(np.max(np.abs(y_array))) < MIN_SIGNAL_PEAK: return [] diff --git a/services/analysis-engine/src/bandscope_analysis/youtube.py b/services/analysis-engine/src/bandscope_analysis/youtube.py index c98f4e513..61c6bab0b 100644 --- a/services/analysis-engine/src/bandscope_analysis/youtube.py +++ b/services/analysis-engine/src/bandscope_analysis/youtube.py @@ -1,11 +1,35 @@ -""" -YouTube import capabilities for BandScope. +"""YouTube import capabilities for BandScope. This module provides a safe wrapper around yt-dlp to download audio from YouTube. + +Security Notes: + - URL intake remains host/path/query allowlisted before any network work. + - Encoded-byte admission uses the same canonical 100 MiB policy as local + audio. yt-dlp ``max_filesize`` and a progress hook abort in-flight + transfers so a multi-gigabyte download cannot fill the cache root before + the post-download check runs. + - Announced duration must be a finite positive non-Boolean number when + present; malformed known-duration metadata fails closed before download. + Download-result duration is revalidated before success so changed + metadata cannot bypass the same 15-minute admission boundary. + - Announced ``filesize`` / ``filesize_approx`` values over the policy + ceiling reject the import before ``download=True``. + - The completed download path must resolve beneath this import's ``out_dir`` + before post-download size checks, cleanup, or success metadata can use it. + - The opened-file size is revalidated with ``AudioResourcePolicy`` after + download; oversize artifacts are deleted. + - In-flight abort deletes owned ``tmpfilename`` / ``filename`` siblings + (``.part``, ``.ytdl``, ``-Frag*``) that stay inside this import's + ``out_dir``. Paths that escape the directory are ignored. + - Validation errors are payload-free and never include source paths, URLs, + cookies, or audio content. """ +from __future__ import annotations + import argparse import json +import math import os import re import sys @@ -14,6 +38,12 @@ import yt_dlp # type: ignore +from bandscope_analysis.audio_resource_policy import ( + DEFAULT_AUDIO_RESOURCE_POLICY, + DEFAULT_MAX_DURATION_SECONDS, + DEFAULT_MAX_ENCODED_FILE_BYTES, +) + YOUTUBE_VIDEO_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{11}$") MAX_YOUTUBE_URL_LENGTH = 2000 SUPPORTED_AUDIO_EXTENSIONS = (".opus", ".m4a", ".mp3", ".wav", ".aac", ".flac", ".ogg") @@ -21,6 +51,22 @@ "Failed to download audio from YouTube. Please use a local audio file instead." ) YOUTUBE_IMPORT_FAILED_MESSAGE = "YouTube import failed. Please use a local audio file instead." +YOUTUBE_SIZE_EXCEEDED_MESSAGE = "Selected audio file exceeds the 100 MiB analysis limit." + + +class YoutubeResourceLimitError(Exception): + """Fail-closed YouTube admission error that never includes payload paths.""" + + def __init__(self, code: str, message: str) -> None: + """Store a payload-safe public error code and next-action message. + + Args: + code: Stable machine-readable error code. + message: User-facing instruction that omits paths and URLs. + """ + super().__init__(message) + self.code = code + self.message = message def validate_url(url: str) -> bool: @@ -72,9 +118,203 @@ def _find_downloaded_file(actual_filepath: str) -> Optional[str]: return actual_filepath +def _size_exceeded_result() -> Dict[str, Any]: + """Return the payload-safe oversize result shared by every admission path.""" + return { + "ok": False, + "error": { + "code": "size_exceeded", + "message": YOUTUBE_SIZE_EXCEEDED_MESSAGE, + }, + } + + +def _download_error_result() -> Dict[str, Any]: + """Return the payload-safe generic import failure result.""" + return { + "ok": False, + "error": {"code": "download_error", "message": YOUTUBE_IMPORT_FAILED_MESSAGE}, + } + + +def _reject_invalid_or_oversize_duration(info: dict[str, Any]) -> Dict[str, Any] | None: + """Validate announced duration before authorizing download work. + + Args: + info: Metadata dictionary from yt-dlp extraction. + + Returns: + A payload-safe failure for malformed/over-budget known duration, or + ``None`` when duration is absent or valid and within policy. + """ + duration = info.get("duration") + if duration is None: + return None + if type(duration) not in (int, float): + return _download_error_result() + duration_seconds = float(duration) + if not math.isfinite(duration_seconds) or duration_seconds <= 0.0: + return _download_error_result() + if duration_seconds > DEFAULT_MAX_DURATION_SECONDS: + return { + "ok": False, + "error": { + "code": "duration_exceeded", + "message": "Video exceeds the 15-minute limit.", + }, + } + return None + + +def _announced_size_exceeds_policy(announced: object) -> bool: + """Return whether yt-dlp metadata already reports an over-budget file. + + Args: + announced: Candidate ``filesize`` or ``filesize_approx`` value. + + Returns: + True when the value is a finite number strictly above the policy ceiling. + """ + if isinstance(announced, bool) or not isinstance(announced, int | float): + return False + if isinstance(announced, float) and not math.isfinite(announced): + return False + size_bytes: int | float = announced + return bool(size_bytes > DEFAULT_MAX_ENCODED_FILE_BYTES) + + +def _reject_announced_oversize(info: dict[str, Any]) -> Dict[str, Any] | None: + """Reject before download when extract_info already announced oversize bytes. + + Args: + info: Metadata dictionary from ``extract_info(..., download=False)``. + + Returns: + The size-exceeded result, or ``None`` when download may proceed. + """ + if _announced_size_exceeds_policy(info.get("filesize")) or _announced_size_exceeds_policy( + info.get("filesize_approx") + ): + return _size_exceeded_result() + return None + + +def _owned_file_path(path: object, out_dir: str) -> str | None: + """Return a real path only when it stays inside this import's output directory. + + Args: + path: Candidate filesystem path from yt-dlp status or sibling lookup. + out_dir: Directory passed to this import call. + + Returns: + The resolved file path, or ``None`` when the value is unsafe or foreign. + """ + if not isinstance(path, str) or path == "": + return None + try: + resolved = os.path.realpath(path) + root = os.path.realpath(out_dir) + except OSError: + return None + if resolved == root or not resolved.startswith(root + os.sep): + return None + return resolved + + +def _remove_owned_file(path: object, out_dir: str) -> None: + """Delete one owned regular file, ignoring missing-path races. + + Args: + path: Candidate path that must resolve inside ``out_dir``. + out_dir: Directory passed to this import call. + """ + owned = _owned_file_path(path, out_dir) + if owned is None: + return + try: + if os.path.isfile(owned): + os.remove(owned) + except OSError: + return + + +def _remove_download_artifacts(status: dict[str, Any], out_dir: str) -> None: + """Delete the current download's partial, fragment, and control files. + + Args: + status: yt-dlp progress-hook payload that may name ``tmpfilename`` + and ``filename``. + out_dir: Directory passed to this import call. + """ + stems: set[str] = set() + for key in ("tmpfilename", "filename"): + owned = _owned_file_path(status.get(key), out_dir) + if owned is None: + continue + _remove_owned_file(owned, out_dir) + name = os.path.basename(owned) + if name.endswith(".part"): + name = name[: -len(".part")] + stems.add(name) + if not stems: + return + try: + entries = os.listdir(out_dir) + except OSError: + return + for entry in entries: + matches_stem = any( + entry == stem or entry.startswith(f"{stem}.") or entry.startswith(f"{stem}-") + for stem in stems + ) + if matches_stem: + _remove_owned_file(os.path.join(out_dir, entry), out_dir) + + +def _abort_over_budget_download(status: dict[str, Any], out_dir: str) -> None: + """Abort an in-flight download once encoded bytes exceed the policy ceiling. + + Args: + status: yt-dlp progress-hook payload. Unknown statuses are ignored. + out_dir: Directory passed to this import call, used to delete partials. + """ + if status.get("status") not in {"downloading", "finished"}: + return + for key in ("downloaded_bytes", "total_bytes", "total_bytes_estimate"): + candidate = status.get(key) + if isinstance(candidate, bool) or not isinstance(candidate, int): + continue + if candidate > DEFAULT_MAX_ENCODED_FILE_BYTES: + _remove_download_artifacts(status, out_dir) + raise YoutubeResourceLimitError("size_exceeded", YOUTUBE_SIZE_EXCEEDED_MESSAGE) + + +def _make_abort_hook(out_dir: str) -> Any: + """Bind the in-flight abort hook to one import output directory. + + Args: + out_dir: Directory passed to this import call. + + Returns: + A yt-dlp progress hook that aborts and deletes owned partials. + """ + + def _bound_abort_over_budget_download(status: dict[str, Any]) -> None: + """Abort and delete owned partials for this import directory. + + Args: + status: yt-dlp progress-hook payload. + """ + _abort_over_budget_download(status, out_dir) + + return _bound_abort_over_budget_download + + def _handle_download_error(e: yt_dlp.utils.DownloadError) -> Dict[str, Any]: """Map yt-dlp DownloadError to the public YouTube import error response.""" msg = str(e).lower() + if "max-filesize" in msg or "100 mib" in msg: + return _size_exceeded_result() if ( "sign in" in msg or "members-only" in msg @@ -130,6 +370,8 @@ def download_youtube_audio(url: str, out_dir: str) -> Dict[str, Any]: "noplaylist": True, "postprocessors": [{"key": "FFmpegExtractAudio"}], "geo_bypass": False, + "max_filesize": DEFAULT_MAX_ENCODED_FILE_BYTES, + "progress_hooks": [_make_abort_hook(out_dir)], } try: @@ -137,21 +379,17 @@ def download_youtube_audio(url: str, out_dir: str) -> Dict[str, Any]: info = ydl.extract_info(url, download=False) if info is None: raise Exception("Failed to extract info") - duration = info.get("duration") - if duration is not None and duration > 15 * 60: - return { - "ok": False, - "error": { - "code": "duration_exceeded", - "message": "Video exceeds the 15-minute limit.", - }, - } + duration_rejection = _reject_invalid_or_oversize_duration(info) + if duration_rejection is not None: + return duration_rejection + announced_rejection = _reject_announced_oversize(info) + if announced_rejection is not None: + return announced_rejection info = ydl.extract_info(url, download=True) if info is None: raise Exception("Failed to extract info") actual_filepath = ydl.prepare_filename(info) - actual_filepath = _find_downloaded_file(actual_filepath) if actual_filepath is None: @@ -163,18 +401,24 @@ def download_youtube_audio(url: str, out_dir: str) -> Dict[str, Any]: }, } - if ( - os.path.exists(actual_filepath) - and os.path.getsize(actual_filepath) > 50 * 1024 * 1024 - ): - os.remove(actual_filepath) - return { - "ok": False, - "error": { - "code": "size_exceeded", - "message": "Downloaded file exceeds the 50MB limit.", - }, - } + owned_filepath = _owned_file_path(actual_filepath, out_dir) + if owned_filepath is None: + return _download_error_result() + actual_filepath = owned_filepath + + duration_rejection = _reject_invalid_or_oversize_duration(info) + if duration_rejection is not None: + _remove_owned_file(actual_filepath, out_dir) + return duration_rejection + + try: + DEFAULT_AUDIO_RESOURCE_POLICY.validate_encoded_file_bytes( + os.path.getsize(actual_filepath) + ) + except ValueError: + if os.path.exists(actual_filepath): + os.remove(actual_filepath) + return _size_exceeded_result() return { "ok": True, "metadata": { @@ -184,13 +428,12 @@ def download_youtube_audio(url: str, out_dir: str) -> Dict[str, Any]: "filepath": actual_filepath, }, } + except YoutubeResourceLimitError: + return _size_exceeded_result() except yt_dlp.utils.DownloadError as e: return _handle_download_error(e) except Exception: - return { - "ok": False, - "error": {"code": "download_error", "message": YOUTUBE_IMPORT_FAILED_MESSAGE}, - } + return _download_error_result() def main() -> None: diff --git a/services/analysis-engine/tests/conftest.py b/services/analysis-engine/tests/conftest.py index e926e1e91..826284efc 100644 --- a/services/analysis-engine/tests/conftest.py +++ b/services/analysis-engine/tests/conftest.py @@ -27,3 +27,27 @@ def make_symlink_or_skip(link: Path, target: Path, *, target_is_directory: bool link.symlink_to(target, target_is_directory=target_is_directory) except OSError as error: pytest.skip(f"symlink creation is unavailable in this environment: {error}") + + +@pytest.fixture(autouse=True) +def _preserve_mocked_demucs_unit_boundary( + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, +) -> None: + """Let separation unit tests keep their explicit fake-model boundary. + + ``test_separation.py`` replaces Demucs itself with an in-memory fake so its + signal/shape contracts do not depend on a heavyweight checkpoint. The + production local-model admission guard is covered separately by + ``test_demucs_local_model_boundary.py`` and must not be bypassed there. + """ + if request.path.name != "test_separation.py": + return + + from bandscope_analysis.separation import audio_separator + + monkeypatch.setattr( + audio_separator, + "_local_demucs_checkpoint", + lambda _model_name: Path("mocked-demucs-checkpoint"), + ) diff --git a/services/analysis-engine/tests/test_audio_admitted_snapshot.py b/services/analysis-engine/tests/test_audio_admitted_snapshot.py new file mode 100644 index 000000000..50c5a23d3 --- /dev/null +++ b/services/analysis-engine/tests/test_audio_admitted_snapshot.py @@ -0,0 +1,150 @@ +"""Regression contracts for admitted local-audio byte continuity.""" + +from __future__ import annotations + +import hashlib + +import numpy as np +import pytest + +import bandscope_analysis.separation.audio_separator as audio_separator_module + + +def _same_size_bytes(seed: bytes, marker: int) -> bytes: + """Return a byte-distinct payload with the same encoded length.""" + payload = bytearray(seed) + payload[-1] = marker + return bytes(payload) + + +def _separator() -> audio_separator_module.AudioStemSeparator: + """Build the bounded separator used by the byte-continuity regressions.""" + return audio_separator_module.AudioStemSeparator( + audio_separator_module.AudioSeparationConfig( + target_sample_rate=8_000, + max_file_bytes=1_000_000, + ) + ) + + +def test_admitted_separator_rejects_same_size_replacement_before_decode( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Reject a pathname replacement that no longer matches native content evidence.""" + original = b"RIFF-admitted-audio" + replacement = _same_size_bytes(original, ord("X")) + audio_path = tmp_path / "source.wav" + audio_path.write_bytes(replacement) + decode_called = False + + def fake_decode(*_args, **_kwargs): + nonlocal decode_called + decode_called = True + return np.ones(8, dtype=np.float32), 8_000 + + monkeypatch.setattr(audio_separator_module, "decode_mono_audio", fake_decode) + + with pytest.raises(ValueError, match="source changed before decode"): + _separator().separate_admitted( + audio_path, + expected_file_size_bytes=len(original), + expected_content_sha256=hashlib.sha256(original).hexdigest(), + ) + + assert decode_called is False + + +def test_admitted_separator_decodes_verified_snapshot_after_path_replacement( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Decode the verified snapshot even if the pathname changes after snapshotting.""" + original = b"RIFF-admitted-audio" + replacement = _same_size_bytes(original, ord("Y")) + audio_path = tmp_path / "source.wav" + audio_path.write_bytes(original) + observed_decode_bytes: bytes | None = None + + def fake_decode(source, *, policy): + nonlocal observed_decode_bytes + audio_path.write_bytes(replacement) + source.seek(0) + observed_decode_bytes = source.read() + return np.ones(8, dtype=np.float32), policy.target_sample_rate + + monkeypatch.setattr(audio_separator_module, "decode_mono_audio", fake_decode) + monkeypatch.setattr( + audio_separator_module.AudioStemSeparator, + "_separate_signal", + lambda _self, audio, _sample_rate: { + "vocals": np.zeros(audio.size, dtype=np.float32), + "bass": np.zeros(audio.size, dtype=np.float32), + "drums": np.zeros(audio.size, dtype=np.float32), + "other": np.zeros(audio.size, dtype=np.float32), + }, + ) + + _separator().separate_admitted( + audio_path, + expected_file_size_bytes=len(original), + expected_content_sha256=hashlib.sha256(original).hexdigest(), + ) + + assert observed_decode_bytes == original + assert audio_path.read_bytes() == replacement + + +def test_plain_separator_consumes_scoped_native_evidence( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Require the production worker entrypoint to honor native admission evidence.""" + original = b"RIFF-admitted-audio" + audio_path = tmp_path / "source.wav" + audio_path.write_bytes(_same_size_bytes(original, ord("Z"))) + decode_called = False + + def fake_decode(*_args, **_kwargs): + nonlocal decode_called + decode_called = True + return np.ones(8, dtype=np.float32), 8_000 + + monkeypatch.setenv("BANDSCOPE_ADMITTED_AUDIO_BYTES", str(len(original))) + monkeypatch.setenv("BANDSCOPE_ADMITTED_AUDIO_SHA256", hashlib.sha256(original).hexdigest()) + monkeypatch.setattr(audio_separator_module, "decode_mono_audio", fake_decode) + monkeypatch.setattr( + audio_separator_module.AudioStemSeparator, + "_separate_signal", + lambda _self, audio, _sample_rate: { + "vocals": np.zeros(audio.size, dtype=np.float32), + "bass": np.zeros(audio.size, dtype=np.float32), + "drums": np.zeros(audio.size, dtype=np.float32), + "other": np.zeros(audio.size, dtype=np.float32), + }, + ) + + with pytest.raises(ValueError, match="source changed before decode"): + _separator().separate(audio_path) + + assert decode_called is False + + +def test_plain_separator_rejects_partial_native_evidence( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Fail closed when a child process receives only half of native evidence.""" + audio_path = tmp_path / "source.wav" + audio_path.write_bytes(b"RIFF-admitted-audio") + decode_called = False + + def fake_decode(*_args, **_kwargs): + nonlocal decode_called + decode_called = True + return np.ones(8, dtype=np.float32), 8_000 + + monkeypatch.setenv("BANDSCOPE_ADMITTED_AUDIO_BYTES", str(audio_path.stat().st_size)) + monkeypatch.delenv("BANDSCOPE_ADMITTED_AUDIO_SHA256", raising=False) + monkeypatch.setattr(audio_separator_module, "decode_mono_audio", fake_decode) + + with pytest.raises(ValueError, match="source changed before decode"): + _separator().separate(audio_path) + + assert decode_called is False diff --git a/services/analysis-engine/tests/test_audio_decode_port.py b/services/analysis-engine/tests/test_audio_decode_port.py new file mode 100644 index 000000000..6838584d9 --- /dev/null +++ b/services/analysis-engine/tests/test_audio_decode_port.py @@ -0,0 +1,172 @@ +"""Contract tests for the canonical local-audio decode port. + +These regressions keep resource admission, decoder failure redaction, and +decoded-output validation behind one owned boundary. +""" + +from __future__ import annotations + +import io + +import numpy as np +import pytest + +from bandscope_analysis import audio_decode +from bandscope_analysis.audio_resource_policy import ( + DEFAULT_AUDIO_RESOURCE_POLICY, + AudioResourcePolicy, + AudioResourcePolicyError, +) + + +def test_decode_mono_audio_preflights_then_validates_one_owned_decode( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep preflight, one decode, and decoded validation in strict order. + + The decode port must own the sequence so downstream analyzers cannot + bypass or duplicate resource admission. + """ + source = io.BytesIO(b"container") + calls: list[tuple[str, object]] = [] + decoder_output = np.array([[0.25, -0.5]], dtype=np.float64) + + def preflight(candidate: object, policy: object) -> None: + calls.append(("preflight", candidate)) + assert policy is DEFAULT_AUDIO_RESOURCE_POLICY + + def load(candidate: object, **kwargs: object) -> tuple[np.ndarray, int]: + calls.append(("decode", candidate)) + assert candidate is source + assert kwargs == { + "sr": DEFAULT_AUDIO_RESOURCE_POLICY.target_sample_rate, + "mono": True, + "duration": DEFAULT_AUDIO_RESOURCE_POLICY.decode_probe_duration_seconds, + } + return decoder_output, DEFAULT_AUDIO_RESOURCE_POLICY.target_sample_rate + + def validate(self: AudioResourcePolicy, decoded: object, sample_rate: object) -> np.ndarray: + calls.append(("validate", decoded)) + assert self is DEFAULT_AUDIO_RESOURCE_POLICY + assert isinstance(decoded, np.ndarray) + assert decoded.dtype == np.float32 + assert decoded.shape == (2,) + assert sample_rate == DEFAULT_AUDIO_RESOURCE_POLICY.target_sample_rate + return decoded + + monkeypatch.setattr(audio_decode, "preflight_audio_metadata", preflight) + monkeypatch.setattr(audio_decode.librosa, "load", load) + monkeypatch.setattr(AudioResourcePolicy, "validate_decoded_audio", validate) + + decoded, sample_rate = audio_decode.decode_mono_audio( + source, + policy=DEFAULT_AUDIO_RESOURCE_POLICY, + ) + + assert calls[0] == ("preflight", source) + assert calls[1] == ("decode", source) + assert calls[2][0] == "validate" + np.testing.assert_array_equal(decoded, np.array([0.25, -0.5], dtype=np.float32)) + assert sample_rate == DEFAULT_AUDIO_RESOURCE_POLICY.target_sample_rate + + +def test_decode_mono_audio_preserves_resource_policy_rejection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Propagate the canonical preflight rejection without invoking a decoder. + + A rejected source must not consume additional decode resources or lose its typed policy reason. + """ + rejection = AudioResourcePolicyError("duration_exceeded") + + def reject(_source: object, _policy: object) -> None: + raise rejection + + monkeypatch.setattr(audio_decode, "preflight_audio_metadata", reject) + monkeypatch.setattr( + audio_decode.librosa, + "load", + lambda *_args, **_kwargs: pytest.fail("decoder must not run after rejected preflight"), + ) + + with pytest.raises(AudioResourcePolicyError) as caught: + audio_decode.decode_mono_audio(io.BytesIO(b"container")) + + assert caught.value is rejection + + +def test_decode_mono_audio_redacts_third_party_decoder_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Map third-party decoder details to a payload-safe policy error. + + Native paths or token-shaped details may remain only in the exception + cause for local debugging, never in buyer-facing error text. + """ + secret_detail = "/Users/alice/Music/private.m4a token=secret" + monkeypatch.setattr(audio_decode, "preflight_audio_metadata", lambda *_args: None) + monkeypatch.setattr( + audio_decode.librosa, + "load", + lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError(secret_detail)), + ) + + with pytest.raises(AudioResourcePolicyError) as caught: + audio_decode.decode_mono_audio(io.BytesIO(b"container")) + + assert caught.value.reason == "malformed_header" + assert secret_detail not in str(caught.value) + assert isinstance(caught.value.__cause__, RuntimeError) + + +def test_decode_mono_audio_redacts_malformed_decoder_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Reject decoder output that cannot be normalized into bounded PCM. + + Malformed third-party values must fail at the decode boundary rather than + escaping into MIR analyzers. + """ + monkeypatch.setattr(audio_decode, "preflight_audio_metadata", lambda *_args: None) + monkeypatch.setattr( + audio_decode.librosa, + "load", + lambda *_args, **_kwargs: ([object()], DEFAULT_AUDIO_RESOURCE_POLICY.target_sample_rate), + ) + + with pytest.raises(AudioResourcePolicyError) as caught: + audio_decode.decode_mono_audio(io.BytesIO(b"container")) + + assert caught.value.reason == "malformed_header" + + +def test_decode_mono_audio_preserves_decoded_policy_rejection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Preserve rejection identity from decoded-audio resource validation. + + The decode port must not collapse a precise post-decode budget failure + into a generic malformed-container error. + """ + rejection = AudioResourcePolicyError("decoded_sample_count_exceeded") + monkeypatch.setattr(audio_decode, "preflight_audio_metadata", lambda *_args: None) + monkeypatch.setattr( + audio_decode.librosa, + "load", + lambda *_args, **_kwargs: ( + np.array([0.1], dtype=np.float32), + DEFAULT_AUDIO_RESOURCE_POLICY.target_sample_rate, + ), + ) + + def reject_decoded( + _self: AudioResourcePolicy, _decoded: object, _sample_rate: object + ) -> np.ndarray: + raise rejection + + monkeypatch.setattr(AudioResourcePolicy, "validate_decoded_audio", reject_decoded) + + with pytest.raises(AudioResourcePolicyError) as caught: + audio_decode.decode_mono_audio(io.BytesIO(b"container")) + + assert caught.value is rejection diff --git a/services/analysis-engine/tests/test_audio_metadata.py b/services/analysis-engine/tests/test_audio_metadata.py new file mode 100644 index 000000000..78e442619 --- /dev/null +++ b/services/analysis-engine/tests/test_audio_metadata.py @@ -0,0 +1,106 @@ +"""Source-container metadata preflight regressions.""" + +from __future__ import annotations + +import io +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from bandscope_analysis.audio_metadata import preflight_audio_metadata +from bandscope_analysis.audio_resource_policy import AudioResourcePolicyError + + +def _info(*, frames: int = 44_100, samplerate: int = 44_100, channels: int = 2) -> SimpleNamespace: + """Build the metadata subset consumed by the preflight boundary.""" + return SimpleNamespace(frames=frames, samplerate=samplerate, channels=channels) + + +@patch("bandscope_analysis.audio_metadata.soundfile.info") +def test_preflight_accepts_metadata_and_rewinds_the_caller_handle(mock_info: object) -> None: + """A successful metadata probe leaves the decoder handle at its beginning.""" + source = io.BytesIO(b"header-bytes") + + def inspect(handle: io.BytesIO) -> SimpleNamespace: + """Consume a small header before returning parsed metadata.""" + handle.read(3) + return _info() + + mock_info.side_effect = inspect # type: ignore[attr-defined] + + preflight_audio_metadata(source) + + assert source.tell() == 0 + + +@pytest.mark.parametrize( + ("info", "reason"), + [ + (_info(frames=44_100 * 901), "duration_exceeded"), + (_info(samplerate=7_999), "sampling_rate_unsupported"), + (_info(channels=3), "channel_count_unsupported"), + ], +) +@patch("bandscope_analysis.audio_metadata.soundfile.info") +def test_preflight_rejects_untrusted_source_metadata( + mock_info: object, + info: SimpleNamespace, + reason: str, +) -> None: + """Source duration, rate, and channel bounds fail before PCM decode.""" + mock_info.return_value = info # type: ignore[attr-defined] + + with pytest.raises(AudioResourcePolicyError, match="audio resource policy") as error: + preflight_audio_metadata(io.BytesIO(b"header")) + + assert error.value.reason == reason + + +@pytest.mark.parametrize( + "dependency_error", + [RuntimeError("decoder detail"), ValueError("decoder detail")], +) +def test_preflight_maps_parser_failures_to_payload_free_policy_error( + dependency_error: Exception, +) -> None: + """Container parser failures cannot masquerade as policy errors or leak decoder detail.""" + with patch( + "bandscope_analysis.audio_metadata.soundfile.info", + side_effect=dependency_error, + ): + with pytest.raises(AudioResourcePolicyError, match="audio resource policy") as error: + preflight_audio_metadata(io.BytesIO(b"bad-header")) + + assert error.value.reason == "malformed_header" + assert error.value.policy_version == "1" + assert "decoder detail" not in str(error.value) + + +@patch("bandscope_analysis.audio_metadata.soundfile.info") +def test_preflight_maps_rewind_failures_to_payload_free_policy_error(mock_info: object) -> None: + """A handle that cannot rewind after probing cannot reach a decoder.""" + + class SeekFailsAfterProbe(io.BytesIO): + """Fail only when the metadata boundary tries to rewind the handle.""" + + def __init__(self) -> None: + """Initialize the caller-owned byte handle and seek counter.""" + super().__init__(b"header") + self.seek_count = 0 + + def seek(self, *args: object, **kwargs: object) -> int: + """Reject the second seek, which is the post-probe rewind.""" + self.seek_count += 1 + if self.seek_count == 2: + raise OSError("rewind failed") + return super().seek(*args, **kwargs) + + mock_info.return_value = _info() # type: ignore[attr-defined] + + with pytest.raises(AudioResourcePolicyError, match="audio resource policy") as error: + preflight_audio_metadata(SeekFailsAfterProbe()) + + assert error.value.reason == "malformed_header" + assert error.value.policy_version == "1" + assert "rewind failed" not in str(error.value) diff --git a/services/analysis-engine/tests/test_audio_model_output_policy.py b/services/analysis-engine/tests/test_audio_model_output_policy.py new file mode 100644 index 000000000..863b5418c --- /dev/null +++ b/services/analysis-engine/tests/test_audio_model_output_policy.py @@ -0,0 +1,39 @@ +"""Regression tests for fail-closed source-separation model output.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from bandscope_analysis.separation.audio_separator import _as_float_array + + +@pytest.mark.parametrize( + "values", + [ + np.array([], dtype=np.float32), + np.array([np.nan], dtype=np.float32), + np.array([np.inf], dtype=np.float32), + np.array([np.finfo(np.float64).max], dtype=np.float64), + ], +) +def test_model_output_rejects_empty_nonfinite_or_float32_overflow(values: np.ndarray) -> None: + """Malformed model stems must fail instead of becoming successful silence.""" + with pytest.raises(ValueError, match=r"^Stem separation produced invalid audio\.$"): + _as_float_array(values) + + +def test_model_output_wraps_non_numeric_conversion_errors() -> None: + """Non-numeric model output must fail with the stable payload-free error.""" + with pytest.raises(ValueError, match=r"^Stem separation produced invalid audio\.$"): + _as_float_array(object()) + + +def test_model_output_preserves_valid_finite_samples() -> None: + """Valid model samples remain finite float32 audio with their original values.""" + values = np.array([0.25, -0.5, 0.75], dtype=np.float64) + + result = _as_float_array(values) + + assert result.dtype == np.float32 + assert np.array_equal(result, values.astype(np.float32)) diff --git a/services/analysis-engine/tests/test_audio_resource_policy.py b/services/analysis-engine/tests/test_audio_resource_policy.py new file mode 100644 index 000000000..0639590cc --- /dev/null +++ b/services/analysis-engine/tests/test_audio_resource_policy.py @@ -0,0 +1,235 @@ +"""Tests for the canonical local-audio resource policy.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from bandscope_analysis.audio_resource_policy import ( + AUDIO_RESOURCE_POLICY_VERSION, + DEFAULT_AUDIO_RESOURCE_POLICY, + DEFAULT_MAX_SOURCE_CHANNELS, + DEFAULT_MAX_SOURCE_SAMPLE_RATE, + DEFAULT_MIN_SOURCE_CHANNELS, + DEFAULT_MIN_SOURCE_SAMPLE_RATE, + AudioResourcePolicy, + AudioResourcePolicyError, +) + + +def test_default_policy_has_stable_version_and_rehearsal_budget() -> None: + """The default policy exposes one versioned budget shared by analyzers.""" + assert AUDIO_RESOURCE_POLICY_VERSION == "1" + assert DEFAULT_AUDIO_RESOURCE_POLICY.max_encoded_file_bytes == 100 * 1024 * 1024 + assert DEFAULT_AUDIO_RESOURCE_POLICY.target_sample_rate == 44_100 + assert DEFAULT_AUDIO_RESOURCE_POLICY.max_duration_seconds == 15 * 60 + assert DEFAULT_AUDIO_RESOURCE_POLICY.max_decoded_samples == 44_100 * 15 * 60 + assert DEFAULT_AUDIO_RESOURCE_POLICY.max_decoded_audio_bytes == 44_100 * 15 * 60 * 8 + + +def test_oversized_encoded_file_exposes_stable_policy_reason() -> None: + """Encoded-size rejection carries a stable reason and policy version for UI/provenance.""" + policy = AudioResourcePolicy(max_encoded_file_bytes=100) + + with pytest.raises(AudioResourcePolicyError) as captured: + policy.validate_encoded_file_bytes(101) + + assert captured.value.reason == "encoded_file_too_large" + assert captured.value.policy_version == AUDIO_RESOURCE_POLICY_VERSION + assert "audio resource policy" in str(captured.value).lower() + + +def test_source_metadata_exposes_stable_policy_reasons() -> None: + """Container admission distinguishes duration, rate, and channel rejection reasons.""" + policy = AudioResourcePolicy(max_duration_seconds=1.0) + + with pytest.raises(AudioResourcePolicyError) as duration_rejection: + policy.validate_source_metadata(frames=44_101, sample_rate=44_100, channels=2) + assert duration_rejection.value.reason == "duration_exceeded" + assert duration_rejection.value.policy_version == AUDIO_RESOURCE_POLICY_VERSION + + with pytest.raises(AudioResourcePolicyError) as rate_rejection: + policy.validate_source_metadata( + frames=44_100, + sample_rate=DEFAULT_MAX_SOURCE_SAMPLE_RATE + 1, + channels=2, + ) + assert rate_rejection.value.reason == "sampling_rate_unsupported" + assert rate_rejection.value.policy_version == AUDIO_RESOURCE_POLICY_VERSION + + with pytest.raises(AudioResourcePolicyError) as channel_rejection: + policy.validate_source_metadata( + frames=44_100, + sample_rate=44_100, + channels=DEFAULT_MAX_SOURCE_CHANNELS + 1, + ) + assert channel_rejection.value.reason == "channel_count_unsupported" + assert channel_rejection.value.policy_version == AUDIO_RESOURCE_POLICY_VERSION + + +def test_decoded_memory_rejection_exposes_stable_policy_reason() -> None: + """Post-decode memory rejection remains machine-readable without exposing payload data.""" + policy = AudioResourcePolicy( + target_sample_rate=8, + max_duration_seconds=1.0, + max_decoded_audio_bytes=16, + ) + audio = np.zeros(4, dtype=np.float64) + + with pytest.raises(AudioResourcePolicyError) as captured: + policy.validate_decoded_audio(audio, 8) + + assert captured.value.reason == "memory_budget_exceeded" + assert captured.value.policy_version == AUDIO_RESOURCE_POLICY_VERSION + + +@pytest.mark.parametrize("file_size", [True, -1, 0, 101]) +def test_encoded_file_size_fails_closed_outside_policy(file_size: object) -> None: + """Invalid, empty, or oversized encoded inputs are rejected before decode.""" + policy = AudioResourcePolicy(max_encoded_file_bytes=100) + + with pytest.raises(ValueError, match="audio resource policy"): + policy.validate_encoded_file_bytes(file_size) + + +def test_encoded_file_size_accepts_exact_boundary() -> None: + """A non-empty encoded file exactly at the configured ceiling is accepted.""" + policy = AudioResourcePolicy(max_encoded_file_bytes=100) + + assert policy.validate_encoded_file_bytes(100) == 100 + + +def test_source_metadata_accepts_the_published_bounds() -> None: + """Source metadata accepts the inclusive rate, channel, and duration bounds.""" + policy = AudioResourcePolicy(max_duration_seconds=15 * 60) + + policy.validate_source_metadata( + frames=DEFAULT_MAX_SOURCE_SAMPLE_RATE * 15 * 60, + sample_rate=DEFAULT_MAX_SOURCE_SAMPLE_RATE, + channels=DEFAULT_MAX_SOURCE_CHANNELS, + ) + policy.validate_source_metadata( + frames=DEFAULT_MIN_SOURCE_SAMPLE_RATE, + sample_rate=DEFAULT_MIN_SOURCE_SAMPLE_RATE, + channels=DEFAULT_MIN_SOURCE_CHANNELS, + ) + + +@pytest.mark.parametrize( + ("frames", "sample_rate", "channels"), + [ + (DEFAULT_MAX_SOURCE_SAMPLE_RATE * (15 * 60 + 1), 44_100, 2), + (44_100, DEFAULT_MIN_SOURCE_SAMPLE_RATE - 1, 2), + (44_100, DEFAULT_MAX_SOURCE_SAMPLE_RATE + 1, 2), + (44_100, 44_100, DEFAULT_MAX_SOURCE_CHANNELS + 1), + (44_100, 44_100, DEFAULT_MIN_SOURCE_CHANNELS - 1), + (0, 44_100, 2), + (44_100, True, 2), + (44_100, 44_100, True), + (10**400, 44_100, 2), + ], +) +def test_source_metadata_fails_closed_before_decode( + frames: object, + sample_rate: object, + channels: object, +) -> None: + """Overlong and malformed source metadata cannot reach a decoder.""" + with pytest.raises(ValueError, match="audio resource policy"): + DEFAULT_AUDIO_RESOURCE_POLICY.validate_source_metadata(frames, sample_rate, channels) + + +@pytest.mark.parametrize( + ("audio", "sample_rate"), + [ + (np.zeros(8_001, dtype=np.float32), 8_000), + (np.zeros((2, 4_000), dtype=np.float32), 8_000), + (np.array([0.0, np.nan], dtype=np.float32), 8_000), + (np.array(["not-a-sample"], dtype=object), 8_000), + (np.zeros(10, dtype=np.int16), 8_000), + (np.zeros(10, dtype=np.float32), 0), + (np.zeros(10, dtype=np.float32), True), + ], +) +def test_decoded_audio_fails_closed_outside_policy( + audio: np.ndarray, + sample_rate: object, +) -> None: + """Decoded output is revalidated for type, shape, finiteness, rate, and sample budget.""" + policy = AudioResourcePolicy(target_sample_rate=8_000, max_duration_seconds=1.0) + + with pytest.raises(ValueError, match="audio resource policy"): + policy.validate_decoded_audio(audio, sample_rate) + + +def test_decoded_audio_rejects_buffer_above_memory_budget() -> None: + """A decoder cannot hide excessive memory behind an allowed sample count.""" + policy = AudioResourcePolicy( + target_sample_rate=8, + max_duration_seconds=1.0, + max_decoded_audio_bytes=16, + ) + audio = np.zeros(4, dtype=np.float64) + + with pytest.raises(ValueError, match="audio resource policy"): + policy.validate_decoded_audio(audio, 8) + + +def test_decoded_audio_accepts_exact_memory_boundary() -> None: + """A finite canonical buffer exactly at the memory ceiling is accepted.""" + policy = AudioResourcePolicy( + target_sample_rate=8, + max_duration_seconds=1.0, + max_decoded_audio_bytes=32, + ) + audio = np.zeros(8, dtype=np.float32) + + assert policy.validate_decoded_audio(audio, 8) is audio + + +def test_decoded_audio_accepts_exact_sample_boundary() -> None: + """A finite mono artifact exactly at the decoded-sample ceiling is accepted.""" + policy = AudioResourcePolicy(target_sample_rate=8_000, max_duration_seconds=1.0) + audio = np.zeros(8_000, dtype=np.float32) + + validated = policy.validate_decoded_audio(audio, 8_000) + + assert validated is audio + + +@pytest.mark.parametrize( + "kwargs", + [ + {"max_encoded_file_bytes": 0}, + {"target_sample_rate": 0}, + {"max_duration_seconds": 0.0}, + {"max_duration_seconds": float("inf")}, + {"max_decoded_audio_bytes": 0}, + {"max_decoded_audio_bytes": True}, + {"min_source_sample_rate": 0}, + {"max_source_channels": True}, + {"min_source_sample_rate": 48_000, "max_source_sample_rate": 44_100}, + {"min_source_channels": 2, "max_source_channels": 1}, + ], +) +def test_policy_configuration_itself_fails_closed(kwargs: dict[str, object]) -> None: + """Invalid policy construction cannot silently create an unbounded budget.""" + with pytest.raises(ValueError, match="audio resource policy"): + AudioResourcePolicy(**kwargs) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + "kwargs", + [ + {"target_sample_rate": 10**400, "max_duration_seconds": 1.0}, + {"target_sample_rate": 1, "max_duration_seconds": 10**400}, + {"max_encoded_file_bytes": 10**400}, + {"max_decoded_audio_bytes": 10**400}, + ], +) +def test_policy_configuration_fails_closed_on_unrepresentable_limits( + kwargs: dict[str, object], +) -> None: + """Extreme integer limits cannot escape stable policy validation through overflow.""" + with pytest.raises(ValueError, match="audio resource policy"): + AudioResourcePolicy(**kwargs) # type: ignore[arg-type] diff --git a/services/analysis-engine/tests/test_audio_resource_policy_coverage_regressions.py b/services/analysis-engine/tests/test_audio_resource_policy_coverage_regressions.py new file mode 100644 index 000000000..8dfa3d688 --- /dev/null +++ b/services/analysis-engine/tests/test_audio_resource_policy_coverage_regressions.py @@ -0,0 +1,74 @@ +"""Coverage regressions for fail-closed audio resource admission branches.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +from bandscope_analysis.audio_resource_policy import AudioResourcePolicy +from bandscope_analysis.separation.audio_separator import ( + AudioSeparationConfig, + AudioStemSeparator, +) + + +def test_policy_rejects_boolean_duration_configuration() -> None: + """A Boolean duration must not be coerced into a one-second resource budget.""" + with pytest.raises(ValueError, match="audio resource policy"): + AudioResourcePolicy(max_duration_seconds=True) + + +def test_policy_rejects_less_than_one_decoded_sample_budget() -> None: + """A positive duration that represents less than one sample must fail closed.""" + with pytest.raises(ValueError, match="audio resource policy"): + AudioResourcePolicy(target_sample_rate=1, max_duration_seconds=0.5) + + +def test_separator_rejects_empty_internal_loader_result_before_model( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An unexpected empty loader result must not reach Demucs inference.""" + audio_path = tmp_path / "unexpected-empty.wav" + audio_path.write_bytes(b"not-empty") + separator = AudioStemSeparator( + AudioSeparationConfig(target_sample_rate=8_000, max_file_bytes=1_000_000) + ) + monkeypatch.setattr( + separator, + "_load_audio", + lambda _path: (np.array([], dtype=np.float32), 8_000), + ) + + def fail_if_model_runs(_audio: np.ndarray, _sample_rate: int) -> dict[str, np.ndarray]: + raise AssertionError("empty decoded audio must be rejected before model inference") + + monkeypatch.setattr(separator, "_separate_signal", fail_if_model_runs) + + with pytest.raises(ValueError, match="Stem separation decode failed"): + separator.separate(audio_path) + + +def test_separator_rejects_zero_byte_file_before_decoder( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A zero-byte selected source must fail before the decoder is invoked.""" + audio_path = tmp_path / "empty.wav" + audio_path.write_bytes(b"") + separator = AudioStemSeparator( + AudioSeparationConfig(target_sample_rate=8_000, max_file_bytes=1_000_000) + ) + + def fail_if_decoder_runs(*_args: object, **_kwargs: object) -> tuple[np.ndarray, int]: + raise AssertionError("zero-byte input must be rejected before decoder invocation") + + monkeypatch.setattr( + "bandscope_analysis.audio_decode.librosa.load", + fail_if_decoder_runs, + ) + + with pytest.raises(ValueError, match="Stem separation decode failed"): + separator.separate(audio_path) diff --git a/services/analysis-engine/tests/test_audio_resource_policy_integration.py b/services/analysis-engine/tests/test_audio_resource_policy_integration.py new file mode 100644 index 000000000..72ce8d0bd --- /dev/null +++ b/services/analysis-engine/tests/test_audio_resource_policy_integration.py @@ -0,0 +1,241 @@ +"""Cross-boundary regressions for canonical local-audio resource admission.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import Mock + +import numpy as np +import pytest + +from bandscope_analysis.api import validate_analysis_job_request +from bandscope_analysis.audio_resource_policy import ( + DEFAULT_AUDIO_RESOURCE_POLICY, + AudioResourcePolicy, +) +from bandscope_analysis.separation.audio_separator import ( + AudioSeparationConfig, + AudioStemSeparator, +) +from bandscope_analysis.temporal.analyzer import TemporalAnalyzer + + +def _local_request(file_size_bytes: object) -> dict[str, object]: + """Build one local-audio request whose only variable is encoded byte metadata.""" + return { + "sourceKind": "local_audio", + "projectId": "policy-project", + "sourceLabel": "rehearsal.wav", + "roleFocus": [], + "localSource": { + "sourcePath": "/tmp/rehearsal.wav", + "fileName": "rehearsal.wav", + "extension": "wav", + "fileSizeBytes": file_size_bytes, + }, + } + + +@pytest.mark.parametrize( + "file_size_bytes", + [True, DEFAULT_AUDIO_RESOURCE_POLICY.max_encoded_file_bytes + 1], +) +def test_request_preflight_rejects_metadata_outside_canonical_policy( + file_size_bytes: object, +) -> None: + """Reject impossible/oversized metadata before orchestration starts expensive work.""" + with pytest.raises(ValueError, match="localSource.fileSizeBytes"): + validate_analysis_job_request(_local_request(file_size_bytes)) + + +def test_request_preflight_accepts_exact_encoded_byte_boundary() -> None: + """The service API accepts the same exact encoded-byte ceiling as the policy.""" + request = validate_analysis_job_request( + _local_request(DEFAULT_AUDIO_RESOURCE_POLICY.max_encoded_file_bytes) + ) + + assert ( + request["localSource"]["fileSizeBytes"] + == DEFAULT_AUDIO_RESOURCE_POLICY.max_encoded_file_bytes + ) + + +def test_temporal_decoder_probes_one_sample_past_duration_limit_and_fails_closed( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Temporal decode detects a one-sample-overlong source instead of silently truncating it.""" + import librosa + + policy = AudioResourcePolicy( + max_encoded_file_bytes=100, + target_sample_rate=8, + max_duration_seconds=1.0, + ) + source = tmp_path / "overlong.wav" + source.write_bytes(b"bounded") + monkeypatch.setattr( + "bandscope_analysis.audio_decode.preflight_audio_metadata", + lambda *_args, **_kwargs: None, + ) + captured: dict[str, object] = {} + + def fake_load(fileobj: object, **kwargs: object) -> tuple[np.ndarray, int]: + captured.update(kwargs) + return np.zeros(policy.max_decoded_samples + 1, dtype=np.float32), policy.target_sample_rate + + monkeypatch.setattr(librosa, "load", fake_load) + monkeypatch.setattr( + librosa.beat, + "beat_track", + lambda **_: (_ for _ in ()).throw( + AssertionError("analysis must not run after policy rejection") + ), + ) + + with pytest.raises(ValueError, match="audio resource policy"): + TemporalAnalyzer(resource_policy=policy).analyze(source) + + assert captured["duration"] == pytest.approx( + (policy.max_decoded_samples + 1) / policy.target_sample_rate + ) + assert captured["sr"] == policy.target_sample_rate + assert captured["mono"] is True + + +@pytest.mark.parametrize( + "metadata", + [ + SimpleNamespace(frames=44_100 * 901, samplerate=44_100, channels=2), + SimpleNamespace(frames=44_100, samplerate=7_999, channels=2), + SimpleNamespace(frames=44_100, samplerate=44_100, channels=3), + ], +) +def test_temporal_rejects_source_metadata_before_librosa_decode( + tmp_path, + monkeypatch: pytest.MonkeyPatch, + metadata: SimpleNamespace, +) -> None: + """Temporal analysis must inspect source metadata before resampling or truncation.""" + import librosa + + source = tmp_path / "source-metadata.wav" + source.write_bytes(b"bounded") + monkeypatch.setattr( + "bandscope_analysis.audio_metadata.soundfile.info", + lambda _fileobj: metadata, + ) + load_mock = Mock(side_effect=AssertionError("source metadata must be checked first")) + monkeypatch.setattr(librosa, "load", load_mock) + + with pytest.raises(ValueError, match="audio resource policy"): + TemporalAnalyzer().analyze(source) + + load_mock.assert_not_called() + + +def test_stem_decoder_probes_one_sample_past_duration_limit_and_fails_closed( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Stem separation consumes the same decoded-sample ceiling as temporal analysis.""" + import librosa + + config = AudioSeparationConfig( + target_sample_rate=8, + max_file_bytes=100, + max_duration_seconds=1.0, + ) + source = tmp_path / "overlong.wav" + source.write_bytes(b"bounded") + monkeypatch.setattr( + "bandscope_analysis.audio_decode.preflight_audio_metadata", + lambda *_args, **_kwargs: None, + ) + captured: dict[str, object] = {} + + def fake_load(fileobj: object, **kwargs: object) -> tuple[np.ndarray, int]: + captured.update(kwargs) + return np.zeros(9, dtype=np.float32), 8 + + monkeypatch.setattr(librosa, "load", fake_load) + monkeypatch.setattr( + AudioStemSeparator, + "_separate_signal", + lambda *_: (_ for _ in ()).throw( + AssertionError("model must not run after policy rejection") + ), + ) + + with pytest.raises(ValueError, match="audio resource policy"): + AudioStemSeparator(config).separate(source) + + assert captured["duration"] == pytest.approx(9 / 8) + assert captured["sr"] == 8 + assert captured["mono"] is True + + +@pytest.mark.parametrize( + "metadata", + [ + SimpleNamespace(frames=44_100 * 901, samplerate=44_100, channels=2), + SimpleNamespace(frames=44_100, samplerate=7_999, channels=2), + SimpleNamespace(frames=44_100, samplerate=44_100, channels=3), + ], +) +def test_stem_decoder_rejects_source_metadata_before_librosa_decode( + tmp_path, + monkeypatch: pytest.MonkeyPatch, + metadata: SimpleNamespace, +) -> None: + """Stem separation must inspect source metadata before mono conversion or model work.""" + import librosa + + source = tmp_path / "source-metadata.wav" + source.write_bytes(b"bounded") + monkeypatch.setattr( + "bandscope_analysis.audio_metadata.soundfile.info", + lambda _fileobj: metadata, + ) + load_mock = Mock(side_effect=AssertionError("source metadata must be checked first")) + monkeypatch.setattr(librosa, "load", load_mock) + + separator = AudioStemSeparator(AudioSeparationConfig(max_file_bytes=100)) + with pytest.raises(ValueError, match="audio resource policy"): + separator.separate(source) + + load_mock.assert_not_called() + + +def test_stem_decoder_rejects_nonfinite_decoded_output_before_model( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Decoder NaN/Inf values fail closed instead of being normalized into model input.""" + import librosa + + source = tmp_path / "nonfinite.wav" + source.write_bytes(b"bounded") + monkeypatch.setattr( + "bandscope_analysis.audio_decode.preflight_audio_metadata", + lambda *_args, **_kwargs: None, + ) + monkeypatch.setattr( + librosa, + "load", + lambda *args, **kwargs: (np.array([0.0, np.nan], dtype=np.float32), 8), + ) + monkeypatch.setattr( + AudioStemSeparator, + "_separate_signal", + lambda *_: (_ for _ in ()).throw(AssertionError("model must not receive non-finite audio")), + ) + + with pytest.raises(ValueError, match="audio resource policy"): + AudioStemSeparator( + AudioSeparationConfig( + target_sample_rate=8, + max_file_bytes=100, + max_duration_seconds=1.0, + ) + ).separate(source) diff --git a/services/analysis-engine/tests/test_audio_separator_device_boundary.py b/services/analysis-engine/tests/test_audio_separator_device_boundary.py new file mode 100644 index 000000000..e8545876b --- /dev/null +++ b/services/analysis-engine/tests/test_audio_separator_device_boundary.py @@ -0,0 +1,109 @@ +"""Device-boundary regressions for local Demucs separation.""" + +from __future__ import annotations + +import sys +from types import ModuleType + +import numpy as np +import pytest + +from bandscope_analysis.separation.audio_separator import AudioSeparationConfig, AudioStemSeparator + + +class _FakeModel: + """Expose the canonical Demucs source order used by production.""" + + sources = ["drums", "bass", "other", "vocals"] + + +class _DeviceTensor: + """Minimal tensor that refuses NumPy conversion until moved to CPU.""" + + def __init__(self, array: np.ndarray, *, on_cpu: bool) -> None: + self.array = np.asarray(array, dtype=np.float32) + self.on_cpu = on_cpu + + def float(self) -> "_DeviceTensor": + return _DeviceTensor(self.array.astype(np.float32), on_cpu=self.on_cpu) + + def mean(self, axis: int | None = None) -> float | "_DeviceTensor": + value = self.array.mean(axis=axis) + if axis is None: + return float(value) + return _DeviceTensor(np.asarray(value, dtype=np.float32), on_cpu=self.on_cpu) + + def std(self) -> float: + return float(self.array.std()) + + def cpu(self) -> "_DeviceTensor": + return _DeviceTensor(self.array, on_cpu=True) + + def numpy(self) -> np.ndarray: + if not self.on_cpu: + raise RuntimeError("can't convert cuda tensor to numpy") + return self.array + + def __getitem__(self, key: object) -> "_DeviceTensor": + return _DeviceTensor(self.array[key], on_cpu=self.on_cpu) + + def __add__(self, value: float) -> "_DeviceTensor": + return _DeviceTensor(self.array + value, on_cpu=self.on_cpu) + + def __sub__(self, value: float) -> "_DeviceTensor": + return _DeviceTensor(self.array - value, on_cpu=self.on_cpu) + + def __mul__(self, value: float) -> "_DeviceTensor": + return _DeviceTensor(self.array * value, on_cpu=self.on_cpu) + + def __truediv__(self, value: float) -> "_DeviceTensor": + return _DeviceTensor(self.array / value, on_cpu=self.on_cpu) + + +class _NoGrad: + def __enter__(self) -> None: + return None + + def __exit__(self, *args: object) -> None: + return None + + +def test_apply_model_moves_device_output_to_cpu_before_numpy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """GPU-selected separation must cross the device boundary before NumPy conversion.""" + calls: dict[str, object] = {} + fake_torch = ModuleType("torch") + fake_torch.from_numpy = lambda array: _DeviceTensor(array, on_cpu=True) # type: ignore[attr-defined] + fake_torch.no_grad = _NoGrad # type: ignore[attr-defined] + + def fake_apply_model( + model: _FakeModel, + batch: _DeviceTensor, + *, + device: str, + split: bool, + overlap: float, + progress: bool, + ) -> _DeviceTensor: + calls.update(device=device, split=split, overlap=overlap, progress=progress) + source_values = np.arange(len(model.sources), dtype=np.float32).reshape(-1, 1, 1) + separated = np.broadcast_to(source_values, (len(model.sources), 2, 4)).copy() + return _DeviceTensor(separated[None], on_cpu=False) + + demucs_module = ModuleType("demucs") + apply_module = ModuleType("demucs.apply") + apply_module.apply_model = fake_apply_model # type: ignore[attr-defined] + demucs_module.apply = apply_module # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "torch", fake_torch) + monkeypatch.setitem(sys.modules, "demucs", demucs_module) + monkeypatch.setitem(sys.modules, "demucs.apply", apply_module) + + audio = np.array([0.0, 1.0, -1.0, 0.5], dtype=np.float32) + separator = AudioStemSeparator(AudioSeparationConfig(device="cuda", overlap=0.375)) + + result = separator._apply_model(_FakeModel(), audio) + + assert calls == {"device": "cuda", "split": True, "overlap": 0.375, "progress": False} + assert set(result) == set(_FakeModel.sources) + assert all(stem.shape == (4,) for stem in result.values()) diff --git a/services/analysis-engine/tests/test_chord_recognizer.py b/services/analysis-engine/tests/test_chord_recognizer.py index 20a6dcf78..88ff6684c 100644 --- a/services/analysis-engine/tests/test_chord_recognizer.py +++ b/services/analysis-engine/tests/test_chord_recognizer.py @@ -3,6 +3,7 @@ from unittest.mock import patch import numpy as np +import pytest from bandscope_analysis.chords.chord_recognizer import ( ChordRecognizer, @@ -20,6 +21,14 @@ def test_chord_recognizer_empty_audio() -> None: assert result == [] +@pytest.mark.parametrize("shape", [(0, 2), (2, 0)]) +def test_chord_recognizer_empty_layouts(shape: tuple[int, int]) -> None: + """Every zero-element NumPy layout must short-circuit recognition.""" + recognizer = ChordRecognizer() + + assert recognizer.recognize(np.empty(shape), sr=22050) == [] + + def test_chord_recognizer_unvoiced_audio() -> None: """Test chord recognition with noise.""" recognizer = ChordRecognizer() diff --git a/services/analysis-engine/tests/test_cli_native_admission_boundary.py b/services/analysis-engine/tests/test_cli_native_admission_boundary.py new file mode 100644 index 000000000..f93130f88 --- /dev/null +++ b/services/analysis-engine/tests/test_cli_native_admission_boundary.py @@ -0,0 +1,55 @@ +"""CLI trust-boundary regressions for native-admitted local audio.""" + +from __future__ import annotations + +import io +import json + +import pytest + +from bandscope_analysis import cli + + +def test_native_admission_skips_temporary_path_reopen(monkeypatch: pytest.MonkeyPatch) -> None: + """Do not decode a mutable pathname before the content-bound worker path.""" + payload = { + "jobId": "job-native-admitted", + "request": { + "sourceKind": "local_audio", + "projectId": "project-1-1", + "sourceLabel": "source.wav", + "roleFocus": [], + "localSource": { + "sourcePath": "/native/app-owned/project-1-1/source.wav", + "fileName": "source.wav", + "extension": "wav", + "fileSizeBytes": 12, + }, + }, + } + stdin = io.StringIO(json.dumps(payload)) + stdout = io.StringIO() + + class ForbiddenTemporalAnalyzer: + def __init__(self) -> None: + raise AssertionError("native-admitted audio must not be reopened by the CLI probe") + + monkeypatch.setenv("BANDSCOPE_ADMITTED_AUDIO_BYTES", "12") + monkeypatch.setenv("BANDSCOPE_ADMITTED_AUDIO_SHA256", "0" * 64) + monkeypatch.setattr(cli, "TemporalAnalyzer", ForbiddenTemporalAnalyzer) + monkeypatch.setattr( + cli, + "run_analysis_job", + lambda job_id, request, requested_at: { + "jobId": job_id, + "state": "succeeded", + "requestedAt": requested_at, + "updatedAt": requested_at, + }, + ) + monkeypatch.setattr(cli.sys, "stdin", stdin) + monkeypatch.setattr(cli.sys, "stdout", stdout) + monkeypatch.setattr(cli.sys, "argv", ["cli.py"]) + + assert cli.main() == 0 + assert json.loads(stdout.getvalue())["jobId"] == "job-native-admitted" diff --git a/services/analysis-engine/tests/test_demucs_local_model_boundary.py b/services/analysis-engine/tests/test_demucs_local_model_boundary.py new file mode 100644 index 000000000..e500bd2b7 --- /dev/null +++ b/services/analysis-engine/tests/test_demucs_local_model_boundary.py @@ -0,0 +1,329 @@ +"""Regression contracts for local-only Demucs model admission.""" + +from __future__ import annotations + +import hashlib +import pickle +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + +import bandscope_analysis.separation.audio_separator as audio_separator_module + + +class _FakeModel: + """Minimal Demucs model stand-in for the model-loading boundary.""" + + sources = ["drums", "bass", "other", "vocals"] + + def eval(self) -> "_FakeModel": + """Match the model evaluation call used after admission.""" + return self + + +def _install_fake_runtime( + monkeypatch: pytest.MonkeyPatch, + *, + torch_hub_dir: str, + get_model: object, +) -> None: + """Install deterministic torch/Demucs import boundaries for local-model tests.""" + fake_torch = ModuleType("torch") + fake_torch.hub = SimpleNamespace(get_dir=lambda: torch_hub_dir) # type: ignore[attr-defined] + + demucs_module = ModuleType("demucs") + pretrained_module = ModuleType("demucs.pretrained") + pretrained_module.get_model = get_model # type: ignore[attr-defined] + demucs_module.pretrained = pretrained_module # type: ignore[attr-defined] + + monkeypatch.setitem(sys.modules, "torch", fake_torch) + monkeypatch.setitem(sys.modules, "demucs", demucs_module) + monkeypatch.setitem(sys.modules, "demucs.pretrained", pretrained_module) + + +def test_demucs_model_load_fails_closed_before_remote_lookup_when_checkpoint_missing( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep first-run local analysis from turning into a model network download.""" + calls = {"count": 0} + + def forbidden_remote_lookup(_name: str, **_kwargs: object) -> _FakeModel: + calls["count"] += 1 + raise AssertionError("remote Demucs lookup must not run without a local checkpoint") + + _install_fake_runtime( + monkeypatch, + torch_hub_dir=str(tmp_path / "torch-hub"), + get_model=forbidden_remote_lookup, + ) + + with pytest.raises(ValueError, match="model weights are not installed locally"): + audio_separator_module.AudioStemSeparator()._load_model() + + assert calls["count"] == 0 + + +def test_demucs_model_load_uses_verified_private_snapshot_in_local_repo( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Deserialize the verified bytes, not a later replacement of the cache pathname.""" + checkpoint_bytes = b"cached-checkpoint-fixture" + checksum_prefix = hashlib.sha256(checkpoint_bytes).hexdigest()[:8] + checkpoint_name = f"955717e8-{checksum_prefix}.th" + checkpoint_root = tmp_path / "torch-hub" / "checkpoints" + checkpoint_root.mkdir(parents=True) + checkpoint_path = checkpoint_root / checkpoint_name + checkpoint_path.write_bytes(checkpoint_bytes) + calls: list[tuple[str, Path]] = [] + + def fake_local_lookup(name: str, *, repo: Path | None = None) -> _FakeModel: + assert name == "955717e8" + assert repo is not None + snapshot_path = repo / checkpoint_name + assert snapshot_path.read_bytes() == checkpoint_bytes + + checkpoint_path.write_bytes(b"cache-path-replaced-after-snapshot") + assert snapshot_path.read_bytes() == checkpoint_bytes + calls.append((name, repo)) + return _FakeModel() + + monkeypatch.setattr( + audio_separator_module, + "_DEMUCS_LOCAL_CHECKPOINTS", + {"htdemucs": checkpoint_name}, + ) + _install_fake_runtime( + monkeypatch, + torch_hub_dir=str(tmp_path / "torch-hub"), + get_model=fake_local_lookup, + ) + + model = audio_separator_module.AudioStemSeparator()._load_model() + + assert isinstance(model, _FakeModel) + assert len(calls) == 1 + assert calls[0][0] == "955717e8" + assert not calls[0][1].exists() + + +def test_demucs_model_load_rejects_tampered_cached_checkpoint( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Reject cached bytes that do not match the checkpoint filename checksum.""" + trusted_bytes = b"trusted-checkpoint-fixture" + checksum_prefix = hashlib.sha256(trusted_bytes).hexdigest()[:8] + checkpoint_name = f"955717e8-{checksum_prefix}.th" + checkpoint_root = tmp_path / "torch-hub" / "checkpoints" + checkpoint_root.mkdir(parents=True) + (checkpoint_root / checkpoint_name).write_bytes(b"tampered-checkpoint-fixture") + calls = {"count": 0} + + def forbidden_lookup(_name: str, **_kwargs: object) -> _FakeModel: + calls["count"] += 1 + raise AssertionError("tampered checkpoint must not reach Demucs deserialization") + + monkeypatch.setattr( + audio_separator_module, + "_DEMUCS_LOCAL_CHECKPOINTS", + {"htdemucs": checkpoint_name}, + ) + _install_fake_runtime( + monkeypatch, + torch_hub_dir=str(tmp_path / "torch-hub"), + get_model=forbidden_lookup, + ) + + with pytest.raises(ValueError, match="model weights are not installed locally"): + audio_separator_module.AudioStemSeparator()._load_model() + + assert calls["count"] == 0 + + +def test_demucs_model_load_rejects_checkpoint_over_resource_limit_before_resolver( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Reject an oversized cache object before copying or deserializing it.""" + checkpoint_bytes = b"oversized-checkpoint-fixture" + checksum_prefix = hashlib.sha256(checkpoint_bytes).hexdigest()[:8] + checkpoint_name = f"955717e8-{checksum_prefix}.th" + checkpoint_root = tmp_path / "torch-hub" / "checkpoints" + checkpoint_root.mkdir(parents=True) + (checkpoint_root / checkpoint_name).write_bytes(checkpoint_bytes) + calls = {"count": 0} + + def forbidden_lookup(_name: str, **_kwargs: object) -> _FakeModel: + calls["count"] += 1 + raise AssertionError("oversized checkpoint must not reach Demucs deserialization") + + monkeypatch.setattr( + audio_separator_module, + "_DEMUCS_LOCAL_CHECKPOINTS", + {"htdemucs": checkpoint_name}, + ) + monkeypatch.setattr( + audio_separator_module, + "_MAX_LOCAL_DEMUCS_CHECKPOINT_BYTES", + len(checkpoint_bytes) - 1, + raising=False, + ) + _install_fake_runtime( + monkeypatch, + torch_hub_dir=str(tmp_path / "torch-hub"), + get_model=forbidden_lookup, + ) + + with pytest.raises(ValueError, match="model weights are not installed locally"): + audio_separator_module.AudioStemSeparator()._load_model() + + assert calls["count"] == 0 + + +def test_demucs_model_load_rejects_checkpoint_growth_after_descriptor_preflight( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Reject bytes beyond the descriptor size admitted before snapshot copy.""" + checkpoint_bytes = b"checkpoint-grew-after-preflight" + checksum_prefix = hashlib.sha256(checkpoint_bytes).hexdigest()[:8] + checkpoint_name = f"955717e8-{checksum_prefix}.th" + checkpoint_root = tmp_path / "torch-hub" / "checkpoints" + checkpoint_root.mkdir(parents=True) + checkpoint_path = checkpoint_root / checkpoint_name + checkpoint_path.write_bytes(checkpoint_bytes) + checkpoint_stat = checkpoint_path.stat() + calls = {"count": 0} + + def forbidden_lookup(_name: str, **_kwargs: object) -> _FakeModel: + calls["count"] += 1 + raise AssertionError("post-preflight growth must not reach Demucs deserialization") + + real_fstat = audio_separator_module.os.fstat + + def stale_preflight_size(descriptor: int) -> object: + current = real_fstat(descriptor) + if current.st_dev == checkpoint_stat.st_dev and current.st_ino == checkpoint_stat.st_ino: + return SimpleNamespace( + st_mode=current.st_mode, + st_dev=current.st_dev, + st_ino=current.st_ino, + st_size=current.st_size - 1, + ) + return current + + monkeypatch.setattr( + audio_separator_module, + "_DEMUCS_LOCAL_CHECKPOINTS", + {"htdemucs": checkpoint_name}, + ) + monkeypatch.setattr(audio_separator_module.os, "fstat", stale_preflight_size) + _install_fake_runtime( + monkeypatch, + torch_hub_dir=str(tmp_path / "torch-hub"), + get_model=forbidden_lookup, + ) + + with pytest.raises(ValueError, match="model weights are not installed locally"): + audio_separator_module.AudioStemSeparator()._load_model() + + assert calls["count"] == 0 + + +def test_demucs_model_load_bounds_pytorch_weights_only_incompatibility( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep PyTorch 2.6+ weights-only failures inside the local-model boundary.""" + checkpoint_bytes = b"legacy-demucs-package-fixture" + checksum_prefix = hashlib.sha256(checkpoint_bytes).hexdigest()[:8] + checkpoint_name = f"955717e8-{checksum_prefix}.th" + checkpoint_root = tmp_path / "torch-hub" / "checkpoints" + checkpoint_root.mkdir(parents=True) + (checkpoint_root / checkpoint_name).write_bytes(checkpoint_bytes) + + def incompatible_weights_only_load(_name: str, **_kwargs: object) -> _FakeModel: + raise pickle.UnpicklingError( + "Weights only load failed: unsupported GLOBAL demucs.htdemucs.HTDemucs" + ) + + monkeypatch.setattr( + audio_separator_module, + "_DEMUCS_LOCAL_CHECKPOINTS", + {"htdemucs": checkpoint_name}, + ) + _install_fake_runtime( + monkeypatch, + torch_hub_dir=str(tmp_path / "torch-hub"), + get_model=incompatible_weights_only_load, + ) + + with pytest.raises(ValueError, match="model weights are not installed locally") as failure: + audio_separator_module.AudioStemSeparator()._load_model() + + assert "HTDemucs" not in str(failure.value) + + +@pytest.mark.parametrize("unsafe_override", ["1", "y", "yes", "true", "TRUE"]) +def test_demucs_model_load_rejects_environment_override_that_disables_weights_only( + tmp_path, + monkeypatch: pytest.MonkeyPatch, + unsafe_override: str, +) -> None: + """Do not let process environment reactivate unrestricted pickle loading.""" + checkpoint_bytes = b"environment-override-checkpoint-fixture" + checksum_prefix = hashlib.sha256(checkpoint_bytes).hexdigest()[:8] + checkpoint_name = f"955717e8-{checksum_prefix}.th" + checkpoint_root = tmp_path / "torch-hub" / "checkpoints" + checkpoint_root.mkdir(parents=True) + (checkpoint_root / checkpoint_name).write_bytes(checkpoint_bytes) + calls = {"count": 0} + + def forbidden_unsafe_lookup(_name: str, **_kwargs: object) -> _FakeModel: + calls["count"] += 1 + raise AssertionError("unsafe weights-only override must fail before deserialization") + + monkeypatch.setattr( + audio_separator_module, + "_DEMUCS_LOCAL_CHECKPOINTS", + {"htdemucs": checkpoint_name}, + ) + monkeypatch.setenv("TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD", unsafe_override) + _install_fake_runtime( + monkeypatch, + torch_hub_dir=str(tmp_path / "torch-hub"), + get_model=forbidden_unsafe_lookup, + ) + + with pytest.raises(ValueError, match="model weights are not installed locally"): + audio_separator_module.AudioStemSeparator()._load_model() + + assert calls["count"] == 0 + + +def test_demucs_model_load_rejects_backend_autoload_environment( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Do not let torch import auto-load out-of-tree backend extensions.""" + calls = {"count": 0} + + def forbidden_lookup(_name: str, **_kwargs: object) -> _FakeModel: + calls["count"] += 1 + raise AssertionError("backend autoload must fail before Demucs or torch import") + + monkeypatch.setenv("TORCH_DEVICE_BACKEND_AUTOLOAD", "1") + _install_fake_runtime( + monkeypatch, + torch_hub_dir=str(tmp_path / "torch-hub"), + get_model=forbidden_lookup, + ) + + with pytest.raises(ValueError, match="model weights are not installed locally"): + audio_separator_module.AudioStemSeparator()._load_model() + + assert calls["count"] == 0 diff --git a/services/analysis-engine/tests/test_project_persistence_workflow_policy.py b/services/analysis-engine/tests/test_project_persistence_workflow_policy.py new file mode 100644 index 000000000..1fd94a013 --- /dev/null +++ b/services/analysis-engine/tests/test_project_persistence_workflow_policy.py @@ -0,0 +1,29 @@ +"""Regression coverage for the Windows Project Persistence evidence lane.""" + +from pathlib import Path + + +def test_windows_project_persistence_gate_tracks_contract_inputs() -> None: + """Run the Windows regression whenever a persistence contract input changes.""" + repo_root = Path(__file__).resolve().parents[3] + workflow = (repo_root / ".github" / "workflows" / "project-persistence-windows.yml").read_text( + encoding="utf-8" + ) + + required_paths = ( + '"apps/desktop/core/Cargo.toml"', + '"apps/desktop/core/src/lib.rs"', + '"apps/desktop/core/src/crate_root.rs"', + '"apps/desktop/core/src/project_format.rs"', + '"apps/desktop/core/tests/project_persistence*.rs"', + '"apps/desktop/core/tests/project_format*.rs"', + '"apps/desktop/core/testdata/project-*.json"', + '"apps/desktop/src-tauri/Cargo.toml"', + '"apps/desktop/src-tauri/Cargo.lock"', + '"apps/desktop/src-tauri/src/main.rs"', + '"apps/desktop/src-tauri/src/project_persistence.rs"', + '"apps/desktop/src-tauri/tests/project_persistence*.rs"', + ) + + for required_path in required_paths: + assert required_path in workflow, f"Windows persistence workflow misses {required_path}" diff --git a/services/analysis-engine/tests/test_security_notes_policy.py b/services/analysis-engine/tests/test_security_notes_policy.py new file mode 100644 index 000000000..b8f45006c --- /dev/null +++ b/services/analysis-engine/tests/test_security_notes_policy.py @@ -0,0 +1,61 @@ +"""Regression coverage for the repository Security Notes documentation contract.""" + +from pathlib import Path +from runpy import run_path + +REPO_ROOT = Path(__file__).resolve().parents[3] +SECURITY_NOTES_CHECK = run_path(str(REPO_ROOT / "scripts" / "checks" / "verify_security_notes.py")) +security_notes_section = SECURITY_NOTES_CHECK["security_notes_section"] + + +def test_security_notes_section_stops_at_next_peer_heading() -> None: + """Do not let unrelated peer sections satisfy missing Security Notes evidence.""" + document = ( + "# Example\n\n" + "## Security Notes\n\n" + "Attack surface and trust boundary are defined here.\n\n" + "## Operations\n\n" + "Mitigations, test points, realistic threats, and remaining risk are " + "documented elsewhere.\n" + ) + + section = security_notes_section(document) + + assert "attack surface" in section + assert "trust boundary" in section + assert "mitigations" not in section + assert "test points" not in section + assert "realistic threats" not in section + assert "remaining risk" not in section + + +def test_security_notes_section_stops_when_parent_section_resumes() -> None: + """Keep a nested Security Notes section from consuming a later parent section.""" + document = ( + "# Example\n\n" + "## Design\n\n" + "### Security Notes\n\n" + "Attack surface and trust boundary are defined here.\n\n" + "## Operations\n\n" + "Mitigations, test points, realistic threats, and remaining risk are " + "documented elsewhere.\n" + ) + + section = security_notes_section(document) + + assert "attack surface" in section + assert "trust boundary" in section + assert "mitigations" not in section + assert "test points" not in section + assert "realistic threats" not in section + assert "remaining risk" not in section + + +def test_local_project_format_uses_required_security_notes_heading() -> None: + """Keep the project-format security section under the repository-mandated heading.""" + project_format = (REPO_ROOT / "docs" / "engineering" / "local-project-format.md").read_text( + encoding="utf-8" + ) + + assert "## Security Notes" in project_format + assert "## Security Constraints" not in project_format diff --git a/services/analysis-engine/tests/test_separation.py b/services/analysis-engine/tests/test_separation.py index f8e098521..49ac97637 100644 --- a/services/analysis-engine/tests/test_separation.py +++ b/services/analysis-engine/tests/test_separation.py @@ -466,7 +466,11 @@ def test_audio_stem_separator_rejects_empty_decoder_output( audio_path = tmp_path / "empty.wav" audio_path.write_bytes(b"placeholder") monkeypatch.setattr( - "bandscope_analysis.separation.audio_separator.librosa.load", + "bandscope_analysis.audio_decode.preflight_audio_metadata", + lambda *_args, **_kwargs: None, + ) + monkeypatch.setattr( + "bandscope_analysis.audio_decode.librosa.load", lambda *args, **kwargs: (np.array([], dtype=np.float32), 8_000), ) separator = AudioStemSeparator(AudioSeparationConfig(target_sample_rate=8_000)) @@ -481,12 +485,16 @@ def test_audio_stem_separator_redacts_decoder_exceptions( """Ensure decoder failures are surfaced without full local paths.""" audio_path = tmp_path / "broken.wav" audio_path.write_bytes(b"placeholder") + monkeypatch.setattr( + "bandscope_analysis.audio_decode.preflight_audio_metadata", + lambda *_args, **_kwargs: None, + ) def fail_decode(*args, **kwargs): raise RuntimeError(f"decoder failed under {tmp_path}") monkeypatch.setattr( - "bandscope_analysis.separation.audio_separator.librosa.load", + "bandscope_analysis.audio_decode.librosa.load", fail_decode, ) separator = AudioStemSeparator(AudioSeparationConfig(target_sample_rate=8_000)) diff --git a/services/analysis-engine/tests/test_stem_separation_logging_privacy.py b/services/analysis-engine/tests/test_stem_separation_logging_privacy.py new file mode 100644 index 000000000..8d7d2d7b1 --- /dev/null +++ b/services/analysis-engine/tests/test_stem_separation_logging_privacy.py @@ -0,0 +1,124 @@ +"""Regression tests for stem-separation logging privacy.""" + +import logging + +import pytest + +import bandscope_analysis.api as analysis_api + + +class _ResultQueue: + """Capture the worker result without starting a multiprocessing queue.""" + + def __init__(self) -> None: + self.items: list[tuple[object, object]] = [] + + def put(self, item: tuple[object, object]) -> None: + """Record one result emitted by the worker.""" + self.items.append(item) + + +class _FailingSeparator: + """Raise dependency-controlled sensitive text from the separator boundary.""" + + def separate(self, source_path: str) -> dict[str, object]: + """Simulate a dependency failure after receiving an authorized source path.""" + raise RuntimeError( + f"decoder failed for {source_path} /Users/Alice/private-song.wav token=super-secret" + ) + + +def _local_audio_request() -> dict[str, object]: + """Return a valid local-audio request without cache or temporary-path authority.""" + return { + "sourceKind": "local_audio", + "projectId": "privacy-regression", + "sourceLabel": "private-song.wav", + "roleFocus": ["bass-guitar"], + "localSource": { + "sourcePath": "/private/customer/Alice/session.wav", + "fileName": "private-song.wav", + "extension": "wav", + "fileSizeBytes": 1024, + }, + } + + +def _assert_payload_free_log(caplog: pytest.LogCaptureFixture) -> None: + """Require routine logs to omit dependency payloads and exception tracebacks.""" + assert "/private/customer/Alice/session.wav" not in caplog.text + assert "/Users/Alice/private-song.wav" not in caplog.text + assert "private-song.wav token=super-secret" not in caplog.text + assert "super-secret" not in caplog.text + assert all(record.exc_info is None for record in caplog.records) + + +def test_stem_worker_failure_log_omits_dependency_payload_and_traceback( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Routine worker diagnostics must not retain dependency payloads or tracebacks.""" + result_queue = _ResultQueue() + source_path = "/private/customer/Alice/session.wav" + + monkeypatch.setattr(analysis_api, "AudioStemSeparator", _FailingSeparator) + caplog.set_level(logging.ERROR, logger=analysis_api.__name__) + + analysis_api._stem_separation_worker(source_path, result_queue) + + assert result_queue.items == [ + ("runtime_error", "Runtime error occurred during stem separation.") + ] + assert "Stem separation failed with a runtime error." in caplog.text + _assert_payload_free_log(caplog) + + +def test_analysis_job_stem_failure_log_omits_dependency_payload_and_traceback( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Parent orchestration failure logs must keep dependency details out of routine logs.""" + sensitive_detail = ( + "decode failed for /private/customer/Alice/session.wav " + "/Users/Alice/private-song.wav token=super-secret" + ) + + def fail_features(_request: analysis_api.AnalysisJobRequest) -> None: + raise ValueError(sensitive_detail) + + monkeypatch.setattr(analysis_api, "_build_local_audio_features", fail_features) + caplog.set_level(logging.ERROR, logger=analysis_api.__name__) + + updates = analysis_api.run_analysis_job_updates( + "job-privacy", + _local_audio_request(), + "2026-08-20T00:00:00Z", + ) + + assert updates[-1]["state"] == "failed" + assert updates[-1]["error"] == { + "code": "engine_unavailable", + "message": "Stem separation failed", + } + assert "Stem separation failed before analysis job completion." in caplog.text + _assert_payload_free_log(caplog) + + +def test_api_logger_preserves_unrelated_exception_traceback( + caplog: pytest.LogCaptureFixture, +) -> None: + """Privacy redaction must not erase traceback evidence from unrelated API diagnostics.""" + caplog.set_level(logging.ERROR, logger=analysis_api.__name__) + + try: + raise RuntimeError("non-sensitive diagnostic sentinel") + except RuntimeError: + analysis_api.logger.exception("Unrelated analysis API diagnostic.") + + records = [ + record + for record in caplog.records + if record.getMessage() == "Unrelated analysis API diagnostic." + ] + assert len(records) == 1 + assert records[0].exc_info is not None diff --git a/services/analysis-engine/tests/test_temporal.py b/services/analysis-engine/tests/test_temporal.py index 6ce90ae1c..16c7f7034 100644 --- a/services/analysis-engine/tests/test_temporal.py +++ b/services/analysis-engine/tests/test_temporal.py @@ -46,9 +46,9 @@ def test_temporal_analyzer_basic(dummy_audio_file: Path) -> None: def test_temporal_analyzer_file_not_found() -> None: - """Test that analyzer raises appropriate error for missing files.""" + """Test that analyzer raises a payload-safe error for missing files.""" analyzer = TemporalAnalyzer() - with pytest.raises(FileNotFoundError, match="Audio file not found"): + with pytest.raises(FileNotFoundError, match="Audio source is unavailable"): analyzer.analyze("nonexistent_file.wav") @@ -62,7 +62,7 @@ def test_temporal_analyzer_missing_file_does_not_call_decoder( monkeypatch.setattr(librosa, "load", load_mock) analyzer = TemporalAnalyzer() - with pytest.raises(FileNotFoundError, match="Audio file not found"): + with pytest.raises(FileNotFoundError, match="Audio source is unavailable"): analyzer.analyze("nonexistent_file.wav") load_mock.assert_not_called() @@ -77,7 +77,7 @@ def test_temporal_analyzer_directory_does_not_call_decoder( load_mock = Mock(side_effect=AssertionError("librosa.load should not be called")) monkeypatch.setattr(librosa, "load", load_mock) - with pytest.raises(FileNotFoundError, match="Audio file not found"): + with pytest.raises(FileNotFoundError, match="Audio source is unavailable"): TemporalAnalyzer().analyze(tmp_path) load_mock.assert_not_called() @@ -94,7 +94,7 @@ def fake_load(*args, **kwargs): monkeypatch.setattr(librosa, "load", fake_load) test_wav = tmp_path / "test.wav" - test_wav.write_bytes(b"dummy") + sf.write(test_wav, np.zeros(4_000, dtype=np.float32), 44_100) with pytest.raises(ValueError, match="Expected numpy array"): TemporalAnalyzer().analyze(test_wav) @@ -104,7 +104,7 @@ def test_temporal_analyzer_exception_handling( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - """Ensure temporal analyzer catches general exceptions and raises ValueError.""" + """Ensure arbitrary decoder exception payloads are not relayed to callers.""" import librosa from bandscope_analysis.temporal.analyzer import TemporalAnalyzer @@ -115,10 +115,11 @@ def fake_load(*args: object, **kwargs: object) -> tuple[np.ndarray, int]: monkeypatch.setattr(librosa, "load", fake_load) test_wav = tmp_path / "test.wav" - test_wav.write_bytes(b"dummy") + sf.write(test_wav, np.zeros(4_000, dtype=np.float32), 44_100) - with pytest.raises(ValueError, match="Temporal analysis failed: Mocked general error"): + with pytest.raises(ValueError, match=r"^Temporal analysis failed\.$") as exc_info: TemporalAnalyzer().analyze(test_wav) + assert "Mocked general error" not in str(exc_info.value) def test_temporal_analyzer_rejects_oversized_file(monkeypatch, tmp_path: Path) -> None: @@ -128,7 +129,7 @@ def test_temporal_analyzer_rejects_oversized_file(monkeypatch, tmp_path: Path) - from bandscope_analysis.temporal import analyzer as analyzer_module test_wav = tmp_path / "large.wav" - test_wav.write_bytes(b"1234") + sf.write(test_wav, np.zeros(4_000, dtype=np.float32), 44_100) monkeypatch.setattr(analyzer_module, "MAX_AUDIO_FILE_BYTES", 1) @@ -147,7 +148,7 @@ def test_temporal_analyzer_uses_duration_limit(monkeypatch, tmp_path: Path) -> N import librosa test_wav = tmp_path / "bounded.wav" - test_wav.write_bytes(b"1234") + sf.write(test_wav, np.zeros(4_000, dtype=np.float32), 44_100) captured_kwargs: dict[str, object] = {} def fake_load(path, **kwargs): @@ -178,7 +179,7 @@ def test_temporal_analyzer_does_not_suppress_unrelated_loader_warnings( import librosa test_wav = tmp_path / "test.wav" - test_wav.write_bytes(b"dummy") + sf.write(test_wav, np.zeros(4_000, dtype=np.float32), 44_100) def fake_load(*args: object, **kwargs: object) -> tuple[np.ndarray, int]: warnings.warn("unrelated downstream warning", FutureWarning, stacklevel=2) diff --git a/services/analysis-engine/tests/test_temporal_error_privacy.py b/services/analysis-engine/tests/test_temporal_error_privacy.py new file mode 100644 index 000000000..ea0c6519f --- /dev/null +++ b/services/analysis-engine/tests/test_temporal_error_privacy.py @@ -0,0 +1,57 @@ +"""Privacy regressions for temporal-analysis failure diagnostics.""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import numpy as np +import pytest +import soundfile as sf + +from bandscope_analysis.temporal import TemporalAnalyzer + + +def test_missing_temporal_source_does_not_disclose_local_path(tmp_path: Path) -> None: + """Missing-file failures must not echo an absolute customer path to callers.""" + sensitive_path = tmp_path / "private-customer-session" / "unreleased-song.wav" + + with pytest.raises(FileNotFoundError) as exc_info: + TemporalAnalyzer().analyze(sensitive_path) + + message = str(exc_info.value) + assert message == "Audio source is unavailable for temporal analysis." + assert str(sensitive_path) not in message + assert "unreleased-song.wav" not in message + + +def test_decoder_failure_redacts_source_path_and_decoder_payload( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Decoder diagnostics must remain useful without logging customer path/payload data.""" + import librosa + + sensitive_path = tmp_path / "private-customer-session" / "unreleased-song.wav" + sensitive_path.parent.mkdir() + sf.write(sensitive_path, np.zeros(4_000, dtype=np.float32), 44_100) + decoder_payload = "decoder exposed /private/customer/token-shaped-audio-name.wav" + + def fail_decode(*args: object, **kwargs: object) -> tuple[object, int]: + raise RuntimeError(decoder_payload) + + monkeypatch.setattr(librosa, "load", fail_decode) + caplog.set_level(logging.INFO, logger="bandscope_analysis.temporal.analyzer") + + with pytest.raises(ValueError) as exc_info: + TemporalAnalyzer().analyze(sensitive_path) + + message = str(exc_info.value) + assert message == "Temporal analysis failed." + assert str(sensitive_path) not in message + assert decoder_payload not in message + assert str(sensitive_path) not in caplog.text + assert "unreleased-song.wav" not in caplog.text + assert decoder_payload not in caplog.text + assert "RuntimeError" in caplog.text diff --git a/services/analysis-engine/tests/test_transcription.py b/services/analysis-engine/tests/test_transcription.py index f9b55af93..80eb126ff 100644 --- a/services/analysis-engine/tests/test_transcription.py +++ b/services/analysis-engine/tests/test_transcription.py @@ -4,8 +4,11 @@ import io from dataclasses import dataclass +from types import SimpleNamespace +from unittest.mock import Mock import numpy as np +import pytest import soundfile as sf from bandscope_analysis.transcription import api as transcription_api @@ -62,6 +65,32 @@ def test_transcribe_bass_stem_rejects_oversized_input(monkeypatch) -> None: transcribe_bass_stem(b"abc") +@pytest.mark.parametrize( + "metadata", + [ + SimpleNamespace(frames=22050 * 121, samplerate=22050, channels=2), + SimpleNamespace(frames=22050, samplerate=7_999, channels=2), + SimpleNamespace(frames=22050, samplerate=22050, channels=3), + ], +) +def test_transcribe_bass_stem_rejects_source_metadata_before_decode( + monkeypatch: pytest.MonkeyPatch, + metadata: SimpleNamespace, +) -> None: + """Bass transcription must validate source duration, rate, and channels before librosa.""" + monkeypatch.setattr( + "bandscope_analysis.audio_metadata.soundfile.info", + lambda _fileobj: metadata, + ) + load_mock = Mock(side_effect=AssertionError("source metadata must be checked first")) + monkeypatch.setattr(transcription_api.librosa, "load", load_mock) + + with pytest.raises(ValueError, match="audio resource policy"): + transcribe_bass_stem(b"not-a-real-wav") + + load_mock.assert_not_called() + + def test_transcribe_bass_stem_wraps_pitch_tracking_parameter_errors(monkeypatch) -> None: """Return a stable ValueError when pYIN rejects decoded audio parameters.""" stem_data = _render_bass_sequence([ExpectedNote("E2", 0.0, 0.45)]) diff --git a/services/analysis-engine/tests/test_youtube.py b/services/analysis-engine/tests/test_youtube.py index 5531ac9d5..0ae449aa9 100644 --- a/services/analysis-engine/tests/test_youtube.py +++ b/services/analysis-engine/tests/test_youtube.py @@ -2,12 +2,22 @@ import importlib import sys +from pathlib import Path from unittest.mock import MagicMock, patch import pytest import yt_dlp # type: ignore -from bandscope_analysis.youtube import MAX_YOUTUBE_URL_LENGTH, download_youtube_audio, validate_url +from bandscope_analysis.audio_resource_policy import DEFAULT_MAX_ENCODED_FILE_BYTES +from bandscope_analysis.youtube import ( + MAX_YOUTUBE_URL_LENGTH, + YOUTUBE_SIZE_EXCEEDED_MESSAGE, + _owned_file_path, + _remove_download_artifacts, + _remove_owned_file, + download_youtube_audio, + validate_url, +) def test_validate_url() -> None: @@ -89,20 +99,23 @@ def test_download_youtube_audio_success( "id": "abc123DEF45", "title": "Test Video", "duration": 60, + "filesize": True, + "filesize_approx": float("nan"), } + out_dir = str(Path("/tmp").resolve()) mock_ydl.extract_info.return_value = mock_info - mock_ydl.prepare_filename.return_value = "/tmp/abc123DEF45.webm" + mock_ydl.prepare_filename.return_value = f"{out_dir}/abc123DEF45.webm" mock_exists.return_value = True mock_getsize.return_value = 10 * 1024 * 1024 input_url = "https://youtube.com/watch?v=abc123DEF45" - result = download_youtube_audio(input_url, "/tmp") + result = download_youtube_audio(input_url, out_dir) assert result["ok"] is True assert result["metadata"]["id"] == "abc123DEF45" assert result["metadata"]["title"] == "Test Video" assert result["metadata"]["duration"] == 60 - assert result["metadata"]["filepath"] == "/tmp/abc123DEF45.webm" + assert result["metadata"]["filepath"] == f"{out_dir}/abc123DEF45.webm" # Assert that YoutubeDL was initialized with the correct options mock_ydl_class.assert_called_once() @@ -114,6 +127,8 @@ def test_download_youtube_audio_success( assert called_opts["noplaylist"] is True assert called_opts["geo_bypass"] is False assert called_opts["postprocessors"] == [{"key": "FFmpegExtractAudio"}] + assert called_opts["max_filesize"] == DEFAULT_MAX_ENCODED_FILE_BYTES + assert called_opts["progress_hooks"] assert "%(id)s.%(ext)s" in called_opts["outtmpl"] # Verify extract_info was called twice correctly: once for metadata, once for download @@ -145,21 +160,22 @@ def test_download_youtube_audio_converted_extension( "title": "Test Video", "duration": 60, } + out_dir = str(Path("/tmp").resolve()) mock_ydl.extract_info.return_value = mock_info - mock_ydl.prepare_filename.return_value = "/tmp/abc123DEF45.webm" + mock_ydl.prepare_filename.return_value = f"{out_dir}/abc123DEF45.webm" # os.path.exists returns False for .webm, but True for the converted .opus. def exists_side_effect(path: str) -> bool: """Mock exists function to simulate converted extension file presence.""" - return path == "/tmp/abc123DEF45.opus" + return path == f"{out_dir}/abc123DEF45.opus" mock_exists.side_effect = exists_side_effect mock_getsize.return_value = 10 * 1024 * 1024 - result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", out_dir) assert result["ok"] is True - assert result["metadata"]["filepath"] == "/tmp/abc123DEF45.opus" + assert result["metadata"]["filepath"] == f"{out_dir}/abc123DEF45.opus" @patch("bandscope_analysis.youtube.os.path.exists") @@ -273,6 +289,50 @@ def test_download_youtube_audio_duration_exceeded(mock_ydl_class: MagicMock) -> assert result["error"]["code"] == "duration_exceeded" +@patch("bandscope_analysis.youtube.os.path.getsize") +@patch("bandscope_analysis.youtube.os.path.exists") +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_download_youtube_audio_accepts_size_between_legacy_and_canonical_ceiling( + mock_ydl_class: MagicMock, + mock_exists: MagicMock, + mock_getsize: MagicMock, +) -> None: + """A 60 MiB download that the old 50 MB check rejected is now accepted.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + out_dir = str(Path("/tmp").resolve()) + mock_ydl.extract_info.return_value = {"id": "abc123DEF45", "duration": 10 * 60} + mock_ydl.prepare_filename.return_value = f"{out_dir}/abc123DEF45.m4a" + mock_exists.return_value = True + mock_getsize.return_value = 60 * 1024 * 1024 + + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", out_dir) + + assert result["ok"] is True + assert result["metadata"]["filepath"] == f"{out_dir}/abc123DEF45.m4a" + + +@patch("bandscope_analysis.youtube.os.path.getsize") +@patch("bandscope_analysis.youtube.os.path.exists") +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_download_youtube_audio_accepts_exact_policy_ceiling( + mock_ydl_class: MagicMock, + mock_exists: MagicMock, + mock_getsize: MagicMock, +) -> None: + """An encoded YouTube file exactly at the 100 MiB ceiling is accepted.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + mock_ydl.extract_info.return_value = {"id": "abc123DEF45", "duration": 10 * 60} + mock_ydl.prepare_filename.return_value = "/tmp/abc123DEF45.m4a" + mock_exists.return_value = True + mock_getsize.return_value = DEFAULT_MAX_ENCODED_FILE_BYTES + + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + + assert result["ok"] is True + + @patch("bandscope_analysis.youtube.os.path.getsize") @patch("bandscope_analysis.youtube.os.path.exists") @patch("bandscope_analysis.youtube.os.remove") @@ -283,18 +343,315 @@ def test_download_youtube_audio_size_exceeded( mock_exists: MagicMock, mock_getsize: MagicMock, ) -> None: - """Test download fails if size exceeds 50MB.""" + """Post-download files one byte over the canonical 100 MiB ceiling are deleted.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + out_dir = str(Path("/tmp").resolve()) + mock_ydl.extract_info.return_value = {"id": "abc123DEF45", "duration": 10 * 60} + mock_ydl.prepare_filename.return_value = f"{out_dir}/abc123DEF45.m4a" + mock_exists.return_value = True + mock_getsize.return_value = DEFAULT_MAX_ENCODED_FILE_BYTES + 1 + + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", out_dir) + assert result["ok"] is False + assert result["error"]["code"] == "size_exceeded" + assert result["error"]["message"] == YOUTUBE_SIZE_EXCEEDED_MESSAGE + mock_remove.assert_called_with(f"{out_dir}/abc123DEF45.m4a") + + +@patch("bandscope_analysis.youtube.os.path.getsize") +@patch("bandscope_analysis.youtube.os.path.exists") +@patch("bandscope_analysis.youtube.os.remove") +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_download_youtube_audio_oversize_skips_remove_when_file_already_gone( + mock_ydl_class: MagicMock, + mock_remove: MagicMock, + mock_exists: MagicMock, + mock_getsize: MagicMock, +) -> None: + """A vanished oversize artifact still fails closed without a remove race.""" mock_ydl = MagicMock() mock_ydl_class.return_value.__enter__.return_value = mock_ydl mock_ydl.extract_info.return_value = {"id": "abc123DEF45", "duration": 10 * 60} mock_ydl.prepare_filename.return_value = "/tmp/abc123DEF45.m4a" + mock_exists.side_effect = [True, False] + mock_getsize.return_value = DEFAULT_MAX_ENCODED_FILE_BYTES + 1 + + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + + assert result["ok"] is False + assert result["error"]["code"] == "size_exceeded" + mock_remove.assert_not_called() + + +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_download_youtube_audio_rejects_announced_filesize_before_download( + mock_ydl_class: MagicMock, +) -> None: + """Announced filesize over the policy ceiling must not start the download.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + mock_ydl.extract_info.return_value = { + "id": "abc123DEF45", + "duration": 60, + "filesize": DEFAULT_MAX_ENCODED_FILE_BYTES + 1, + } + + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + + assert result["ok"] is False + assert result["error"]["code"] == "size_exceeded" + assert result["error"]["message"] == YOUTUBE_SIZE_EXCEEDED_MESSAGE + mock_ydl.extract_info.assert_called_once_with( + "https://youtube.com/watch?v=abc123DEF45", + download=False, + ) + + +@pytest.mark.parametrize( + "info", + [ + { + "id": "abc123DEF45", + "duration": 60, + "filesize_approx": DEFAULT_MAX_ENCODED_FILE_BYTES + 1, + }, + { + "id": "abc123DEF45", + "duration": 60, + "filesize_approx": float(DEFAULT_MAX_ENCODED_FILE_BYTES) + 0.5, + }, + ], +) +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_download_youtube_audio_rejects_announced_approximate_oversize( + mock_ydl_class: MagicMock, + info: dict[str, object], +) -> None: + """Approximate oversize metadata rejects the import before download starts.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + mock_ydl.extract_info.return_value = info + + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + + assert result["ok"] is False + assert result["error"]["code"] == "size_exceeded" + mock_ydl.extract_info.assert_called_once() + + +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_download_youtube_audio_progress_hook_aborts_over_budget( + mock_ydl_class: MagicMock, +) -> None: + """In-flight progress that crosses the encoded-byte ceiling fails closed.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + + def extract_info(_url: str, download: bool = False) -> dict[str, object]: + """Invoke the registered progress hook when the download starts.""" + if download: + hook = mock_ydl_class.call_args[0][0]["progress_hooks"][0] + hook( + { + "status": "downloading", + "downloaded_bytes": DEFAULT_MAX_ENCODED_FILE_BYTES + 1, + } + ) + return {"id": "abc123DEF45", "duration": 60} + + mock_ydl.extract_info.side_effect = extract_info + + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + + assert result["ok"] is False + assert result["error"]["code"] == "size_exceeded" + assert result["error"]["message"] == YOUTUBE_SIZE_EXCEEDED_MESSAGE + + +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_download_youtube_audio_progress_hook_deletes_partial_artifacts( + mock_ydl_class: MagicMock, + tmp_path: Path, +) -> None: + """In-flight abort must delete written partials so they cannot fill the cache.""" + out_dir = tmp_path / "import-cache" + out_dir.mkdir() + outsider = tmp_path / "unrelated-youtube-partial.part" + partial = out_dir / "abc123DEF45.m4a.part" + fragment = out_dir / "abc123DEF45.m4a-Frag1" + control = out_dir / "abc123DEF45.m4a.ytdl" + keep = out_dir / "keep-me.txt" + partial.write_bytes(b"partial-cache-bytes") + fragment.write_bytes(b"hls-fragment-bytes") + control.write_bytes(b"ytdl-control-bytes") + keep.write_bytes(b"unrelated-cache-note") + outsider.write_bytes(b"must-not-delete") + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + + def extract_info(_url: str, download: bool = False) -> dict[str, object]: + """Abort after yt-dlp has already written the current block to disk.""" + if download: + hook = mock_ydl_class.call_args[0][0]["progress_hooks"][0] + hook( + { + "status": "downloading", + "downloaded_bytes": DEFAULT_MAX_ENCODED_FILE_BYTES + 1, + "tmpfilename": str(partial), + "filename": str(out_dir / "abc123DEF45.m4a"), + } + ) + return {"id": "abc123DEF45", "duration": 60} + + mock_ydl.extract_info.side_effect = extract_info + + result = download_youtube_audio( + "https://youtube.com/watch?v=abc123DEF45", + str(out_dir), + ) + + assert result["ok"] is False + assert result["error"]["code"] == "size_exceeded" + assert result["error"]["message"] == YOUTUBE_SIZE_EXCEEDED_MESSAGE + assert not partial.exists() + assert not fragment.exists() + assert not control.exists() + assert keep.exists() + assert outsider.exists() + + +def test_owned_file_path_rejects_empty_foreign_and_unresolvable_paths( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Abort cleanup must not follow empty, escaped, or unresolvable paths.""" + out_dir = tmp_path / "import-cache" + out_dir.mkdir() + escaped = tmp_path / "outside.part" + escaped.write_bytes(b"keep") + + assert _owned_file_path(None, str(out_dir)) is None + assert _owned_file_path("", str(out_dir)) is None + assert _owned_file_path(str(out_dir), str(out_dir)) is None + assert _owned_file_path(str(escaped), str(out_dir)) is None + + def boom(_path: str) -> str: + """Simulate a filesystem error while resolving a candidate path.""" + raise OSError("realpath failed") + + monkeypatch.setattr("bandscope_analysis.youtube.os.path.realpath", boom) + assert _owned_file_path(str(out_dir / "clip.part"), str(out_dir)) is None + + +def test_remove_owned_file_ignores_missing_directories_and_remove_races( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Owned cleanup skips non-files and treats remove races as already gone.""" + out_dir = tmp_path / "import-cache" + nested = out_dir / "nested-dir" + nested.mkdir(parents=True) + _remove_owned_file(None, str(out_dir)) + _remove_owned_file(str(nested), str(out_dir)) + assert nested.is_dir() + + target = out_dir / "clip.part" + target.write_bytes(b"partial") + + def boom(_path: str) -> None: + """Simulate a disappearing file during abort cleanup.""" + raise OSError("remove failed") + + monkeypatch.setattr("bandscope_analysis.youtube.os.remove", boom) + _remove_owned_file(str(target), str(out_dir)) + assert target.exists() + + +def test_remove_download_artifacts_skips_empty_status_and_unlistable_cache( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Artifact sweep no-ops when yt-dlp omitted paths or the cache vanished.""" + out_dir = tmp_path / "import-cache" + out_dir.mkdir() + leftover = out_dir / "other-file.txt" + leftover.write_bytes(b"keep") + _remove_download_artifacts({"tmpfilename": None, "filename": 12}, str(out_dir)) + assert leftover.exists() + + partial = out_dir / "abc123DEF45.m4a.part" + partial.write_bytes(b"partial") + + def boom(_path: str) -> list[str]: + """Simulate the import cache disappearing after the first delete.""" + raise OSError("listdir failed") + + monkeypatch.setattr("bandscope_analysis.youtube.os.listdir", boom) + _remove_download_artifacts({"tmpfilename": str(partial)}, str(out_dir)) + assert leftover.exists() + + +@patch("bandscope_analysis.youtube.os.path.getsize") +@patch("bandscope_analysis.youtube.os.path.exists") +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_download_youtube_audio_progress_hook_ignores_non_budget_updates( + mock_ydl_class: MagicMock, + mock_exists: MagicMock, + mock_getsize: MagicMock, +) -> None: + """Unknown statuses and non-integer byte fields do not abort a valid download.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + mock_ydl.extract_info.return_value = {"id": "abc123DEF45", "duration": 60} + mock_ydl.prepare_filename.return_value = "/tmp/abc123DEF45.m4a" mock_exists.return_value = True - mock_getsize.return_value = 51 * 1024 * 1024 + mock_getsize.return_value = 10 * 1024 * 1024 result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + hook = mock_ydl_class.call_args[0][0]["progress_hooks"][0] + hook({"status": "error"}) + hook({"status": "downloading", "downloaded_bytes": True}) + hook({"status": "downloading", "downloaded_bytes": 12.5}) + hook({"status": "downloading", "downloaded_bytes": 10}) + hook({"status": "finished", "total_bytes": DEFAULT_MAX_ENCODED_FILE_BYTES}) + + assert result["ok"] is True + + +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_download_youtube_audio_maps_max_filesize_download_error( + mock_ydl_class: MagicMock, +) -> None: + """yt-dlp max-filesize aborts become the payload-safe size-exceeded result.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + mock_ydl.extract_info.side_effect = yt_dlp.utils.DownloadError( + "File is larger than max-filesize" + ) + + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + assert result["ok"] is False assert result["error"]["code"] == "size_exceeded" - mock_remove.assert_called_with("/tmp/abc123DEF45.m4a") + assert result["error"]["message"] == YOUTUBE_SIZE_EXCEEDED_MESSAGE + assert "max-filesize" not in result["error"]["message"] + + +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_download_youtube_audio_maps_mib_limit_download_error( + mock_ydl_class: MagicMock, +) -> None: + """Download errors that mention the 100 MiB ceiling stay payload-safe.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + mock_ydl.extract_info.side_effect = yt_dlp.utils.DownloadError(YOUTUBE_SIZE_EXCEEDED_MESSAGE) + + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + + assert result["ok"] is False + assert result["error"]["code"] == "size_exceeded" + assert result["error"]["message"] == YOUTUBE_SIZE_EXCEEDED_MESSAGE def test_main_block(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: @@ -326,38 +683,35 @@ def test_main_block(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixtu def test_module_execution( - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, ) -> None: - """Test the if __name__ == '__main__' block using runpy.""" + """Test module execution against a real owned output path without network I/O.""" import runpy import bandscope_analysis.youtube + downloaded_path = tmp_path / "abc123DEF45.m4a" + downloaded_path.write_bytes(b"test-audio") test_args = [ "youtube.py", "--url", "https://youtube.com/watch?v=abc123DEF45", "--out-dir", - "/tmp", + str(tmp_path), ] monkeypatch.setattr(sys, "argv", test_args) - # Mock yt_dlp so runpy doesn't actually download + # Mock only the downloader/network boundary. Real filesystem semantics are + # required so the completed-path ownership check remains exercised. mock_yt_dlp = MagicMock() mock_ydl = MagicMock() mock_yt_dlp.YoutubeDL.return_value.__enter__.return_value = mock_ydl mock_ydl.extract_info.return_value = {"id": "abc123DEF45"} - mock_ydl.prepare_filename.return_value = "/tmp/abc123DEF45.m4a" + mock_ydl.prepare_filename.return_value = str(downloaded_path) monkeypatch.setitem(sys.modules, "yt_dlp", mock_yt_dlp) - # Mock os to ensure runpy uses our mocked filesystem methods - mock_os = MagicMock() - # Keep some essential attributes - mock_os.path = MagicMock() - mock_os.path.exists.return_value = True - mock_os.path.getsize.return_value = 10 * 1024 * 1024 - monkeypatch.setitem(sys.modules, "os", mock_os) - with patch.object(sys, "exit") as mock_exit: runpy.run_path(bandscope_analysis.youtube.__file__, run_name="__main__") mock_exit.assert_called_with(0) diff --git a/services/analysis-engine/tests/test_youtube_downloaded_duration_revalidation.py b/services/analysis-engine/tests/test_youtube_downloaded_duration_revalidation.py new file mode 100644 index 000000000..6780203f6 --- /dev/null +++ b/services/analysis-engine/tests/test_youtube_downloaded_duration_revalidation.py @@ -0,0 +1,40 @@ +"""Post-download YouTube duration revalidation regressions.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from bandscope_analysis.youtube import download_youtube_audio + + +@patch("bandscope_analysis.youtube.os.path.exists") +@patch("bandscope_analysis.youtube.os.path.isfile") +@patch("bandscope_analysis.youtube.os.remove") +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_youtube_revalidates_downloaded_duration_before_returning_success( + mock_ydl_class: MagicMock, + mock_remove: MagicMock, + mock_isfile: MagicMock, + mock_exists: MagicMock, +) -> None: + """Changed download metadata must not bypass the 15-minute admission limit.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + out_dir = str(Path("/tmp").resolve()) + mock_ydl.extract_info.side_effect = [ + {"id": "abc123DEF45", "duration": 60}, + {"id": "abc123DEF45", "title": "Changed metadata", "duration": 16 * 60}, + ] + mock_ydl.prepare_filename.return_value = f"{out_dir}/abc123DEF45.m4a" + mock_exists.return_value = True + mock_isfile.return_value = True + + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", out_dir) + + assert result == { + "ok": False, + "error": { + "code": "duration_exceeded", + "message": "Video exceeds the 15-minute limit.", + }, + } + mock_remove.assert_called_once_with(f"{out_dir}/abc123DEF45.m4a") diff --git a/services/analysis-engine/tests/test_youtube_duration_contract.py b/services/analysis-engine/tests/test_youtube_duration_contract.py new file mode 100644 index 000000000..0cb168787 --- /dev/null +++ b/services/analysis-engine/tests/test_youtube_duration_contract.py @@ -0,0 +1,54 @@ +"""Fail-closed YouTube duration metadata admission contract.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from bandscope_analysis.youtube import download_youtube_audio + + +class _NonCanonicalFloat(float): + """Numeric subtype that must not cross the untrusted metadata boundary.""" + + +@pytest.mark.parametrize( + "duration", + [ + True, + 0, + -1, + float("nan"), + float("inf"), + "60", + object(), + _NonCanonicalFloat(60.0), + ], +) +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_youtube_rejects_malformed_announced_duration_before_download( + mock_ydl_class: MagicMock, + duration: object, +) -> None: + """Malformed known-duration metadata must not authorize a media download.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + mock_ydl.extract_info.return_value = { + "id": "abc123DEF45", + "duration": duration, + } + + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + + assert result == { + "ok": False, + "error": { + "code": "download_error", + "message": "YouTube import failed. Please use a local audio file instead.", + }, + } + mock_ydl.extract_info.assert_called_once_with( + "https://youtube.com/watch?v=abc123DEF45", + download=False, + ) diff --git a/services/analysis-engine/tests/test_youtube_post_download_path_authority.py b/services/analysis-engine/tests/test_youtube_post_download_path_authority.py new file mode 100644 index 000000000..75b23c5eb --- /dev/null +++ b/services/analysis-engine/tests/test_youtube_post_download_path_authority.py @@ -0,0 +1,74 @@ +"""Regression coverage for post-download YouTube path authority. + +The downloader owns only artifacts that resolve beneath the per-import output +directory. Metadata returned by yt-dlp must not turn an arbitrary filesystem path +into a successful import or deletion target. +""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from bandscope_analysis.audio_resource_policy import DEFAULT_MAX_ENCODED_FILE_BYTES +from bandscope_analysis.youtube import YOUTUBE_IMPORT_FAILED_MESSAGE, download_youtube_audio + + +def _configure_download(mock_ydl_class: MagicMock, filepath: Path) -> None: + """Configure yt-dlp to report one completed download at ``filepath``.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + mock_ydl.extract_info.return_value = { + "id": "abc123DEF45", + "title": "Authority regression", + "duration": 60, + } + mock_ydl.prepare_filename.return_value = str(filepath) + + +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_download_rejects_foreign_completed_path( + mock_ydl_class: MagicMock, + tmp_path: Path, +) -> None: + """A completed path outside this import directory must never become success metadata.""" + out_dir = tmp_path / "import-cache" + out_dir.mkdir() + foreign = tmp_path / "foreign.m4a" + foreign.write_bytes(b"not-owned-by-this-import") + _configure_download(mock_ydl_class, foreign) + + result = download_youtube_audio( + "https://youtube.com/watch?v=abc123DEF45", + str(out_dir), + ) + + assert result == { + "ok": False, + "error": {"code": "download_error", "message": YOUTUBE_IMPORT_FAILED_MESSAGE}, + } + assert foreign.read_bytes() == b"not-owned-by-this-import" + + +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_oversize_foreign_completed_path_is_not_deleted( + mock_ydl_class: MagicMock, + tmp_path: Path, +) -> None: + """Oversize rejection must not delete a path outside this import's authority.""" + out_dir = tmp_path / "import-cache" + out_dir.mkdir() + foreign = tmp_path / "foreign-oversize.m4a" + with foreign.open("wb") as handle: + handle.truncate(DEFAULT_MAX_ENCODED_FILE_BYTES + 1) + _configure_download(mock_ydl_class, foreign) + + result = download_youtube_audio( + "https://youtube.com/watch?v=abc123DEF45", + str(out_dir), + ) + + assert result == { + "ok": False, + "error": {"code": "download_error", "message": YOUTUBE_IMPORT_FAILED_MESSAGE}, + } + assert foreign.exists() + assert foreign.stat().st_size == DEFAULT_MAX_ENCODED_FILE_BYTES + 1