diff --git a/.github/workflows/project-persistence-macos.yml b/.github/workflows/project-persistence-macos.yml new file mode 100644 index 000000000..61ecf4965 --- /dev/null +++ b/.github/workflows/project-persistence-macos.yml @@ -0,0 +1,83 @@ +name: project-persistence-macos + +on: + pull_request: + branches: + - develop + - main + paths: + - "apps/desktop/core/Cargo.toml" + - "apps/desktop/core/src/root.rs" + - "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_load.rs" + - "apps/desktop/src-tauri/src/project_persistence.rs" + - "apps/desktop/src-tauri/src/project_persistence_engine.rs" + - "apps/desktop/src-tauri/src/project_root.rs" + - "apps/desktop/src-tauri/tests/project_persistence*.rs" + - "apps/desktop/src-tauri/tests/project_persistence*.case" + - "apps/desktop/src-tauri/tests/project_root_existing_authority.rs" + - "docs/traceability/project-persistence-native-ci.md" + - ".github/workflows/project-persistence-macos.yml" + - "services/analysis-engine/tests/test_project_persistence_workflow_policy.py" + push: + branches: + - develop + - main + paths: + - "apps/desktop/core/Cargo.toml" + - "apps/desktop/core/src/root.rs" + - "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_load.rs" + - "apps/desktop/src-tauri/src/project_persistence.rs" + - "apps/desktop/src-tauri/src/project_persistence_engine.rs" + - "apps/desktop/src-tauri/src/project_root.rs" + - "apps/desktop/src-tauri/tests/project_persistence*.rs" + - "apps/desktop/src-tauri/tests/project_persistence*.case" + - "apps/desktop/src-tauri/tests/project_root_existing_authority.rs" + - "docs/traceability/project-persistence-native-ci.md" + - ".github/workflows/project-persistence-macos.yml" + - "services/analysis-engine/tests/test_project_persistence_workflow_policy.py" + +permissions: + contents: read + +env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: develop + +jobs: + macos-recovery-cleanup: + name: test / project-persistence / macos + runs-on: macos-15 + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Install Rust 1.97.1 + run: rustup toolchain install 1.97.1 --profile minimal + - name: Prepare compile-only frontendDist fixture + run: | + mkdir -p apps/desktop/dist + printf '%s' 'BandScope test fixture' > apps/desktop/dist/index.html + - name: Run macOS recovery-cleanup regression + run: cargo +1.97.1 test --manifest-path apps/desktop/src-tauri/Cargo.toml --no-default-features --features persistence_warning_gate --tests diff --git a/.github/workflows/project-persistence-windows-native.yml b/.github/workflows/project-persistence-windows-native.yml new file mode 100644 index 000000000..ab1a146fd --- /dev/null +++ b/.github/workflows/project-persistence-windows-native.yml @@ -0,0 +1,84 @@ +name: project-persistence-windows-native + +on: + pull_request: + branches: + - develop + - main + paths: + - "apps/desktop/core/Cargo.toml" + - "apps/desktop/core/src/root.rs" + - "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_load.rs" + - "apps/desktop/src-tauri/src/project_persistence.rs" + - "apps/desktop/src-tauri/src/project_persistence_engine.rs" + - "apps/desktop/src-tauri/src/project_root.rs" + - "apps/desktop/src-tauri/tests/project_persistence*.rs" + - "apps/desktop/src-tauri/tests/project_persistence*.case" + - "apps/desktop/src-tauri/tests/project_root_existing_authority.rs" + - "docs/traceability/project-persistence-native-ci.md" + - ".github/workflows/project-persistence-windows-native.yml" + - "services/analysis-engine/tests/test_project_persistence_workflow_policy.py" + push: + branches: + - develop + - main + paths: + - "apps/desktop/core/Cargo.toml" + - "apps/desktop/core/src/root.rs" + - "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_load.rs" + - "apps/desktop/src-tauri/src/project_persistence.rs" + - "apps/desktop/src-tauri/src/project_persistence_engine.rs" + - "apps/desktop/src-tauri/src/project_root.rs" + - "apps/desktop/src-tauri/tests/project_persistence*.rs" + - "apps/desktop/src-tauri/tests/project_persistence*.case" + - "apps/desktop/src-tauri/tests/project_root_existing_authority.rs" + - "docs/traceability/project-persistence-native-ci.md" + - ".github/workflows/project-persistence-windows-native.yml" + - "services/analysis-engine/tests/test_project_persistence_workflow_policy.py" + +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 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - 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 --features persistence_warning_gate --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..919e13f9b 100644 --- a/apps/desktop/core/Cargo.toml +++ b/apps/desktop/core/Cargo.toml @@ -7,7 +7,11 @@ publish = false [lib] name = "bandscope_desktop_core" -path = "src/lib.rs" +path = "src/root.rs" + +[features] +default = [] +persistence_warning_gate = [] [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..093e3bb3d 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -122,12 +122,146 @@ 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, +} + +/// Historical on-disk project format owned only by the strict v1 compatibility parser. +const LEGACY_PROJECT_FORMAT_VERSION: u16 = 1; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProjectFileV1Payload { + 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 +272,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 +322,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 +401,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 +479,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 +506,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, } @@ -527,13 +793,32 @@ pub fn is_youtube_video_id(value: &str) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') } -pub fn project_payload_from_content(content: &str) -> Result { - if let Ok(parsed) = serde_json::from_str::(content) { +pub(crate) fn project_payload_from_content(content: &str) -> Result { + 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(LEGACY_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())?; + if envelope.project_format_version != LEGACY_PROJECT_FORMAT_VERSION { + return Err(format!( + "Unsupported project format version: {}", + envelope.project_format_version + )); + } + 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 @@ -869,6 +1154,98 @@ mod tests { assert_eq!(parsed.title, "Late Night Set"); } + #[test] + fn project_format_v1_parses_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 = serde_json::to_string_pretty(&json!({ + "projectFormatVersion": LEGACY_PROJECT_FORMAT_VERSION, + "song": song + })) + .expect("v1 project fixture should serialize"); + let encoded: Value = serde_json::from_str(&content).expect("v1 project should be JSON"); + assert_eq!( + encoded["projectFormatVersion"], + json!(LEGACY_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": LEGACY_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": LEGACY_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": LEGACY_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":{{}}}}}}"#, + LEGACY_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..bf4a6cda1 --- /dev/null +++ b/apps/desktop/core/src/project_format.rs @@ -0,0 +1,477 @@ +//! 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 std::io::{Cursor, Read}; + +use crate::{ + audio_resource::MAX_LOCAL_AUDIO_FILE_BYTES, + content_sha256::sha256_hex_reader, + 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, +} + +/// Content-addressed evidence for one deterministic project-format admission. +/// +/// `source_format_version` is `None` only for the historical unversioned raw +/// song shape. A current v3 input still receives a receipt with `migrated = +/// false`; its output digest identifies the canonical v3 serialization used by +/// the migration boundary rather than preserving incidental input whitespace. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ProjectMigrationReceipt { + /// Historical source version, or `None` for the unversioned legacy shape. + pub source_format_version: Option, + /// Current format version produced by this migration boundary. + pub target_format_version: u16, + /// SHA-256 of the exact input project bytes presented to the parser. + pub input_sha256: String, + /// SHA-256 of the deterministic current-version serialization. + pub output_sha256: String, + /// Whether the admitted input required a historical-format migration. + pub migrated: bool, +} + +fn verify_receipt_reader( + reader: impl Read, + expected_sha256: &str, + error_message: &str, +) -> Result<(), String> { + if !sha256_hex_is_canonical(expected_sha256) { + return Err(error_message.to_string()); + } + let observed_sha256 = + sha256_hex_reader(reader).map_err(|_| error_message.to_string())?; + if observed_sha256 != expected_sha256 { + return Err(error_message.to_string()); + } + Ok(()) +} + +impl ProjectMigrationReceipt { + /// Verify that an already-authorized reader still contains the exact admitted input bytes. + /// + /// Project Persistence uses this as the content side of migration compare-and-swap. The caller + /// owns filesystem authority and must provide the displaced predecessor object rather than + /// reopening a pathname. This method only applies the canonical SHA-256 contract and never opens + /// a path, logs content, or treats content identity as authenticity. + pub fn verify_input_reader(&self, reader: impl Read) -> Result<(), String> { + verify_receipt_reader( + reader, + &self.input_sha256, + "Could not validate project migration predecessor", + ) + } + + /// Verify that an already-authorized reader contains the exact validated output bytes. + /// + /// A persistence adapter may use this after native replacement and before retiring rollback + /// material. The receipt compares bytes only; the caller remains responsible for native file + /// authority and for ensuring that the reader denotes the published candidate object. + pub fn verify_output_reader(&self, reader: impl Read) -> Result<(), String> { + verify_receipt_reader( + reader, + &self.output_sha256, + "Could not validate project migration publication", + ) + } +} + +/// Fully validated in-memory candidate for a historical project migration. +/// +/// `canonical_content` is the exact current-version byte sequence that a +/// persistence adapter may stage. It has already reopened through the current +/// parser and reproduced itself through the canonical serializer before this +/// value is returned. The raw input is intentionally not retained here. +#[derive(Clone, Debug)] +pub struct PreparedProjectMigration { + document: ProjectDocumentPayload, + canonical_content: String, + receipt: ProjectMigrationReceipt, +} + +impl PreparedProjectMigration { + /// Return the current typed document admitted by the migration boundary. + pub fn document(&self) -> &ProjectDocumentPayload { + &self.document + } + + /// Return the exact validated current-version bytes that may be staged. + pub fn canonical_content(&self) -> &str { + &self.canonical_content + } + + /// Return the content-addressed evidence bound to this validated candidate. + pub fn receipt(&self) -> &ProjectMigrationReceipt { + &self.receipt + } +} + +#[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) +} + +fn parse_project_document( + content: &str, +) -> Result<(ProjectDocumentPayload, Option), String> { + 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, + }, + 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, + }, + Some(1), + )) + } + 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, + }, + Some(2), + )) + } + 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, + ))); + } + let document = validate_document(ProjectDocumentPayload { + song: envelope.song, + preferences: envelope.preferences, + source_reference: envelope.source_reference, + }) + .map_err(|_| "Invalid project file format".to_string())?; + Ok((document, Some(CURRENT_PROJECT_FORMAT_VERSION))) + } + _ => Err(unsupported_version(version)), + } +} + +/// 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 { + parse_project_document(content).map(|(document, _)| document) +} + +/// Prepare and self-validate a canonical current-version migration candidate. +/// +/// The input hash binds the exact bytes supplied by the caller. The candidate +/// is serialized once through the canonical v3 writer, reopened through the +/// current parser, and serialized again. Any parser/serializer disagreement +/// fails before a filesystem adapter can stage the candidate. Re-running this +/// function on `canonical_content` yields `migrated = false` and the same +/// output digest, making the validated copy suitable for later receipt-bound +/// crash-safe publication without retaining raw historical input in the plan. +pub fn prepare_project_migration(content: &str) -> Result { + let input_sha256 = sha256_hex_reader(Cursor::new(content.as_bytes())) + .map_err(|_| "Could not compute project migration receipt".to_string())?; + let (document, source_format_version) = parse_project_document(content)?; + let canonical_content = project_content_for_document(&document)?; + let output_sha256 = sha256_hex_reader(Cursor::new(canonical_content.as_bytes())) + .map_err(|_| "Could not compute project migration receipt".to_string())?; + + let (reopened, reopened_version) = parse_project_document(&canonical_content)?; + if reopened_version != Some(CURRENT_PROJECT_FORMAT_VERSION) { + return Err("Could not validate project migration candidate".to_string()); + } + let reopened_content = project_content_for_document(&reopened)?; + if reopened_content != canonical_content { + return Err("Could not validate project migration candidate".to_string()); + } + let reopened_sha256 = sha256_hex_reader(Cursor::new(reopened_content.as_bytes())) + .map_err(|_| "Could not compute project migration receipt".to_string())?; + if reopened_sha256 != output_sha256 { + return Err("Could not validate project migration candidate".to_string()); + } + + Ok(PreparedProjectMigration { + document, + canonical_content, + receipt: ProjectMigrationReceipt { + source_format_version, + target_format_version: CURRENT_PROJECT_FORMAT_VERSION, + input_sha256, + output_sha256, + migrated: source_format_version != Some(CURRENT_PROJECT_FORMAT_VERSION), + }, + }) +} + +/// Parse and normalize a project while producing deterministic migration evidence. +/// +/// Compatibility wrapper for callers that do not yet consume the validated +/// canonical copy directly. The same preparation boundary performs the +/// parse/serialize/reopen check before returning the typed document and receipt. +pub fn project_document_with_migration_receipt( + content: &str, +) -> Result<(ProjectDocumentPayload, ProjectMigrationReceipt), String> { + let prepared = prepare_project_migration(content)?; + Ok((prepared.document, prepared.receipt)) +} + +/// 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..385523fb6 --- /dev/null +++ b/apps/desktop/core/src/root.rs @@ -0,0 +1,45 @@ +#![cfg_attr(feature = "persistence_warning_gate", deny(warnings))] + +//! 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::{ + prepare_project_migration, project_content_for_document, project_content_for_payload, + project_document_from_content, project_document_from_value, + project_document_with_migration_receipt, project_payload_from_content, + project_source_reference_from_publication_identity, PreparedProjectMigration, + ProjectDocumentPayload, ProjectMigrationReceipt, 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..3ea26324f --- /dev/null +++ b/apps/desktop/core/tests/project_format_v2_fixture.rs @@ -0,0 +1,55 @@ +use std::io::Cursor; + +use bandscope_desktop_core::{ + prepare_project_migration, sha256_hex_reader, SelectedPlaybackSourcePayload, + CURRENT_PROJECT_FORMAT_VERSION, +}; +use serde_json::{json, Value}; + +#[test] +fn golden_v2_fixture_preserves_the_selected_playback_source() { + let content = include_str!("../testdata/project-v2.json"); + let prepared = prepare_project_migration(content) + .expect("the checked-in v2 fixture should prepare a validated migration copy"); + let document = prepared.document(); + let receipt = prepared.receipt(); + let canonical_content = prepared.canonical_content(); + + assert_eq!( + document.preferences.selected_playback_source, + SelectedPlaybackSourcePayload::Vocals + ); + assert_eq!(receipt.source_format_version, Some(2)); + assert_eq!(receipt.target_format_version, CURRENT_PROJECT_FORMAT_VERSION); + assert!(receipt.migrated); + assert_eq!( + receipt.input_sha256, + sha256_hex_reader(Cursor::new(content.as_bytes())) + .expect("fixture input digest should be reproducible") + ); + + assert_eq!( + receipt.output_sha256, + sha256_hex_reader(Cursor::new(canonical_content.as_bytes())) + .expect("migrated output digest should be reproducible") + ); + + let current = prepare_project_migration(canonical_content) + .expect("canonical migrated output should reopen through the current parser"); + assert_eq!(current.receipt().source_format_version, Some(3)); + assert!(!current.receipt().migrated); + assert_eq!(current.receipt().input_sha256, receipt.output_sha256); + assert_eq!(current.receipt().output_sha256, receipt.output_sha256); + assert_eq!(current.canonical_content(), canonical_content); + + let value: Value = serde_json::from_str(canonical_content) + .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_migration_receipt_input_binding.rs b/apps/desktop/core/tests/project_migration_receipt_input_binding.rs new file mode 100644 index 000000000..602516551 --- /dev/null +++ b/apps/desktop/core/tests/project_migration_receipt_input_binding.rs @@ -0,0 +1,35 @@ +use bandscope_desktop_core::prepare_project_migration; +use std::io::Cursor; + +#[test] +fn migration_receipt_verifies_the_exact_input_and_output_bytes() { + let original = include_str!("../testdata/project-v2.json"); + let prepared = prepare_project_migration(original).expect("v2 fixture should prepare migration"); + + prepared + .receipt() + .verify_input_reader(Cursor::new(original.as_bytes())) + .expect("the exact admitted input bytes should match their receipt"); + prepared + .receipt() + .verify_output_reader(Cursor::new(prepared.canonical_content().as_bytes())) + .expect("the exact validated candidate bytes should match their receipt"); + + let changed_input_but_parse_equivalent = format!("{original}\n"); + assert!( + prepared + .receipt() + .verify_input_reader(Cursor::new(changed_input_but_parse_equivalent.as_bytes())) + .is_err(), + "a byte-level predecessor change must fail even when the JSON parses to the same document" + ); + + let changed_output_but_parse_equivalent = format!("{}\n", prepared.canonical_content()); + assert!( + prepared + .receipt() + .verify_output_reader(Cursor::new(changed_output_but_parse_equivalent.as_bytes())) + .is_err(), + "a byte-level published-candidate change must fail even when the JSON remains parse-equivalent" + ); +} 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/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index bcafbab44..a01361592 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -19,6 +19,7 @@ uuid = { version = "1", features = ["v4"] } [features] default = [] +persistence_warning_gate = ["bandscope-desktop-core/persistence_warning_gate"] [package.metadata.opencode.coverage] # Baseline measured by the central Rust coverage evidence gate on PR #527. 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..2ec9dca67 --- /dev/null +++ b/apps/desktop/src-tauri/src/analysis_source.rs @@ -0,0 +1,84 @@ +use bandscope_desktop_core::{ + project_source_reference_from_publication_identity, + re_admit_local_audio_publication_from_project_root, + LocalAudioPublicationIdentity, ProjectBootstrapSummaryPayload, +}; +use std::{fs::File, io::Read, path::Path}; + +const ANALYSIS_SOURCE_NOT_FOUND: &str = + "Analysis job source was not found. Choose local audio again."; + +/// Create one non-clobbering local-audio stage without widening Unix permissions through umask. +/// +/// Security Notes: the stage path is already constrained to a validated app-owned project root by +/// the caller. Unix requests owner-only mode `0600` at creation so a permissive inherited process +/// umask cannot make the raw rehearsal audio group/world accessible before publication. Non-Unix +/// platforms retain native ACL inheritance. This function creates only the single requested file; +/// it does not acquire source-path authority, publish/replace a destination, or change Resource +/// Admission size/content semantics. +#[cfg(unix)] +pub(crate) fn create_private_local_audio_stage(path: &Path) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true).mode(0o600); + options.open(path) +} + +/// Create one non-clobbering local-audio stage while preserving native ACL inheritance. +#[cfg(not(unix))] +pub(crate) fn create_private_local_audio_stage(path: &Path) -> std::io::Result { + File::create_new(path) +} + +/// 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..228ac6b93 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,5 +1,13 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod analysis_source; +mod project_load; +mod project_persistence; +mod project_root; + +use analysis_source::{ + create_private_local_audio_stage, revalidate_local_audio_bootstrap_for_analysis, +}; use bandscope_desktop_core::*; use rfd::FileDialog; use serde_json::{json, Value}; @@ -14,6 +22,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) @@ -135,13 +156,45 @@ fn app_owned_root( .map_err(|_| "Could not prepare the local temp workspace.".to_string())?, _ => return Err(format!("Could not prepare the local {kind} workspace.")), }; + if kind == "projects" { + return project_root::resolve_existing_project_root(&base_root, project_id); + } let root = base_root.join(project_id); - std::fs::create_dir_all(&root) - .map_err(|_| format!("Could not prepare the local {kind} workspace."))?; - Ok(root) + project_root::ensure_owned_directory(&root) + .map_err(|_| format!("Could not prepare the local {kind} workspace.")) } -fn normalize_local_audio_source(path: &Path) -> Result { +fn provision_project_root( + app: &tauri::AppHandle, + project_id: &str, +) -> Result { + let base_root = app + .path() + .app_local_data_dir() + .map_err(|_| "Could not prepare the local project workspace.".to_string())?; + project_root::provision_new_project_root(&base_root, project_id) +} + +/// 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, Project Persistence +/// publishes it through the platform-native no-replace owner: Linux/macOS also +/// synchronize the parent directory before success, while Windows uses the +/// write-through native move. 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 +206,100 @@ 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 project_persistence::publish_synced_file_noreplace(&stage, &destination).is_err() { + 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 +435,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 +578,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 +593,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 +775,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 +789,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 +811,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 +896,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 +928,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 project_root = provision_project_root(&app, &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, @@ -672,7 +966,7 @@ async fn import_youtube_url( } let project_id = next_project_id(&state); - let project_root = app_owned_root(&app, "projects", &project_id)?; + let project_root = provision_project_root(&app, &project_id)?; let cache_root = app_owned_root(&app, "cache", &project_id)?; let temp_root = app_owned_root(&app, "temp", &project_id)?; @@ -712,6 +1006,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 +1034,45 @@ 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) + let document = project_load::load_project_document(&path)?; + restore_project_source_after_restart(&app, &state, &publication_state, &document)?; + Ok(document) } fn scores_root_for_project( @@ -779,9 +1083,8 @@ fn scores_root_for_project( // before this join; the root stays inside the app-owned data directory. let project_root = app_owned_root(app, "projects", project_id)?; let root = project_root.join("scores"); - std::fs::create_dir_all(&root) - .map_err(|_| "Could not prepare the local scores workspace.".to_string())?; - Ok(root) + project_root::ensure_owned_directory(&root) + .map_err(|_| "Could not prepare the local scores workspace.".to_string()) } /// Security Notes: the file path comes exclusively from the OS file dialog @@ -826,7 +1129,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 +1143,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 +1173,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_load.rs b/apps/desktop/src-tauri/src/project_load.rs new file mode 100644 index 000000000..7900060b4 --- /dev/null +++ b/apps/desktop/src-tauri/src/project_load.rs @@ -0,0 +1,81 @@ +use crate::project_persistence; +use bandscope_desktop_core::{prepare_project_migration, ProjectDocumentPayload}; +use std::path::{Path, PathBuf}; + +const PROJECT_PUBLISH_ERROR: &str = "Could not publish the project safely."; + +fn migration_stage_path(target: &Path) -> Result { + let parent = target.parent().unwrap_or_else(|| Path::new(".")); + if target.file_name().is_none() { + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + Ok(parent.join(format!( + ".bandscope-stage-{}.stage", + uuid::Uuid::new_v4() + ))) +} + +fn remove_stage(path: &Path) { + let _ = std::fs::remove_file(path); +} + +#[cfg(unix)] +fn preserve_project_data_permissions(target: &Path, stage: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + + let target_file = project_persistence::open_project_file(target) + .map_err(|_| PROJECT_PUBLISH_ERROR.to_string())?; + let target_metadata = target_file + .metadata() + .map_err(|_| PROJECT_PUBLISH_ERROR.to_string())?; + let stage_file = project_persistence::open_project_file(stage) + .map_err(|_| PROJECT_PUBLISH_ERROR.to_string())?; + let data_permissions = + std::fs::Permissions::from_mode(target_metadata.permissions().mode() & 0o666); + stage_file + .set_permissions(data_permissions) + .map_err(|_| PROJECT_PUBLISH_ERROR.to_string())?; + stage_file + .sync_all() + .map_err(|_| PROJECT_PUBLISH_ERROR.to_string())?; + Ok(()) +} + +#[cfg(not(unix))] +fn preserve_project_data_permissions(_target: &Path, _stage: &Path) -> Result<(), String> { + Ok(()) +} + +/// Load one project and durably publish a validated historical-format migration when required. +/// +/// The exact bounded bytes and native identity come from the same Project Persistence snapshot. +/// Historical inputs are prepared through the core migration authority, staged through the existing +/// crash-safe publisher, then committed only through the receipt-aware compare-and-swap replacement. +/// Current-format projects are parsed and returned without rewriting incidental byte representation. +/// A successful migration returns the already-validated prepared document instead of reparsing a +/// pathname that another process could have replaced after publication. +pub(crate) fn load_project_document(target: &Path) -> Result { + project_persistence::recover_project_publication(target)?; + let snapshot = project_persistence::read_project_file_with_identity(target)?; + let prepared = prepare_project_migration(snapshot.content())?; + + if prepared.receipt().migrated { + let stage = migration_stage_path(target)?; + project_persistence::publish_new_project_file( + &stage, + prepared.canonical_content().as_bytes(), + )?; + if let Err(error) = preserve_project_data_permissions(target, &stage) { + remove_stage(&stage); + return Err(error); + } + project_persistence::replace_existing_project_file_for_migration( + &stage, + target, + snapshot.identity(), + prepared.receipt(), + )?; + } + + Ok(prepared.document().clone()) +} 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..ebe19c58b --- /dev/null +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -0,0 +1,365 @@ +#[path = "../src/project_persistence_engine.rs"] +mod engine; + +pub(crate) use engine::*; + +use std::{ + fs::{self, File}, + io::Write, + path::{Path, PathBuf}, +}; + +const FIRST_SAVE_MAX_PROJECT_FILE_BYTES: usize = 5 * 1024 * 1024; +const FIRST_SAVE_EXISTS_ERROR: &str = "Project file already exists. Choose a new file name."; +const FIRST_SAVE_STAGE_ERROR: &str = "Could not stage the project safely."; +const FIRST_SAVE_PUBLISH_ERROR: &str = "Could not publish the project safely."; + +fn first_save_parent(target: &Path) -> &Path { + match target.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent, + _ => Path::new("."), + } +} + +fn first_save_stage_path(target: &Path) -> Result { + if target.file_name().is_none() { + return Err(FIRST_SAVE_PUBLISH_ERROR.to_string()); + } + Ok(first_save_parent(target).join(format!( + ".bandscope-stage-{}.stage", + uuid::Uuid::new_v4() + ))) +} + +#[cfg(unix)] +fn first_save_create_private_file(path: &Path) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true).mode(0o600); + options.open(path) +} + +#[cfg(not(unix))] +fn first_save_create_private_file(path: &Path) -> std::io::Result { + File::create_new(path) +} + +#[cfg(windows)] +fn first_save_metadata_is_safe_directory(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + metadata.is_dir() && metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 +} + +#[cfg(not(windows))] +fn first_save_metadata_is_safe_directory(metadata: &fs::Metadata) -> bool { + metadata.is_dir() && !metadata.file_type().is_symlink() +} + +#[cfg(target_os = "macos")] +fn first_save_is_trusted_macos_root_alias(path: &Path, metadata: &fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + + let Some(expected_target) = engine::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| first_save_metadata_is_safe_directory(&target)) +} + +#[cfg(not(target_os = "macos"))] +fn first_save_is_trusted_macos_root_alias(_path: &Path, _metadata: &fs::Metadata) -> bool { + false +} + +fn first_save_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| { + first_save_metadata_is_safe_directory(&metadata) + || first_save_is_trusted_macos_root_alias(ancestor, &metadata) + }) + }) +} + +#[cfg(unix)] +#[derive(Clone, Debug, Eq, PartialEq)] +struct FirstSaveIdentity { + device: u64, + inode: u64, +} + +#[cfg(unix)] +fn first_save_identity_from_file(file: &File) -> std::io::Result { + use std::os::unix::fs::MetadataExt; + + let metadata = file.metadata()?; + Ok(FirstSaveIdentity { + device: metadata.dev(), + inode: metadata.ino(), + }) +} + +#[cfg(unix)] +fn first_save_path_matches_identity(path: &Path, expected: &FirstSaveIdentity) -> bool { + use std::os::unix::fs::MetadataExt; + + fs::symlink_metadata(path).is_ok_and(|metadata| { + metadata.is_file() + && !metadata.file_type().is_symlink() + && metadata.dev() == expected.device + && metadata.ino() == expected.inode + }) +} + +#[cfg(windows)] +type FirstSaveIdentity = engine::WindowsFileIdentity; + +#[cfg(windows)] +fn first_save_identity_from_file(file: &File) -> std::io::Result { + engine::windows_file_identity(file) +} + +#[cfg(windows)] +fn first_save_path_matches_identity(path: &Path, expected: &FirstSaveIdentity) -> bool { + engine::project_file_identity(path).is_ok_and(|identity| &identity == expected) +} + +#[cfg(not(any(unix, windows)))] +#[derive(Clone, Debug, Eq, PartialEq)] +struct FirstSaveIdentity; + +#[cfg(not(any(unix, windows)))] +fn first_save_identity_from_file(_file: &File) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "first-save identity is unsupported on this platform", + )) +} + +#[cfg(not(any(unix, windows)))] +fn first_save_path_matches_identity(_path: &Path, _expected: &FirstSaveIdentity) -> bool { + false +} + +fn first_save_stage_is_current( + parent: &Path, + stage: &Path, + expected: &FirstSaveIdentity, +) -> bool { + first_save_parent_chain_is_safe(parent) && first_save_path_matches_identity(stage, expected) +} + +fn first_save_target_is_current( + parent: &Path, + target: &Path, + expected: &FirstSaveIdentity, +) -> bool { + first_save_parent_chain_is_safe(parent) && first_save_path_matches_identity(target, expected) +} + +fn first_save_remove_owned_stage( + parent: &Path, + stage: &Path, + expected: &FirstSaveIdentity, + required: bool, +) -> Result<(), String> { + if !first_save_parent_chain_is_safe(parent) { + return Err(FIRST_SAVE_PUBLISH_ERROR.to_string()); + } + match fs::symlink_metadata(stage) { + Ok(_) if first_save_path_matches_identity(stage, expected) => { + fs::remove_file(stage).map_err(|_| FIRST_SAVE_PUBLISH_ERROR.to_string()) + } + Err(error) if !required && error.kind() == std::io::ErrorKind::NotFound => Ok(()), + _ => Err(FIRST_SAVE_PUBLISH_ERROR.to_string()), + } +} + +#[cfg(unix)] +fn first_save_sync_parent(parent: &Path) -> std::io::Result<()> { + File::open(parent)?.sync_all() +} + +#[cfg(windows)] +fn first_save_sync_parent(_parent: &Path) -> std::io::Result<()> { + Ok(()) +} + +#[cfg(not(any(unix, windows)))] +fn first_save_sync_parent(_parent: &Path) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "first-save directory durability is unsupported on this platform", + )) +} + +#[cfg(windows)] +fn first_save_flush_target( + target: &Path, + expected: &FirstSaveIdentity, +) -> Result<(), String> { + use std::os::windows::fs::OpenOptionsExt; + + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + if !first_save_path_matches_identity(target, expected) { + return Err(FIRST_SAVE_PUBLISH_ERROR.to_string()); + } + let mut options = fs::OpenOptions::new(); + options + .read(true) + .write(true) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + let file = options + .open(target) + .map_err(|_| FIRST_SAVE_PUBLISH_ERROR.to_string())?; + let identity = engine::windows_file_identity(&file) + .map_err(|_| FIRST_SAVE_PUBLISH_ERROR.to_string())?; + if &identity != expected { + return Err(FIRST_SAVE_PUBLISH_ERROR.to_string()); + } + file.sync_all() + .map_err(|_| FIRST_SAVE_PUBLISH_ERROR.to_string()) +} + +#[cfg(not(windows))] +fn first_save_flush_target( + _target: &Path, + _expected: &FirstSaveIdentity, +) -> Result<(), String> { + Ok(()) +} + +/// Executes the single first-save publication state machine with injectable native boundaries. +/// +/// Production passes the real hard-link and parent-durability operations. Native persistence tests +/// replace only those two boundaries to exercise failure ordering while preserving the identical +/// staging, identity, no-clobber, permission, replacement, cleanup, and durability implementation. +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(FIRST_SAVE_STAGE_ERROR.to_string()); + } + if content.len() > FIRST_SAVE_MAX_PROJECT_FILE_BYTES { + return Err("Project file is too large (exceeds 5 MiB limit)".to_string()); + } + + let parent = first_save_parent(target); + if target.file_name().is_none() || !first_save_parent_chain_is_safe(parent) { + return Err(FIRST_SAVE_STAGE_ERROR.to_string()); + } + + let expected_target = match fs::symlink_metadata(target) { + Ok(metadata) => { + let identity = engine::project_file_identity(target)?; + Some((identity, metadata.permissions())) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(_) => return Err(FIRST_SAVE_PUBLISH_ERROR.to_string()), + }; + + let stage = first_save_stage_path(target)?; + let mut staged = + first_save_create_private_file(&stage).map_err(|_| FIRST_SAVE_STAGE_ERROR.to_string())?; + let staged_identity = first_save_identity_from_file(&staged) + .map_err(|_| FIRST_SAVE_STAGE_ERROR.to_string())?; + if staged.write_all(content).is_err() { + drop(staged); + let _ = first_save_remove_owned_stage(parent, &stage, &staged_identity, true); + return Err(FIRST_SAVE_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); + let _ = first_save_remove_owned_stage(parent, &stage, &staged_identity, true); + return Err(FIRST_SAVE_STAGE_ERROR.to_string()); + } + } + if staged.sync_all().is_err() { + drop(staged); + let _ = first_save_remove_owned_stage(parent, &stage, &staged_identity, true); + return Err(FIRST_SAVE_STAGE_ERROR.to_string()); + } + drop(staged); + + if !first_save_stage_is_current(parent, &stage, &staged_identity) { + return Err(FIRST_SAVE_PUBLISH_ERROR.to_string()); + } + + if let Some((expected, _)) = expected_target { + return engine::replace_existing_project_file(&stage, target, &expected); + } + + let link_result = link(&stage, target); + if !first_save_stage_is_current(parent, &stage, &staged_identity) { + return Err(FIRST_SAVE_PUBLISH_ERROR.to_string()); + } + + match link_result { + Ok(()) => { + if !first_save_target_is_current(parent, target, &staged_identity) { + return Err(FIRST_SAVE_PUBLISH_ERROR.to_string()); + } + first_save_remove_owned_stage(parent, &stage, &staged_identity, true)?; + if !first_save_target_is_current(parent, target, &staged_identity) { + return Err(FIRST_SAVE_PUBLISH_ERROR.to_string()); + } + sync_parent(parent).map_err(|_| FIRST_SAVE_PUBLISH_ERROR.to_string())?; + if !first_save_target_is_current(parent, target, &staged_identity) { + return Err(FIRST_SAVE_PUBLISH_ERROR.to_string()); + } + first_save_flush_target(target, &staged_identity)?; + Ok(()) + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + first_save_remove_owned_stage(parent, &stage, &staged_identity, true)?; + Err(FIRST_SAVE_EXISTS_ERROR.to_string()) + } + Err(_) => match engine::publish_synced_file_noreplace_with_directory_sync( + &stage, + target, + &mut sync_parent, + ) { + Ok(()) => { + if !first_save_target_is_current(parent, target, &staged_identity) { + return Err(FIRST_SAVE_PUBLISH_ERROR.to_string()); + } + first_save_flush_target(target, &staged_identity)?; + Ok(()) + } + Err(error) => { + first_save_remove_owned_stage(parent, &stage, &staged_identity, false)?; + Err(error) + } + }, + } +} + +pub(crate) fn publish_new_project_file(target: &Path, content: &[u8]) -> Result<(), String> { + publish_new_project_file_with_linker_and_directory_sync( + target, + content, + |source, destination| fs::hard_link(source, destination), + first_save_sync_parent, + ) +} diff --git a/apps/desktop/src-tauri/src/project_persistence_engine.rs b/apps/desktop/src-tauri/src/project_persistence_engine.rs new file mode 100644 index 000000000..2a759e68c --- /dev/null +++ b/apps/desktop/src-tauri/src/project_persistence_engine.rs @@ -0,0 +1,2248 @@ +use std::{ + fs::{self, File}, + io::{Read, Write}, + path::{Path, PathBuf}, +}; + +use bandscope_desktop_core::ProjectMigrationReceipt; +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."; +#[cfg(test)] +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("."), + } +} + +#[cfg(any(windows, test))] +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(unix)] +fn create_private_file_new(path: &Path) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true).mode(0o600); + options.open(path) +} + +#[cfg(not(unix))] +fn create_private_file_new(path: &Path) -> std::io::Result { + File::create_new(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(windows)] +fn flush_project_file_with_expected_identity( + path: &Path, + expected: &ProjectFileIdentity, +) -> Result<(), String> { + use std::os::windows::fs::OpenOptionsExt; + + let mut options = fs::OpenOptions::new(); + options + .read(true) + .write(true) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + let file = options + .open(path) + .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()); + } + let identity = windows_file_identity(&file).map_err(|_| PROJECT_PUBLISH_ERROR.to_string())?; + if &identity != expected { + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + file.sync_all() + .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()) +} + +/// Bounded project content coupled to the native identity of the exact opened file. +#[derive(Debug)] +pub(crate) struct ProjectFileReadSnapshot { + content: String, + identity: ProjectFileIdentity, +} + +impl ProjectFileReadSnapshot { + /// Returns the UTF-8 project content read from the identity-bearing native handle. + pub(crate) fn content(&self) -> &str { + &self.content + } + + /// Returns the native identity of the same opened file that produced `content`. + pub(crate) fn identity(&self) -> &ProjectFileIdentity { + &self.identity + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn open_project_file_with_expected_identity( + path: &Path, + expected: &ProjectFileIdentity, +) -> Result { + let file = open_project_file(path).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()); + } + + #[cfg(unix)] + let identity = project_file_identity_from_metadata(&metadata); + #[cfg(windows)] + let identity = windows_file_identity(&file).map_err(|_| PROJECT_PUBLISH_ERROR.to_string())?; + + if &identity != expected { + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + Ok(file) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn verify_migration_predecessor( + path: &Path, + expected: &ProjectFileIdentity, + receipt: &ProjectMigrationReceipt, +) -> Result<(), String> { + let file = open_project_file_with_expected_identity(path, expected)?; + receipt + .verify_input_reader(file) + .map_err(|_| PROJECT_PUBLISH_ERROR.to_string()) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn verify_migration_candidate( + path: &Path, + expected: &ProjectFileIdentity, + receipt: &ProjectMigrationReceipt, +) -> Result<(), String> { + let file = open_project_file_with_expected_identity(path, expected)?; + receipt + .verify_output_reader(file) + .map_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(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub(crate) enum PublicationValidation { + IdentityOnly, + Migration { receipt: ProjectMigrationReceipt }, +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct PublicationJournal { + version: u8, + target_name: JournalPathName, + candidate_name: JournalPathName, + displaced_name: JournalPathName, + expected: ProjectFileIdentity, + candidate: ProjectFileIdentity, + validation: PublicationValidation, +} + +#[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)] + let relative = { + use std::{ffi::OsStr, os::unix::ffi::OsStrExt}; + PathBuf::from(OsStr::from_bytes(name)) + }; + #[cfg(windows)] + let relative = { + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + PathBuf::from(OsString::from_wide(name)) + }; + + let mut components = relative.components(); + match (components.next(), components.next()) { + (Some(std::path::Component::Normal(_)), None) => Some(parent.join(relative)), + _ => None, + } +} + +#[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<()> { + // MoveFileExW no-replace publication uses MOVEFILE_WRITE_THROUGH. ReplaceFileW has no + // supported write-through flag, so existing-target commit/rollback and hard-link first-save + // durability are established with identity-bound file flushes at their call sites instead. + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +pub(crate) fn publish_synced_file_noreplace( + stage: &Path, + target: &Path, +) -> Result<(), String> { + publish_synced_file_noreplace_with_directory_sync(stage, target, sync_parent_directory) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] +pub(crate) fn publish_synced_file_noreplace( + _stage: &Path, + _target: &Path, +) -> Result<(), String> { + Err(PROJECT_PUBLISH_ERROR.to_string()) +} + +/// Publishes a caller-owned, already-synchronized stage without replacing an existing target. +/// +/// Project Persistence owns the platform publication primitive so Resource Admission and Active +/// Player do not grow their own rename/write-through implementations. The stage and target must be +/// siblings inside a safe project directory. Linux/macOS use the native no-replace rename and fsync +/// the parent before success; Windows uses `MoveFileExW(MOVEFILE_WRITE_THROUGH)` and the directory +/// synchronizer is intentionally a no-op. A competing target leaves the stage intact. If parent +/// durability fails after a successful rename, the complete target is left in place but success is +/// not acknowledged, preventing a persistence identity from claiming durability that was not proven. +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +pub(crate) fn publish_synced_file_noreplace_with_directory_sync( + stage: &Path, + target: &Path, + mut sync_parent: S, +) -> Result<(), String> +where + S: FnMut(&Path) -> std::io::Result<()>, +{ + let parent = project_parent(target); + if target.file_name().is_none() + || stage.parent() != Some(parent) + || !project_parent_chain_is_safe(parent) + { + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + let stage_metadata = + fs::symlink_metadata(stage).map_err(|_| PROJECT_PUBLISH_ERROR.to_string())?; + if !metadata_is_regular_project_file(&stage_metadata) { + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + + match rename_noreplace(stage, target) { + Ok(()) => sync_parent(parent).map_err(|_| PROJECT_PUBLISH_ERROR.to_string()), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + Err(PROJECT_EXISTS_ERROR.to_string()) + } + Err(_) => Err(PROJECT_PUBLISH_ERROR.to_string()), + } +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] +pub(crate) fn publish_synced_file_noreplace_with_directory_sync( + _stage: &Path, + _target: &Path, + _sync_parent: S, +) -> Result<(), String> +where + S: FnMut(&Path) -> std::io::Result<()>, +{ + Err(PROJECT_PUBLISH_ERROR.to_string()) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn create_publication_journal( + target: &Path, + candidate_stage: &Path, + displaced: &Path, + expected: &ProjectFileIdentity, + candidate: &ProjectFileIdentity, + validation: PublicationValidation, +) -> Result { + let journal_path = publication_journal_path(target, false)?; + let journal = PublicationJournal { + version: 2, + 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(), + validation, + }; + let bytes = serde_json::to_vec(&journal).map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + let mut file = match create_private_file_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)?; + let journal_content = read_project_file_with_opener( + &published, + 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 target_identity = + project_file_identity(target).map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + if target_identity != durable_journal.candidate { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + verify_recovery_migration_candidate(target, &durable_journal)?; + + match project_file_identity_if_present(stage)? { + Some(identity) if identity == durable_journal.expected => { + verify_recovery_migration_predecessor(stage, &durable_journal)?; + remove_recovery_artifact(stage)?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + } + Some(_) => return Err(PROJECT_RECOVERY_ERROR.to_string()), + None => {} + } + + remove_recovery_artifact(&published)?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + 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 verify_recovery_migration_predecessor( + path: &Path, + journal: &PublicationJournal, +) -> Result<(), String> { + if let PublicationValidation::Migration { receipt } = &journal.validation { + verify_migration_predecessor(path, &journal.expected, receipt) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + } + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn verify_recovery_migration_candidate( + path: &Path, + journal: &PublicationJournal, +) -> Result<(), String> { + if let PublicationValidation::Migration { receipt } = &journal.validation { + verify_migration_candidate(path, &journal.candidate, receipt) + .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()); + } + verify_recovery_migration_candidate(target, journal)?; + #[cfg(windows)] + flush_project_file_with_expected_identity(target, &journal.candidate) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + if displaced_identity.is_some() { + verify_recovery_migration_predecessor(displaced, journal)?; + 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) + { + if displaced_identity.as_ref() == Some(&journal.expected) { + verify_recovery_migration_predecessor(displaced, journal)?; + } + #[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()); + } + let restored_identity = displaced_identity + .as_ref() + .ok_or_else(|| PROJECT_RECOVERY_ERROR.to_string())?; + flush_project_file_with_expected_identity(target, restored_identity) + .map_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) + { + verify_recovery_migration_predecessor(target, journal)?; + #[cfg(windows)] + flush_project_file_with_expected_identity(target, &journal.expected) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + 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/content pairs fail +/// closed. Migration journals additionally retain the exact deterministic migration receipt so a +/// post-validation crash cannot later discard rollback material based only on native file identity. +#[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 != 2 + || !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"))] +fn replace_existing_project_file_with_validation( + stage: &Path, + target: &Path, + expected: &ProjectFileIdentity, + validation: PublicationValidation, + validate: F, +) -> Result<(), String> +where + F: FnOnce(&Path, &Path, &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, + validation, + ) { + 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); + let displaced_matches = displaced.as_ref().is_ok_and(|identity| identity == expected); + if displaced_matches && validate(stage, target, &candidate).is_ok() { + 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(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn replace_existing_project_file( + stage: &Path, + target: &Path, + expected: &ProjectFileIdentity, +) -> Result<(), String> { + replace_existing_project_file_with_validation( + stage, + target, + expected, + PublicationValidation::IdentityOnly, + |_, _, _| Ok(()), + ) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn replace_existing_project_file_for_migration( + stage: &Path, + target: &Path, + expected: &ProjectFileIdentity, + receipt: &ProjectMigrationReceipt, +) -> Result<(), String> { + replace_existing_project_file_with_validation( + stage, + target, + expected, + PublicationValidation::Migration { + receipt: receipt.clone(), + }, + |displaced, published, candidate| { + verify_migration_predecessor(displaced, expected, receipt)?; + verify_migration_candidate(published, candidate, receipt) + }, + ) +} + +#[cfg(windows)] +pub(crate) fn replace_existing_project_file_with_validation_and_flush( + stage: &Path, + target: &Path, + expected: &ProjectFileIdentity, + validation: PublicationValidation, + validate: F, + mut flush: S, +) -> Result<(), String> +where + F: FnOnce(&Path, &Path, &ProjectFileIdentity) -> Result<(), String>, + S: FnMut(&Path, &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, + validation, + ) { + 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); + let displaced_matches = displaced.as_ref().is_ok_and(|identity| identity == expected); + if displaced_matches + && validate(&backup, target, &candidate).is_ok() + && flush(target, &candidate).is_ok() + { + 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() { + if displaced + .as_ref() + .is_ok_and(|restored_identity| flush(target, restored_identity).is_ok()) + { + let _ = finish_rolled_back_publication(stage, &journal, target); + } + } + Err(PROJECT_PUBLISH_ERROR.to_string()) +} + +#[cfg(windows)] +fn replace_existing_project_file_with_validation( + stage: &Path, + target: &Path, + expected: &ProjectFileIdentity, + validation: PublicationValidation, + validate: F, +) -> Result<(), String> +where + F: FnOnce(&Path, &Path, &ProjectFileIdentity) -> Result<(), String>, +{ + replace_existing_project_file_with_validation_and_flush( + stage, + target, + expected, + validation, + validate, + flush_project_file_with_expected_identity, + ) +} + +#[cfg(windows)] +pub(crate) fn replace_existing_project_file( + stage: &Path, + target: &Path, + expected: &ProjectFileIdentity, +) -> Result<(), String> { + replace_existing_project_file_with_validation( + stage, + target, + expected, + PublicationValidation::IdentityOnly, + |_, _, _| Ok(()), + ) +} + +#[cfg(windows)] +pub(crate) fn replace_existing_project_file_for_migration( + stage: &Path, + target: &Path, + expected: &ProjectFileIdentity, + receipt: &ProjectMigrationReceipt, +) -> Result<(), String> { + replace_existing_project_file_with_validation( + stage, + target, + expected, + PublicationValidation::Migration { + receipt: receipt.clone(), + }, + |displaced, published, candidate| { + verify_migration_predecessor(displaced, expected, receipt)?; + verify_migration_candidate(published, candidate, receipt) + }, + ) +} + +#[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(not(any(target_os = "linux", target_os = "macos", windows)))] +pub(crate) fn replace_existing_project_file_for_migration( + stage: &Path, + _target: &Path, + _expected: &ProjectFileIdentity, + _receipt: &ProjectMigrationReceipt, +) -> Result<(), String> { + remove_stage(stage); + Err(PROJECT_PUBLISH_ERROR.to_string()) +} + +#[cfg(target_os = "macos")] +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_identity_and_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)] + let identity = { + if !same_file_identity(&before, &opened) || !same_file_identity(&opened, &after) { + return Err(PROJECT_READ_ERROR.to_string()); + } + project_file_identity_from_metadata(&opened) + }; + + #[cfg(windows)] + let identity = { + 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()); + } + opened_identity + }; + + #[cfg(not(any(unix, windows)))] + let identity: ProjectFileIdentity = 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()); + } + let content = String::from_utf8(bytes).map_err(|_| PROJECT_READ_ERROR.to_string())?; + Ok(ProjectFileReadSnapshot { content, identity }) +} + +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, +{ + read_project_file_with_identity_and_opener(target, open_file, max_bytes, too_large_error) + .map(|read| read.content) +} + +/// Reads one project and retains the native identity of the same bounded file handle. +/// +/// This is the Project Persistence predecessor-authority primitive for migration publication. Content +/// and identity are captured from one no-follow native handle after the existing before/opened/after +/// path-stability checks. A later pathname replacement therefore cannot silently become the expected +/// predecessor for a migration derived from these bytes. The returned identity is local filesystem +/// authority only; it is not content authenticity, a signature, or Resource Admission evidence. +pub(crate) fn read_project_file_with_identity( + target: &Path, +) -> Result { + read_project_file_with_identity_and_opener( + target, + open_project_file, + MAX_PROJECT_FILE_BYTES, + PROJECT_TOO_LARGE_ERROR, + ) +} + +/// Test-only String projection of the bounded identity-bearing project reader. +#[cfg(test)] +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. A newly created Unix stage starts owner-only (`0600`) before any bytes are written; an +/// existing target's read/write mode is then applied to that staged inode before sync, while executable +/// and special bits are never copied to project data. Windows keeps its native ACL creation semantics. +/// 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, flushes the exact published candidate +/// before retiring rollback material, and on rejection restores plus flushes the exact displaced file +/// that occupied the target at replacement time. Only a displaced file matching the captured expected +/// identity can authorize commit. 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`. Windows also flushes the exact hard-linked target after the temporary +/// stage alias is retired. For hard-link first saves, the stage alias is removed before the parent +/// directory durability boundary; if that sync fails, the complete target remains but hidden duplicate +/// project bytes are not intentionally retained. A newly created final directory entry is part of the +/// success contract: Unix fsyncs its parent, while Windows requires the identity-bound file flush or +/// `MOVEFILE_WRITE_THROUGH` before success is acknowledged. Existing-target replacement keeps durable +/// recovery material until commit or rollback authority is proven. These checks do not claim +/// descriptor-bound protection for a parent-chain swap or full-machine power-loss proof; packaged +/// interruption testing remains required. A durable adjacent journal repairs interrupted replacement +/// state the next time the same target is selected; global startup scanning and backup rotation remain +/// #962 work. +#[cfg(test)] +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) + }) +} + +#[cfg(test)] +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, + ) +} + +#[cfg(test)] +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 = create_private_file_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); + + #[cfg(windows)] + let staged_identity = project_file_identity(&stage)?; + + 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())?; + #[cfg(windows)] + flush_project_file_with_expected_identity(target, &staged_identity)?; + 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()); + } + } + } + + fs::remove_file(&stage).map_err(|_| PROJECT_PUBLISH_ERROR.to_string())?; + sync_parent(parent).map_err(|_| PROJECT_PUBLISH_ERROR.to_string())?; + #[cfg(windows)] + flush_project_file_with_expected_identity(target, &staged_identity)?; + 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, + super::PublicationValidation::IdentityOnly, + ) + .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, + super::PublicationValidation::IdentityOnly, + ) + .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 fixture 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 fixture 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, + super::PublicationValidation::IdentityOnly, + ) + .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", windows))] + #[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 displaced = if cfg!(windows) { + super::staging_path(&target).expect("Windows displaced path should be derivable") + } else { + stage.clone() + }; + 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, + &displaced, + &expected, + &candidate_identity, + super::PublicationValidation::IdentityOnly, + ) + .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"); + #[cfg(any(target_os = "linux", target_os = "macos"))] + super::rename_exchange(&stage, &target).expect("fixture should model interrupted exchange"); + #[cfg(windows)] + super::replace_file_with_backup(&target, &stage, &displaced) + .expect("fixture should model interrupted ReplaceFileW publication"); + + 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!(!displaced.exists(), "the displaced racer artifact should be consumed"); + 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, + super::PublicationValidation::IdentityOnly, + ) + .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_migration_owner() { + let main_source = include_str!("main.rs"); + let load_source = include_str!("project_load.rs"); + + assert!( + main_source.contains("project_load::load_project_document(&path)"), + "the Tauri load command must route through the migrate-on-load owner" + ); + assert!( + load_source.contains("project_persistence::read_project_file_with_identity(target)"), + "migrate-on-load must derive migration authority from the bounded identity-bearing read" + ); + 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..24c6572e5 --- /dev/null +++ b/apps/desktop/src-tauri/src/project_root.rs @@ -0,0 +1,212 @@ +use bandscope_desktop_core::is_valid_project_id; +use std::{ + fs, + io::ErrorKind, + 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() +} + +/// Return the exact canonical destination allowed for a standard macOS root alias. +#[cfg(target_os = "macos")] +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, + } +} + +/// Return whether one macOS root symlink is the exact root-owned system alias BandScope permits. +#[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_existing_project_directory(&target_metadata)) +} + +/// Keep the macOS alias exception unavailable on platforms that do not have those system aliases. +#[cfg(not(target_os = "macos"))] +fn metadata_is_trusted_macos_root_directory_alias(_path: &Path, _metadata: &fs::Metadata) -> bool { + false +} + +fn metadata_is_safe_project_directory_component(path: &Path, metadata: &fs::Metadata) -> bool { + metadata_is_safe_existing_project_directory(metadata) + || metadata_is_trusted_macos_root_directory_alias(path, metadata) +} + +/// Validate every existing lexical directory component that grants project-root authority. +fn existing_project_directory_chain_is_safe(path: &Path) -> bool { + path.ancestors() + .filter(|ancestor| !ancestor.as_os_str().is_empty()) + .all(|ancestor| { + fs::symlink_metadata(ancestor).is_ok_and(|metadata| { + metadata_is_safe_project_directory_component(ancestor, &metadata) + }) + }) +} + +/// Create one new app-owned Unix directory without exposing it through a permissive process umask. +#[cfg(unix)] +fn create_owned_directory(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::DirBuilderExt; + + let mut builder = fs::DirBuilder::new(); + builder.mode(0o700).create(path) +} + +/// Preserve native ACL inheritance on non-Unix platforms. +#[cfg(not(unix))] +fn create_owned_directory(path: &Path) -> std::io::Result<()> { + fs::create_dir(path) +} + +/// Create missing app-owned directory components one at a time without following a stable link. +/// +/// Security Notes: unlike `create_dir_all`, each already-existing lexical component is inspected +/// with `symlink_metadata` before a child component is created. A newly created component is +/// inspected again immediately and must be a real directory. Unix creation requests mode `0700`, +/// so an inherited permissive process umask cannot broaden a new BandScope-owned directory; an +/// existing directory keeps its deliberate mode. Windows keeps native ACL inheritance. This closes +/// stable symlink/junction redirection during provisioning while preserving the narrow root-owned +/// macOS aliases accepted by reopen. It does not claim descriptor-bound protection against an +/// ancestor replaced between the metadata check and the following filesystem operation. +fn provision_directory_chain(path: &Path) -> Result<(), String> { + let mut ancestors: Vec<&Path> = path + .ancestors() + .filter(|ancestor| !ancestor.as_os_str().is_empty()) + .collect(); + ancestors.reverse(); + + for ancestor in ancestors { + match fs::symlink_metadata(ancestor) { + Ok(metadata) => { + if !metadata_is_safe_project_directory_component(ancestor, &metadata) { + return Err(PROJECT_ROOT_ERROR.to_string()); + } + } + Err(error) if error.kind() == ErrorKind::NotFound => { + create_owned_directory(ancestor).map_err(|_| PROJECT_ROOT_ERROR.to_string())?; + let metadata = fs::symlink_metadata(ancestor) + .map_err(|_| PROJECT_ROOT_ERROR.to_string())?; + if !metadata_is_safe_project_directory_component(ancestor, &metadata) { + return Err(PROJECT_ROOT_ERROR.to_string()); + } + } + Err(_) => return Err(PROJECT_ROOT_ERROR.to_string()), + } + } + + Ok(()) +} + +/// Provision or reopen one app-owned workspace directory without following linked components. +/// +/// Security Notes: cache, temp, and score workspaces are reusable across operations, so unlike a +/// newly minted project root this function permits an already-existing final directory. Every +/// lexical component must still be a real directory rather than a Unix symlink or Windows reparse +/// point, with only the narrow root-owned macOS system aliases admitted. Missing Unix components +/// are created owner-only (`0700`) and revalidated immediately; existing directory modes are not +/// rewritten. This prevents stable cache/temp/scores redirection through `create_dir_all` and +/// prevents a permissive inherited umask from making a newly created workspace group/world +/// accessible. It does not claim descriptor-bound protection against a component replaced after +/// validation or a Windows ACL policy beyond native inheritance. +pub(crate) fn ensure_owned_directory(path: &Path) -> Result { + provision_directory_chain(path)?; + if !existing_project_directory_chain_is_safe(path) { + return Err(PROJECT_ROOT_ERROR.to_string()); + } + Ok(path.to_path_buf()) +} + +/// Provision one new app-local project directory without following linked ancestors. +/// +/// Security Notes: `project_id` is validated before joining. Missing app-local base components are +/// created one lexical directory at a time and every existing/new component must be a real +/// directory rather than a Unix symlink or Windows reparse point. New Unix components, including +/// the final project root, request owner-only mode `0700`; existing directory modes are preserved. +/// The final project directory uses single-directory create semantics and therefore refuses to reuse +/// an already-existing target. This creation-side authority mirrors `resolve_existing_project_root` +/// instead of letting a raw `create_dir_all` follow a stable link into another filesystem subtree. +pub(crate) fn provision_new_project_root( + base_root: &Path, + project_id: &str, +) -> Result { + if !is_valid_project_id(project_id) { + return Err(PROJECT_ROOT_ERROR.to_string()); + } + + provision_directory_chain(base_root)?; + if !existing_project_directory_chain_is_safe(base_root) { + return Err(PROJECT_ROOT_ERROR.to_string()); + } + + let project_root = base_root.join(project_id); + create_owned_directory(&project_root).map_err(|_| PROJECT_ROOT_ERROR.to_string())?; + if !existing_project_directory_chain_is_safe(&project_root) { + return Err(PROJECT_ROOT_ERROR.to_string()); + } + + Ok(project_root) +} + +/// Resolve one already-provisioned app-local project directory without creating it. +/// +/// Security Notes: `project_id` is validated before joining. The app-local base, +/// its lexical ancestor chain, and the final project directory must already exist +/// as real directories rather than symlinks or Windows reparse points. macOS keeps +/// only the root-owned `/etc`, `/tmp`, and `/var` aliases whose canonical targets +/// are the exact system `/private` directories. Rejecting linked ancestors 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. These checks close stable link redirection; they do +/// not claim descriptor-bound protection against an ancestor replaced after the +/// check. +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()); + } + + if !existing_project_directory_chain_is_safe(base_root) { + return Err(PROJECT_ROOT_ERROR.to_string()); + } + + let project_root = base_root.join(project_id); + if !existing_project_directory_chain_is_safe(&project_root) { + 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..d6aa827b0 --- /dev/null +++ b/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs @@ -0,0 +1,177 @@ +#[path = "../src/analysis_source.rs"] +mod analysis_source; + +#[cfg(unix)] +use analysis_source::create_private_local_audio_stage; +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"); +} + +#[cfg(unix)] +#[test] +fn local_audio_stage_remains_owner_private_with_permissive_umask() { + use std::{ + io::Write, + os::raw::c_uint, + os::unix::fs::PermissionsExt, + process::Command, + }; + + const CHILD_ENV: &str = "BANDSCOPE_LOCAL_AUDIO_PRIVATE_STAGE_CHILD"; + const TEST_NAME: &str = + "local_audio_stage_remains_owner_private_with_permissive_umask"; + + if std::env::var_os(CHILD_ENV).is_none() { + let output = Command::new(std::env::current_exe().expect("test executable should resolve")) + .args(["--exact", TEST_NAME, "--nocapture"]) + .env(CHILD_ENV, "1") + .output() + .expect("isolated permissive-umask child should launch"); + assert!( + output.status.success(), + "permissive-umask local-audio child failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + + extern "C" { + fn umask(mask: c_uint) -> c_uint; + } + + unsafe { + umask(0); + } + + let root = unique_project_root(); + fs::create_dir_all(&root).expect("test project root should be created"); + let stage = root.join(".source-private.stage"); + let mut staged = create_private_local_audio_stage(&stage) + .expect("production local-audio stage should be created"); + staged + .write_all(WAV_BYTES) + .expect("fixture bytes should be written through the production stage handle"); + staged.sync_all().expect("fixture stage should synchronize"); + drop(staged); + + let mode = fs::metadata(&stage) + .expect("local-audio stage should be readable") + .permissions() + .mode() + & 0o777; + assert_eq!( + mode, 0o600, + "app-owned local-audio stage must remain owner-only under a permissive umask" + ); + + fs::remove_dir_all(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_private_stage_contract.rs b/apps/desktop/src-tauri/tests/local_audio_private_stage_contract.rs new file mode 100644 index 000000000..b5397475a --- /dev/null +++ b/apps/desktop/src-tauri/tests/local_audio_private_stage_contract.rs @@ -0,0 +1,22 @@ +const MAIN_SOURCE: &str = include_str!("../src/main.rs"); + +#[test] +fn production_local_audio_materializer_uses_private_stage_creation_boundary() { + let materializer_start = MAIN_SOURCE + .find("fn materialize_local_audio_source(") + .expect("desktop materializer must remain present"); + let materializer_tail = &MAIN_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("create_private_local_audio_stage(&stage)"), + "production local-audio materialization must create its private stage through the tested native boundary" + ); + assert!( + !materializer.contains(".create_new(true)"), + "the production materializer must not bypass the private stage boundary with ambient-mode file creation" + ); +} 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..8ffc42666 --- /dev/null +++ b/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs @@ -0,0 +1,168 @@ +#[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_uses_project_persistence_no_replace_durability_owner() { + 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( + "project_persistence::publish_synced_file_noreplace(&stage, &destination)" + ), + "production publication must delegate no-replace and directory durability to Project Persistence" + ); + assert!( + !materializer.contains("std::fs::hard_link(&stage, &destination)"), + "Resource Admission must not own a second hard-link publication path" + ); + 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" + ); + + let publication_start = materializer + .find("project_persistence::publish_synced_file_noreplace(&stage, &destination)") + .expect("Project Persistence publication call must remain present"); + let publication_tail = &materializer[publication_start..]; + let verification_start = publication_tail + .find("let published_path_metadata") + .expect("published-source verification must follow publication"); + let publication_failure_boundary = &publication_tail[..verification_start]; + assert!( + !publication_failure_boundary.contains("remove_file(&stage)"), + "a no-replace publication rejection must preserve the candidate stage promised by the Project Persistence owner; the caller must not erase it before recovery/diagnostics can inspect it" + ); +} + +#[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" + ); +} \ No newline at end of file diff --git a/apps/desktop/src-tauri/tests/project_persistence.rs b/apps/desktop/src-tauri/tests/project_persistence.rs new file mode 100644 index 000000000..c7028d305 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence.rs @@ -0,0 +1,52 @@ +#![cfg_attr(feature = "persistence_warning_gate", deny(warnings))] + +//! Single-compile native integration harness for Project Persistence. +//! +//! The production persistence owner is included exactly once so integration cases can exercise its +//! crate-private capability boundary without recompiling the entire source file in every test crate. + +mod project_persistence { + include!("../src/project_persistence.rs"); +} + +#[path = "../src/project_load.rs"] +mod project_load; +#[path = "../src/project_root.rs"] +mod project_root; + +#[path = "project_persistence_atomic_publication.case"] +mod atomic_publication; +#[path = "project_persistence_journal_path_boundary.case"] +mod journal_path_boundary; +#[path = "project_persistence_linked_ancestor.case"] +mod linked_ancestor; +#[cfg(target_os = "macos")] +#[path = "project_persistence_macos_root_alias.case"] +mod macos_root_alias; +#[path = "project_persistence_migration_content_cas.case"] +mod migration_content_cas; +#[path = "project_persistence_open_authority.case"] +mod open_authority; +#[path = "project_persistence_overwrite.case"] +mod overwrite; +#[path = "project_persistence_parent_swap.case"] +mod parent_swap; +#[path = "project_persistence_parent_symlink.case"] +mod parent_symlink; +#[path = "project_persistence_permissions.case"] +mod permissions; +#[path = "project_persistence_process_kill.case"] +mod process_kill; +#[path = "project_persistence_project_root_authority.case"] +mod project_root_authority; +#[path = "project_persistence_published_recovery_content_cas.case"] +mod published_recovery_content_cas; +#[path = "project_persistence_read_identity.case"] +mod read_identity; +#[path = "project_persistence_recovery_cleanup.case"] +mod recovery_cleanup; +#[path = "project_persistence_rollback_identity.case"] +mod rollback_identity; +#[cfg(windows)] +#[path = "project_persistence_windows_identity.case"] +mod windows_identity; diff --git a/apps/desktop/src-tauri/tests/project_persistence_atomic_publication.case b/apps/desktop/src-tauri/tests/project_persistence_atomic_publication.case new file mode 100644 index 000000000..26e7508e9 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_atomic_publication.case @@ -0,0 +1,349 @@ +use crate::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_discards_temporary_alias_before_parent_durability_boundary() { + 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!( + stage_paths(&root).is_empty(), + "the temporary hard-link alias must be removed before the directory durability boundary so a failed sync cannot retain hidden project bytes" + ); + 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_retires_hard_link_alias_before_parent_sync() { + 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 + ); + assert!( + stage_paths(&root).is_empty(), + "temporary hard-link aliases must be retired before the parent directory durability boundary" + ); + 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"); +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[test] +fn synced_source_publication_moves_the_owned_stage_only_after_durable_no_replace_publish() { + let root = test_dir("synced-source-success"); + let stage = root.join(".source-candidate.stage"); + let target = root.join("source.wav"); + let content = b"RIFF-durable-source"; + fs::write(&stage, content).expect("source stage should be written"); + fs::OpenOptions::new() + .read(true) + .write(true) + .open(&stage) + .expect("source stage should reopen with write authority for durable flush") + .sync_all() + .expect("source stage bytes should be durable before publication"); + let sync_calls = Cell::new(0usize); + + project_persistence::publish_synced_file_noreplace_with_directory_sync( + &stage, + &target, + |parent| { + assert_eq!(parent, root.as_path()); + assert_eq!( + fs::read(&target).expect("target must exist before directory sync"), + content + ); + sync_calls.set(sync_calls.get() + 1); + Ok(()) + }, + ) + .expect("synced source publication should succeed after directory durability"); + + assert_eq!(sync_calls.get(), 1); + assert_eq!(fs::read(&target).expect("published source should be readable"), content); + assert!(!stage.exists(), "native no-replace publication consumes the stage name"); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[test] +fn synced_source_publication_public_owner_publishes_a_durable_stage() { + let root = test_dir("synced-source-public-owner"); + let stage = root.join(".source-public-owner.stage"); + let target = root.join("source.wav"); + let content = b"RIFF-public-owner-source"; + fs::write(&stage, content).expect("source stage should be written"); + fs::OpenOptions::new() + .read(true) + .write(true) + .open(&stage) + .expect("source stage should reopen with write authority for durable flush") + .sync_all() + .expect("source stage bytes should be durable before publication"); + + project_persistence::publish_synced_file_noreplace(&stage, &target) + .expect("the production no-replace owner should publish the durable source stage"); + + assert_eq!(fs::read(&target).expect("published source should be readable"), content); + assert!(!stage.exists(), "successful publication consumes the owned stage name"); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[test] +fn synced_source_publication_preserves_a_competing_destination() { + let root = test_dir("synced-source-conflict"); + let stage = root.join(".source-candidate.stage"); + let target = root.join("source.wav"); + let candidate = b"candidate-source"; + let competing = b"competing-source"; + fs::write(&stage, candidate).expect("source stage should be written"); + fs::write(&target, competing).expect("competing source should be written"); + let sync_calls = Cell::new(0usize); + + let error = project_persistence::publish_synced_file_noreplace_with_directory_sync( + &stage, + &target, + |_parent| { + sync_calls.set(sync_calls.get() + 1); + Ok(()) + }, + ) + .expect_err("no-replace source publication must reject an existing target"); + + assert_eq!(error, "Project file already exists. Choose a new file name."); + assert_eq!(sync_calls.get(), 0); + assert_eq!(fs::read(&target).expect("competing source must remain"), competing); + assert_eq!(fs::read(&stage).expect("candidate stage must remain"), candidate); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[test] +fn synced_source_publication_never_reports_success_when_directory_durability_fails() { + let root = test_dir("synced-source-sync-failure"); + let stage = root.join(".source-candidate.stage"); + let target = root.join("source.wav"); + let content = b"complete-source-before-sync-failure"; + fs::write(&stage, content).expect("source stage should be written"); + + let error = project_persistence::publish_synced_file_noreplace_with_directory_sync( + &stage, + &target, + |parent| { + assert_eq!(parent, root.as_path()); + assert_eq!( + fs::read(&target).expect("complete target must be visible before sync"), + content + ); + Err(io::Error::new( + io::ErrorKind::Other, + "injected source-directory sync failure", + )) + }, + ) + .expect_err("publication authority must not be returned before directory durability"); + + assert_eq!(error, "Could not publish the project safely."); + assert_eq!( + fs::read(&target).expect("complete target must not be deleted after sync failure"), + content + ); + assert!(!stage.exists(), "native rename consumes the stage before sync failure"); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[cfg(windows)] +#[test] +fn existing_project_replacement_rolls_back_when_candidate_flush_fails() { + let root = test_dir("windows-replace-flush-failure"); + let target = root.join("setlist.bscope"); + let stage = root.join(".replacement.stage"); + let known_good = br#"{"id":"known-good"}"#; + let candidate = br#"{"id":"candidate"}"#; + fs::write(&target, known_good).expect("known-good target should be written"); + fs::write(&stage, candidate).expect("candidate stage should be written"); + fs::OpenOptions::new() + .read(true) + .write(true) + .open(&stage) + .expect("candidate stage should reopen with write authority") + .sync_all() + .expect("candidate bytes should be durable before replacement"); + let expected = project_persistence::project_file_identity(&target) + .expect("known-good target identity should be capturable"); + let flush_calls = Cell::new(0usize); + + let error = project_persistence::replace_existing_project_file_with_validation_and_flush( + &stage, + &target, + &expected, + project_persistence::PublicationValidation::IdentityOnly, + |_, _, _| Ok(()), + |flushed, _identity| { + let call = flush_calls.get() + 1; + flush_calls.set(call); + assert_eq!(flushed, target.as_path()); + if call == 1 { + assert_eq!( + fs::read(&target).expect("candidate should be published before its flush"), + candidate + ); + Err("injected candidate flush failure".to_string()) + } else { + assert_eq!( + fs::read(&target).expect("known-good target should be restored before cleanup"), + known_good + ); + Ok(()) + } + }, + ) + .expect_err("candidate flush failure must fail closed after restoring the known-good target"); + + assert_eq!(error, "Could not publish the project safely."); + assert_eq!(flush_calls.get(), 2, "candidate and restored target must both reach the durability boundary"); + assert_eq!(fs::read(&target).expect("known-good target should remain"), known_good); + assert!(!stage.exists(), "candidate rollback artifact should be removed only after restored-target durability succeeds"); + assert!(stage_paths(&root).is_empty(), "generated rollback artifacts should be retired after restored-target durability succeeds"); + assert!( + fs::read_dir(&root) + .expect("test directory should be readable") + .filter_map(Result::ok) + .filter_map(|entry| entry.file_name().into_string().ok()) + .all(|name| !name.starts_with(".bandscope-recovery-")), + "recovery journal should be retired only after restored-target durability succeeds" + ); + fs::remove_dir_all(root).expect("test directory should be removable"); +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_journal_path_boundary.case b/apps/desktop/src-tauri/tests/project_persistence_journal_path_boundary.case new file mode 100644 index 000000000..0504748d6 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_journal_path_boundary.case @@ -0,0 +1,181 @@ +use crate::project_persistence; + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +use std::{ + fs::{self, OpenOptions}, + io::Write, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +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-journal-path-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).expect("test directory should be created"); + path +} + +#[cfg(unix)] +fn journal_name(path: &Path) -> Vec { + use std::os::unix::ffi::OsStrExt; + + path.file_name() + .expect("fixture path should have a file name") + .as_bytes() + .to_vec() +} + +#[cfg(windows)] +fn journal_name(path: &Path) -> Vec { + use std::os::windows::ffi::OsStrExt; + + path.file_name() + .expect("fixture path should have a file name") + .encode_wide() + .collect() +} + +#[cfg(unix)] +fn journal_name_units(value: &str) -> Vec { + value.as_bytes().to_vec() +} + +#[cfg(windows)] +fn journal_name_units(value: &str) -> Vec { + use std::os::windows::ffi::OsStrExt; + + std::ffi::OsStr::new(value).encode_wide().collect() +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +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") + )) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn write_prepared_identity_journal( + target: &Path, + stage: &Path, + expected: &project_persistence::ProjectFileIdentity, + candidate: &project_persistence::ProjectFileIdentity, +) -> PathBuf { + let journal = prepared_journal_path(target); + let record = serde_json::json!({ + "version": 2, + "target_name": journal_name(target), + "candidate_name": journal_name(stage), + "displaced_name": journal_name(stage), + "expected": expected, + "candidate": candidate, + "validation": { "kind": "identity_only" }, + }); + let bytes = serde_json::to_vec(&record).expect("prepared journal fixture should serialize"); + let mut file = fs::File::create_new(&journal).expect("prepared journal fixture should be created"); + file.write_all(&bytes) + .expect("prepared journal fixture should be written"); + file.sync_all() + .expect("prepared journal fixture should be synchronized"); + journal +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn rewrite_durable_journal_candidate( + journal: &Path, + candidate_name: &str, + candidate_identity: serde_json::Value, +) { + let mut document: serde_json::Value = serde_json::from_slice( + &fs::read(journal).expect("durable recovery journal should be readable"), + ) + .expect("recovery journal should remain valid JSON"); + document["candidate_name"] = serde_json::to_value(journal_name_units(candidate_name)) + .expect("journal path units should serialize"); + document["candidate"] = candidate_identity; + let bytes = serde_json::to_vec(&document).expect("tampered recovery journal should serialize"); + let mut file = OpenOptions::new() + .write(true) + .truncate(true) + .open(journal) + .expect("recovery journal should be writable for the hostile-local-state fixture"); + file.write_all(&bytes) + .expect("hostile recovery journal bytes should be written"); + file.sync_all() + .expect("hostile recovery journal fixture should be durable"); +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[test] +fn recovery_rejects_parent_traversal_in_serialized_candidate_name() { + let root = test_dir("candidate-parent-traversal"); + let target = root.join("setlist.bscope"); + let stage = root.join(format!( + ".bandscope-stage-{}.stage", + uuid::Uuid::new_v4() + )); + let outside_name = format!( + ".bandscope-stage-{}.stage", + uuid::Uuid::new_v4() + ); + let outside = root + .parent() + .expect("test directory should have a parent") + .join(&outside_name); + let original = br#"{\"id\":\"known-good\"}"#; + let candidate = br#"{\"id\":\"candidate\"}"#; + let outside_bytes = br#"{\"id\":\"outside-sentinel\"}"#; + + fs::write(&target, original).expect("known-good project should be written"); + fs::write(&stage, candidate).expect("candidate stage should be written"); + fs::write(&outside, outside_bytes).expect("outside sentinel should be written"); + + let expected = project_persistence::project_file_identity(&target) + .expect("known-good project identity should be capturable"); + let candidate_identity = project_persistence::project_file_identity(&stage) + .expect("candidate stage identity should be capturable"); + let journal = write_prepared_identity_journal(&target, &stage, &expected, &candidate_identity); + + fs::remove_file(&stage).expect("legitimate candidate should be absent for the recovery fixture"); + let outside_identity = project_persistence::project_file_identity(&outside) + .expect("outside sentinel identity should be capturable"); + let traversal = format!("..{}{}", std::path::MAIN_SEPARATOR, outside_name); + rewrite_durable_journal_candidate( + &journal, + &traversal, + serde_json::to_value(outside_identity).expect("outside identity should serialize"), + ); + + let result = project_persistence::recover_project_publication(&target); + + assert!( + result.is_err(), + "a recovery journal must not resolve candidate names outside the selected project directory" + ); + assert_eq!( + fs::read(&outside).expect("outside sentinel should remain readable"), + outside_bytes, + "hostile recovery metadata must not delete or mutate an external generated-looking file" + ); + assert_eq!( + fs::read(&target).expect("known-good project should remain readable"), + original, + "hostile recovery metadata must not change the selected project" + ); + + fs::remove_file(&outside).expect("outside sentinel should be removable"); + fs::remove_file(&journal).expect("hostile journal should be removable after the assertion"); + fs::remove_dir_all(root).expect("test directory should be removable"); +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_linked_ancestor.case b/apps/desktop/src-tauri/tests/project_persistence_linked_ancestor.case new file mode 100644 index 000000000..6d2a30dd5 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_linked_ancestor.case @@ -0,0 +1,33 @@ +#[cfg(unix)] +use crate::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.case b/apps/desktop/src-tauri/tests/project_persistence_macos_root_alias.case new file mode 100644 index 000000000..2f4499a7e --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_macos_root_alias.case @@ -0,0 +1,35 @@ +use crate::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_migration_content_cas.case b/apps/desktop/src-tauri/tests/project_persistence_migration_content_cas.case new file mode 100644 index 000000000..ef44ecdc3 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_migration_content_cas.case @@ -0,0 +1,175 @@ +use crate::{project_load, project_persistence}; + +use bandscope_desktop_core::prepare_project_migration; +use std::{fs, io::Write}; + +fn test_root(label: &str) -> std::path::PathBuf { + let root = std::env::temp_dir().join(format!( + "bandscope-migration-content-cas-{label}-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&root).expect("fixture directory should be created"); + root +} + +fn synchronized_stage(root: &std::path::Path, content: &[u8]) -> std::path::PathBuf { + let stage = root.join(format!( + ".bandscope-stage-{}.stage", + uuid::Uuid::new_v4() + )); + let mut file = fs::File::create_new(&stage).expect("candidate stage should be created"); + file.write_all(content) + .expect("candidate bytes should be written"); + file.sync_all().expect("candidate stage should be synchronized"); + stage +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[test] +fn migration_publication_rolls_back_an_in_place_predecessor_change() { + let root = test_root("predecessor-change"); + let target = root.join("setlist.bscope"); + let original = include_str!("../../core/testdata/project-v2.json"); + fs::write(&target, original).expect("historical fixture should be written"); + + let snapshot = project_persistence::read_project_file_with_identity(&target) + .expect("historical project should be read with native identity"); + let prepared = prepare_project_migration(snapshot.content()) + .expect("historical project should prepare a canonical migration"); + + let changed_but_parse_equivalent = format!("{original}\n"); + let mut same_file = fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(&target) + .expect("the predecessor object should remain openable"); + same_file + .write_all(changed_but_parse_equivalent.as_bytes()) + .expect("the predecessor bytes should change in place"); + same_file + .sync_all() + .expect("the in-place predecessor change should be synchronized"); + drop(same_file); + + let current_identity = project_persistence::project_file_identity(&target) + .expect("the modified predecessor should retain native identity"); + assert_eq!(¤t_identity, snapshot.identity()); + + let stage = synchronized_stage(&root, prepared.canonical_content().as_bytes()); + project_persistence::replace_existing_project_file_for_migration( + &stage, + &target, + snapshot.identity(), + prepared.receipt(), + ) + .expect_err("digest mismatch must roll the migration publication back"); + + assert_eq!( + fs::read(&target).expect("the changed predecessor should remain readable"), + changed_but_parse_equivalent.as_bytes() + ); + assert!(!stage.exists(), "the failed candidate stage 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 migration_publication_commits_only_exact_receipt_bound_bytes() { + let root = test_root("exact-match"); + let target = root.join("setlist.bscope"); + let original = include_str!("../../core/testdata/project-v2.json"); + fs::write(&target, original).expect("historical fixture should be written"); + + let snapshot = project_persistence::read_project_file_with_identity(&target) + .expect("historical project should be read with native identity"); + let prepared = prepare_project_migration(snapshot.content()) + .expect("historical project should prepare a canonical migration"); + let stage = synchronized_stage(&root, prepared.canonical_content().as_bytes()); + + project_persistence::replace_existing_project_file_for_migration( + &stage, + &target, + snapshot.identity(), + prepared.receipt(), + ) + .expect("exact predecessor and candidate bytes should publish"); + + assert_eq!( + fs::read(&target).expect("the migrated target should be readable"), + prepared.canonical_content().as_bytes() + ); + assert!(!stage.exists(), "successful publication should retire rollback material"); + fs::remove_dir_all(root).expect("fixture directory should be removable"); +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[test] +fn project_load_migrates_a_historical_fixture_through_receipt_bound_publication() { + let root = test_root("load-migrates"); + let target = root.join("setlist.bscope"); + let original = include_str!("../../core/testdata/project-v2.json"); + fs::write(&target, original).expect("historical fixture should be written"); + let expected = prepare_project_migration(original) + .expect("historical fixture should prepare a canonical migration"); + + let loaded = project_load::load_project_document(&target) + .expect("loading a historical project should publish its validated migration"); + + assert_eq!( + fs::read(&target).expect("the migrated project should remain readable"), + expected.canonical_content().as_bytes() + ); + assert_eq!( + serde_json::to_value(&loaded).expect("loaded document should serialize"), + serde_json::to_value(expected.document()).expect("prepared document should serialize") + ); + fs::remove_dir_all(root).expect("fixture directory should be removable"); +} + +#[cfg(unix)] +#[test] +fn project_load_preserves_existing_data_permissions_during_migration() { + use std::os::unix::fs::PermissionsExt; + + let root = test_root("load-permissions"); + let target = root.join("setlist.bscope"); + let original = include_str!("../../core/testdata/project-v2.json"); + fs::write(&target, original).expect("historical fixture should be written"); + fs::set_permissions(&target, fs::Permissions::from_mode(0o640)) + .expect("fixture data permissions should be set"); + + project_load::load_project_document(&target) + .expect("migration should preserve existing project data permissions"); + + let mode = fs::metadata(&target) + .expect("migrated project metadata should be readable") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o640); + fs::remove_dir_all(root).expect("fixture directory should be removable"); +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[test] +fn project_load_does_not_rewrite_a_current_v3_project() { + let root = test_root("load-current-noop"); + let target = root.join("setlist.bscope"); + let historical = include_str!("../../core/testdata/project-v2.json"); + let prepared = prepare_project_migration(historical) + .expect("historical fixture should prepare a canonical migration"); + let current_with_incidental_whitespace = format!("{}\n", prepared.canonical_content()); + fs::write(&target, ¤t_with_incidental_whitespace) + .expect("current project should be written"); + + project_load::load_project_document(&target) + .expect("current project should load without a migration publication"); + + assert_eq!( + fs::read(&target).expect("current project should remain readable"), + current_with_incidental_whitespace.as_bytes(), + "format-v3 load must not rewrite bytes when no migration is required" + ); + fs::remove_dir_all(root).expect("fixture directory should be removable"); +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_open_authority.case b/apps/desktop/src-tauri/tests/project_persistence_open_authority.case new file mode 100644 index 000000000..8cb3b4caa --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_open_authority.case @@ -0,0 +1,246 @@ +use crate::{project_persistence, 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_path = root.join(project_id); + fs::create_dir_all(&project_root_path).expect("project root should be created"); + let source_bytes = b"RIFF-safe-reopen-fixture"; + fs::write(project_root_path.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_path, + &reference, + project_persistence::open_project_file, + ) + .expect("the exact regular app-owned source should regain native identity"); + + assert_eq!(reopened.source_path, project_root_path.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_path = root.join(project_id); + fs::create_dir_all(&project_root_path).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_path, + &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_path = root.join(project_id); + fs::create_dir_all(&project_root_path).expect("project root should be created"); + let external_bytes = b"outside-project"; + let external = root.join("external.wav"); + let source_path = project_root_path.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_path, + &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_path = base_root.join(project_id); + fs::create_dir(&project_root_path).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_path); + 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_path = 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_path.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.case b/apps/desktop/src-tauri/tests/project_persistence_overwrite.case new file mode 100644 index 000000000..d52fbf86f --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_overwrite.case @@ -0,0 +1,250 @@ +use crate::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_swap.case b/apps/desktop/src-tauri/tests/project_persistence_parent_swap.case new file mode 100644 index 000000000..566b2c7f9 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_parent_swap.case @@ -0,0 +1,90 @@ +#[cfg(unix)] +mod unix_parent_swap { + use crate::project_persistence; + use std::{ + fs::{self, File}, + os::unix::fs::symlink, + 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-parent-swap-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).expect("test directory should be created"); + path + } + + fn generated_stage(parent: &Path) -> PathBuf { + fs::read_dir(parent) + .expect("project directory should be readable") + .filter_map(Result::ok) + .find_map(|entry| { + let name = entry.file_name(); + let name_text = name.to_string_lossy(); + (name_text.starts_with(".bandscope-stage-") && name_text.ends_with(".stage")) + .then(|| entry.path()) + }) + .expect("a generated project stage should remain inspectable") + } + + #[test] + fn first_save_does_not_report_success_after_parent_swap_changes_candidate_identity() { + let root = test_dir("first-save"); + let selected_parent = root.join("projects"); + let parked_parent = root.join("parked-projects"); + let redirected_parent = root.join("redirected-projects"); + fs::create_dir(&selected_parent).expect("selected parent should be created"); + fs::create_dir(&redirected_parent).expect("redirected parent should be created"); + + let target = selected_parent.join("setlist.bscope"); + let candidate = br#"{"id":"candidate"}"#; + let redirected = br#"{"id":"redirected"}"#; + + let result = project_persistence::publish_new_project_file_with_linker_and_directory_sync( + &target, + candidate, + |stage, destination| { + let stage_name = stage + .file_name() + .expect("generated stage should have a file name") + .to_owned(); + fs::rename(&selected_parent, &parked_parent)?; + symlink(&redirected_parent, &selected_parent)?; + fs::write(redirected_parent.join(stage_name), redirected)?; + fs::hard_link(stage, destination) + }, + |parent| File::open(parent)?.sync_all(), + ); + + assert!( + result.is_err(), + "publication must not report success when the selected parent is replaced and the published inode is not the staged candidate" + ); + assert_eq!( + fs::read(generated_stage(&parked_parent)).expect("original staged bytes should remain readable"), + candidate, + "the original staged candidate must not be confused with the redirected file" + ); + assert_eq!( + fs::read(generated_stage(&redirected_parent)) + .expect("foreign redirected stage should remain inspectable"), + redirected, + "fail-closed publication must not delete a generated-looking file from a parent that replaced the authorized directory" + ); + assert_eq!( + fs::read(redirected_parent.join("setlist.bscope")) + .expect("redirected fixture target should remain inspectable"), + redirected, + "the redirected inode demonstrates why publication authority must remain bound to the staged candidate" + ); + + fs::remove_dir_all(root).expect("test directory should be removable"); + } +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.case b/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.case new file mode 100644 index 000000000..76af88ee0 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.case @@ -0,0 +1,149 @@ +#[cfg(unix)] +use crate::project_persistence; +use crate::project_root; + +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 +} + +#[test] +fn real_existing_project_root_remains_authorized() { + use std::fs; + + let root = fixture_root("project-root-real-chain"); + let base = root.join("app-local"); + let project_id = "project-1-1"; + let expected = base.join(project_id); + fs::create_dir_all(&expected).expect("real app-local project directory should be created"); + + let resolved = project_root::resolve_existing_project_root(&base, project_id) + .expect("a real existing project-root chain should remain authorized"); + assert_eq!(resolved, expected); + + fs::remove_dir_all(root).expect("test fixture should be removable"); +} + +#[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"); +} + +#[cfg(unix)] +#[test] +fn linked_ancestor_cannot_authorize_existing_project_root() { + use std::{fs, os::unix::fs::symlink}; + + let root = fixture_root("project-root-ancestor-symlink"); + let real_ancestor = root.join("real-ancestor"); + let real_base = real_ancestor.join("app-local"); + let linked_ancestor = root.join("linked-ancestor"); + let selected_base = linked_ancestor.join("app-local"); + let project_id = "project-1-1"; + + fs::create_dir_all(real_base.join(project_id)) + .expect("real app-local project directory should be created"); + symlink(&real_ancestor, &linked_ancestor).expect("linked ancestor should be created"); + + assert!( + project_root::resolve_existing_project_root(&selected_base, project_id).is_err(), + "a linked ancestor must not redirect existing project-root authority" + ); + + fs::remove_dir_all(root).expect("test fixture should be removable"); +} + +#[cfg(windows)] +#[test] +fn reparse_ancestor_cannot_authorize_existing_project_root() { + use std::{fs, process::Command}; + + let root = fixture_root("project-root-ancestor-reparse"); + let real_ancestor = root.join("real-ancestor"); + let real_base = real_ancestor.join("app-local"); + let linked_ancestor = root.join("linked-ancestor"); + let selected_base = linked_ancestor.join("app-local"); + let project_id = "project-1-1"; + + fs::create_dir_all(real_base.join(project_id)) + .expect("real app-local project directory should be created"); + let junction = Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&linked_ancestor) + .arg(&real_ancestor) + .status() + .expect("junction command should start"); + assert!(junction.success(), "junction fixture should be created"); + + assert!( + project_root::resolve_existing_project_root(&selected_base, project_id).is_err(), + "a reparse ancestor must not redirect existing project-root authority" + ); + + fs::remove_dir(&linked_ancestor).expect("junction should be removable"); + fs::remove_dir_all(root).expect("test fixture should be removable"); +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_permissions.case b/apps/desktop/src-tauri/tests/project_persistence_permissions.case new file mode 100644 index 000000000..13e88c40f --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_permissions.case @@ -0,0 +1,273 @@ +#[cfg(unix)] +use crate::{project_persistence, project_root}; + +#[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"); +} + +#[cfg(unix)] +#[test] +fn new_project_and_recovery_journal_remain_owner_private_with_permissive_umask() { + use std::{ + fs, + os::raw::c_uint, + os::unix::fs::PermissionsExt, + process::Command, + time::{SystemTime, UNIX_EPOCH}, + }; + + const CHILD_ENV: &str = "BANDSCOPE_PROJECT_PRIVATE_MODE_CHILD"; + const TEST_NAME: &str = + "permissions::new_project_and_recovery_journal_remain_owner_private_with_permissive_umask"; + + if std::env::var_os(CHILD_ENV).is_none() { + let output = Command::new(std::env::current_exe().expect("test executable should resolve")) + .args(["--exact", TEST_NAME, "--nocapture"]) + .env(CHILD_ENV, "1") + .output() + .expect("isolated permissive-umask child should launch"); + assert!( + output.status.success(), + "permissive-umask child failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + + extern "C" { + fn umask(mask: c_uint) -> c_uint; + } + + unsafe { + umask(0); + } + + 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-private-mode-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&root).expect("test directory should be created"); + + let new_target = root.join("new-private.bscope"); + project_persistence::publish_new_project_file(&new_target, br#"{\"id\":\"private-new\"}"#) + .expect("new project should publish under a permissive process umask"); + let new_mode = fs::metadata(&new_target) + .expect("new project should be readable") + .permissions() + .mode() + & 0o777; + assert_eq!( + new_mode, 0o600, + "new durable project data must default to owner-only permissions" + ); + + let shared_target = root.join("shared-existing.bscope"); + fs::write(&shared_target, br#"{\"id\":\"shared-old\"}"#) + .expect("shared predecessor should be written"); + fs::set_permissions(&shared_target, fs::Permissions::from_mode(0o640)) + .expect("shared predecessor mode should be explicit"); + project_persistence::publish_new_project_file( + &shared_target, + br#"{\"id\":\"shared-new\"}"#, + ) + .expect("existing shared project should retain its deliberate read/write mode"); + let shared_mode = fs::metadata(&shared_target) + .expect("shared replacement should be readable") + .permissions() + .mode() + & 0o777; + assert_eq!( + shared_mode, 0o640, + "private staging must not silently revoke an existing project's deliberate sharing mode" + ); + + // Force the production replacement state machine to stop after it has durably created the + // prepared journal. A pre-existing published marker makes journal promotion fail closed, leaving + // the real prepared journal available for permission inspection without a test-only writer seam. + let existing_target = root.join("existing.bscope"); + fs::write(&existing_target, br#"{\"id\":\"known-good\"}"#) + .expect("known-good project should be written"); + let journal_key = project_persistence::journal_target_key(&existing_target) + .expect("journal target key should be derivable"); + let prepared_journal = root.join(format!( + ".bandscope-recovery-{journal_key}.prepared.journal" + )); + let published_blocker = root.join(format!( + ".bandscope-recovery-{journal_key}.published.journal" + )); + fs::write(&published_blocker, b"block journal promotion") + .expect("published blocker should be created"); + + project_persistence::publish_new_project_file( + &existing_target, + br#"{\"id\":\"candidate\"}"#, + ) + .expect_err("pre-existing published recovery marker must make replacement fail closed"); + + let journal_mode = fs::metadata(&prepared_journal) + .expect("production replacement should leave its prepared journal for recovery") + .permissions() + .mode() + & 0o777; + assert_eq!( + journal_mode, 0o600, + "recovery metadata must default to owner-only permissions" + ); + + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[cfg(unix)] +#[test] +fn app_owned_project_and_workspace_directories_remain_owner_private_with_permissive_umask() { + use std::{ + fs, + os::raw::c_uint, + os::unix::fs::PermissionsExt, + process::Command, + time::{SystemTime, UNIX_EPOCH}, + }; + + const CHILD_ENV: &str = "BANDSCOPE_PROJECT_PRIVATE_DIRECTORY_MODE_CHILD"; + const TEST_NAME: &str = "permissions::app_owned_project_and_workspace_directories_remain_owner_private_with_permissive_umask"; + + if std::env::var_os(CHILD_ENV).is_none() { + let output = Command::new(std::env::current_exe().expect("test executable should resolve")) + .args(["--exact", TEST_NAME, "--nocapture"]) + .env(CHILD_ENV, "1") + .output() + .expect("isolated permissive-umask child should launch"); + assert!( + output.status.success(), + "permissive-umask directory child failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + + extern "C" { + fn umask(mask: c_uint) -> c_uint; + } + + unsafe { + umask(0); + } + + 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-private-directory-mode-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&root).expect("test directory should be created"); + + let project_base = root.join("app-local"); + let project_root = project_root::provision_new_project_root(&project_base, "project-1-1") + .expect("new app-owned project root should be provisioned"); + for directory in [&project_base, &project_root] { + let mode = fs::metadata(directory) + .expect("app-owned project directory should be readable") + .permissions() + .mode() + & 0o777; + assert_eq!( + mode, 0o700, + "new app-owned project directories must default to owner-only permissions" + ); + } + + let workspace_parent = root.join("cache"); + let workspace_root = workspace_parent.join("project-1-1"); + project_root::ensure_owned_directory(&workspace_root) + .expect("new reusable app-owned workspace should be provisioned"); + for directory in [&workspace_parent, &workspace_root] { + let mode = fs::metadata(directory) + .expect("app-owned workspace directory should be readable") + .permissions() + .mode() + & 0o777; + assert_eq!( + mode, 0o700, + "new reusable app-owned workspace directories must default to owner-only permissions" + ); + } + + fs::remove_dir_all(root).expect("test directory should be removable"); +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_process_kill.case b/apps/desktop/src-tauri/tests/project_persistence_process_kill.case new file mode 100644 index 000000000..48556226c --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_process_kill.case @@ -0,0 +1,124 @@ +use crate::project_persistence; +use std::{ + fs, + path::{Path, PathBuf}, + process::{Command, Stdio}, + thread, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +const CHILD_ENV: &str = "BANDSCOPE_PROJECT_PERSISTENCE_PROCESS_KILL_CHILD"; +const ROOT_ENV: &str = "BANDSCOPE_PROJECT_PERSISTENCE_PROCESS_KILL_ROOT"; +const CHILD_TEST_NAME: &str = "process_kill::child_first_save_waits_at_parent_durability_boundary"; +const ORIGINAL: &[u8] = br#"{\"id\":\"process-kill-candidate\"}"#; +const FOLLOW_UP: &[u8] = br#"{\"id\":\"process-kill-follow-up\"}"#; + +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-process-kill-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).expect("test directory should be created"); + path +} + +fn generated_stage_exists(root: &Path) -> bool { + root.read_dir() + .expect("test directory should remain readable") + .filter_map(Result::ok) + .any(|entry| { + let name = entry.file_name(); + let name = name.to_string_lossy(); + name.starts_with(".bandscope-stage-") && name.ends_with(".stage") + }) +} + +#[test] +fn child_first_save_waits_at_parent_durability_boundary() { + if std::env::var_os(CHILD_ENV).is_none() { + return; + } + let root = PathBuf::from( + std::env::var_os(ROOT_ENV).expect("child process must receive the fixture root"), + ); + let target = root.join("setlist.bscope"); + let marker = root.join("child-at-parent-durability-boundary.marker"); + + let _ = project_persistence::publish_new_project_file_with_linker_and_directory_sync( + &target, + ORIGINAL, + |source, destination| fs::hard_link(source, destination), + |_parent| { + fs::write(&marker, b"published-stage-retired")?; + loop { + thread::sleep(Duration::from_secs(60)); + } + }, + ); + panic!("process-kill child unexpectedly returned from the durability boundary"); +} + +#[test] +fn process_kill_after_first_save_publication_keeps_complete_target_and_no_stage_alias() { + let root = test_dir("first-save-parent-durability"); + let target = root.join("setlist.bscope"); + let marker = root.join("child-at-parent-durability-boundary.marker"); + let mut child = Command::new(std::env::current_exe().expect("test executable should be known")) + .args(["--exact", CHILD_TEST_NAME, "--nocapture", "--test-threads=1"]) + .env(CHILD_ENV, "1") + .env(ROOT_ENV, &root) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .expect("process-kill child should start"); + + let reached_boundary = (0..200).any(|_| { + if marker.exists() { + true + } else if child.try_wait().expect("child status should be readable").is_some() { + true + } else { + thread::sleep(Duration::from_millis(50)); + false + } + }); + assert!(reached_boundary, "child must reach the deterministic publication boundary"); + assert!( + marker.exists(), + "child must publish the marker before the parent terminates it" + ); + assert!( + child.try_wait().expect("child status should be readable").is_none(), + "child must still be alive at the injected interruption boundary" + ); + + child.kill().expect("parent must be able to terminate the child process"); + let status = child.wait().expect("terminated child should be reapable"); + assert!(!status.success(), "the interruption must be a real process termination"); + + assert_eq!( + fs::read(&target).expect("published target must remain readable after process termination"), + ORIGINAL + ); + assert!( + !generated_stage_exists(&root), + "first-save temporary aliases must already be retired before durability acknowledgement" + ); + + project_persistence::publish_new_project_file(&target, FOLLOW_UP) + .expect("a normal follow-up save must remain possible after the interrupted first save"); + assert_eq!( + fs::read(&target).expect("follow-up target should remain readable"), + FOLLOW_UP + ); + assert!( + !generated_stage_exists(&root), + "follow-up save must not leave staging artifacts" + ); + + fs::remove_dir_all(root).expect("test directory should be removable"); +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_project_root_authority.case b/apps/desktop/src-tauri/tests/project_persistence_project_root_authority.case new file mode 100644 index 000000000..831fd2aad --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_project_root_authority.case @@ -0,0 +1,296 @@ +#[test] +fn ordinary_app_local_base_provisions_one_new_project_root_without_reuse() { + use std::fs; + + let test_root = std::env::temp_dir().join(format!( + "bandscope-project-root-provisioning-positive-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let base_root = test_root.join("app-local").join("BandScope"); + let project_id = "project-1-1"; + + let created = crate::project_root::provision_new_project_root(&base_root, project_id) + .expect("ordinary missing app-local components should be provisioned"); + assert_eq!(created, base_root.join(project_id)); + assert!(created.is_dir(), "new project root should be a real directory"); + assert!( + crate::project_root::provision_new_project_root(&base_root, project_id).is_err(), + "new-project provisioning must not silently reuse an existing project root" + ); + + fs::remove_dir_all(&test_root).expect("test directory should be removable"); +} + +#[test] +fn ordinary_owned_workspace_directory_can_be_provisioned_and_reused() { + use std::fs; + + let test_root = std::env::temp_dir().join(format!( + "bandscope-owned-workspace-positive-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let workspace = test_root.join("cache").join("project-1-1"); + + let created = crate::project_root::ensure_owned_directory(&workspace) + .expect("ordinary app-owned workspace should be provisioned"); + assert_eq!(created, workspace); + let reopened = crate::project_root::ensure_owned_directory(&workspace) + .expect("an existing ordinary app-owned workspace should be reusable"); + assert_eq!(reopened, workspace); + + fs::remove_dir_all(&test_root).expect("test directory should be removable"); +} + +#[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!( + crate::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(unix)] +#[test] +fn linked_app_local_base_cannot_provision_a_new_project_root() { + use std::{fs, os::unix::fs::symlink}; + + let test_root = std::env::temp_dir().join(format!( + "bandscope-project-root-provisioning-{}-{}", + 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).expect("real app-local base should be created"); + symlink(&real_app_local_base, &linked_app_local_base) + .expect("linked app-local base should be created"); + + assert!( + crate::project_root::provision_new_project_root(&linked_app_local_base, project_id).is_err(), + "a symlinked app-local base must not redirect new project provisioning" + ); + assert!( + !real_app_local_base.join(project_id).exists(), + "rejected provisioning must not create a project through the linked base" + ); + + fs::remove_dir_all(&test_root).expect("test directory should be removable"); +} + +#[cfg(unix)] +#[test] +fn linked_owned_workspace_ancestor_cannot_redirect_app_owned_storage() { + use std::{fs, os::unix::fs::symlink}; + + let test_root = std::env::temp_dir().join(format!( + "bandscope-owned-workspace-authority-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let real_base = test_root.join("real-cache"); + let linked_base = test_root.join("linked-cache"); + let redirected_workspace = real_base.join("project-1-1"); + + fs::create_dir_all(&real_base).expect("real cache base should be created"); + symlink(&real_base, &linked_base).expect("linked cache base should be created"); + + assert!( + crate::project_root::ensure_owned_directory(&linked_base.join("project-1-1")).is_err(), + "a linked cache/temp ancestor must not redirect app-owned workspace provisioning" + ); + assert!( + !redirected_workspace.exists(), + "rejected workspace provisioning must not create a directory through a linked ancestor" + ); + + fs::remove_dir_all(&test_root).expect("test directory should be removable"); +} + +#[cfg(unix)] +#[test] +fn linked_scores_directory_cannot_be_reused_as_owned_storage() { + use std::{fs, os::unix::fs::symlink}; + + let test_root = std::env::temp_dir().join(format!( + "bandscope-scores-authority-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let project_root = test_root.join("project-1-1"); + let external_scores = test_root.join("external-scores"); + let scores = project_root.join("scores"); + + fs::create_dir_all(&project_root).expect("project root should be created"); + fs::create_dir_all(&external_scores).expect("external scores target should be created"); + symlink(&external_scores, &scores).expect("linked scores directory should be created"); + + assert!( + crate::project_root::ensure_owned_directory(&scores).is_err(), + "a linked scores directory must not become app-owned storage 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!( + crate::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"); +} + +#[cfg(windows)] +#[test] +fn reparse_app_local_base_cannot_provision_a_new_project_root() { + use std::{fs, process::Command}; + + let test_root = std::env::temp_dir().join(format!( + "bandscope-project-root-provisioning-{}-{}", + 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).expect("real app-local base 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!( + crate::project_root::provision_new_project_root(&linked_app_local_base, project_id).is_err(), + "a reparse app-local base must not redirect new project provisioning" + ); + assert!( + !real_app_local_base.join(project_id).exists(), + "rejected provisioning must not create a project through the reparse base" + ); + + fs::remove_dir(&linked_app_local_base).expect("junction should be removable"); + fs::remove_dir_all(&test_root).expect("test directory should be removable"); +} + +#[cfg(windows)] +#[test] +fn reparse_owned_workspace_ancestor_cannot_redirect_app_owned_storage() { + use std::{fs, process::Command}; + + let test_root = std::env::temp_dir().join(format!( + "bandscope-owned-workspace-authority-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let real_base = test_root.join("real-cache"); + let linked_base = test_root.join("linked-cache"); + let redirected_workspace = real_base.join("project-1-1"); + + fs::create_dir_all(&real_base).expect("real cache base should be created"); + let junction = Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&linked_base) + .arg(&real_base) + .status() + .expect("junction command should start"); + assert!(junction.success(), "junction fixture should be created"); + + assert!( + crate::project_root::ensure_owned_directory(&linked_base.join("project-1-1")).is_err(), + "a reparse cache/temp ancestor must not redirect app-owned workspace provisioning" + ); + assert!( + !redirected_workspace.exists(), + "rejected workspace provisioning must not create a directory through a reparse ancestor" + ); + + fs::remove_dir(&linked_base).expect("junction should be removable"); + fs::remove_dir_all(&test_root).expect("test directory should be removable"); +} + +#[cfg(windows)] +#[test] +fn reparse_scores_directory_cannot_be_reused_as_owned_storage() { + use std::{fs, process::Command}; + + let test_root = std::env::temp_dir().join(format!( + "bandscope-scores-authority-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let project_root = test_root.join("project-1-1"); + let external_scores = test_root.join("external-scores"); + let scores = project_root.join("scores"); + + fs::create_dir_all(&project_root).expect("project root should be created"); + fs::create_dir_all(&external_scores).expect("external scores target should be created"); + let junction = Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&scores) + .arg(&external_scores) + .status() + .expect("junction command should start"); + assert!(junction.success(), "junction fixture should be created"); + + assert!( + crate::project_root::ensure_owned_directory(&scores).is_err(), + "a reparse scores directory must not become app-owned storage authority" + ); + + fs::remove_dir(&scores).expect("scores junction should be removable"); + fs::remove_dir_all(&test_root).expect("test directory should be removable"); +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_published_recovery_content_cas.case b/apps/desktop/src-tauri/tests/project_persistence_published_recovery_content_cas.case new file mode 100644 index 000000000..5a973ef75 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_published_recovery_content_cas.case @@ -0,0 +1,194 @@ +use crate::project_persistence; + +use bandscope_desktop_core::prepare_project_migration; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +fn test_root(label: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "bandscope-published-recovery-content-cas-{label}-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&root).expect("fixture directory should be created"); + root +} + +fn generated_stage(root: &Path) -> PathBuf { + root.join(format!( + ".bandscope-stage-{}.stage", + uuid::Uuid::new_v4() + )) +} + +fn published_journal_path(target: &Path) -> PathBuf { + target + .parent() + .expect("fixture target should have a parent") + .join(format!( + ".bandscope-recovery-{}.published.journal", + project_persistence::journal_target_key(target) + .expect("fixture target key should be derivable") + )) +} + +#[cfg(unix)] +fn journal_name(path: &Path) -> Vec { + use std::os::unix::ffi::OsStrExt; + + path.file_name() + .expect("fixture path should have a file name") + .as_bytes() + .to_vec() +} + +#[cfg(windows)] +fn journal_name(path: &Path) -> Vec { + use std::os::windows::ffi::OsStrExt; + + path.file_name() + .expect("fixture path should have a file name") + .encode_wide() + .collect() +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn published_migration_fixture( + label: &str, + journal_version: u8, + include_validation: bool, +) -> (PathBuf, PathBuf, PathBuf, Vec) { + let root = test_root(label); + let target = root.join("setlist.bscope"); + let displaced = generated_stage(&root); + #[cfg(unix)] + let candidate_stage = displaced.clone(); + #[cfg(windows)] + let candidate_stage = generated_stage(&root); + let historical = include_str!("../../core/testdata/project-v2.json"); + let prepared = prepare_project_migration(historical) + .expect("historical fixture should prepare a canonical migration"); + let candidate = prepared.canonical_content().as_bytes().to_vec(); + + fs::write(&displaced, historical).expect("known-good predecessor should be written"); + fs::write(&target, &candidate).expect("published candidate should be written"); + + let expected = project_persistence::project_file_identity(&displaced) + .expect("predecessor identity should be capturable"); + let candidate_identity = project_persistence::project_file_identity(&target) + .expect("candidate identity should be capturable"); + let journal = published_journal_path(&target); + let mut record = serde_json::json!({ + "version": journal_version, + "target_name": journal_name(&target), + "candidate_name": journal_name(&candidate_stage), + "displaced_name": journal_name(&displaced), + "expected": expected, + "candidate": candidate_identity, + }); + if include_validation { + record["validation"] = serde_json::json!({ + "kind": "migration", + "receipt": prepared.receipt(), + }); + } + fs::write( + &journal, + serde_json::to_vec(&record).expect("journal fixture should serialize"), + ) + .expect("published journal should be written"); + + (root, target, displaced, candidate) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[test] +fn published_v2_migration_journal_cleans_only_exact_receipt_bound_artifacts() { + let (root, target, displaced, candidate) = + published_migration_fixture("exact-v2", 2, true); + let journal = published_journal_path(&target); + + project_persistence::recover_project_publication(&target) + .expect("exact receipt-bound published state should finish cleanup"); + + assert_eq!( + fs::read(&target).expect("published candidate should remain readable"), + candidate + ); + assert!( + !displaced.exists(), + "validated rollback material should retire" + ); + assert!( + !journal.exists(), + "validated published journal should retire" + ); + fs::remove_dir_all(root).expect("fixture directory should be removable"); +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[test] +fn published_v2_migration_journal_preserves_rollback_on_in_place_candidate_change() { + use std::io::Write; + + let (root, target, displaced, candidate) = + published_migration_fixture("mutated-v2", 2, true); + let journal = published_journal_path(&target); + let mutated = [candidate.as_slice(), b"\n"].concat(); + let mut same_file = fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(&target) + .expect("published candidate should remain openable"); + same_file + .write_all(&mutated) + .expect("candidate bytes should change in place"); + same_file + .sync_all() + .expect("candidate mutation should be synchronized"); + drop(same_file); + + project_persistence::recover_project_publication(&target) + .expect_err("receipt mismatch must preserve rollback evidence"); + + assert_eq!( + fs::read(&target).expect("mutated candidate should remain inspectable"), + mutated + ); + assert!( + displaced.exists(), + "known-good predecessor must remain available" + ); + assert!( + journal.exists(), + "failed recovery must retain its durable journal" + ); + fs::remove_dir_all(root).expect("fixture directory should be removable"); +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[test] +fn legacy_identity_only_published_journal_fails_closed_without_deleting_known_good() { + let (root, target, displaced, candidate) = + published_migration_fixture("legacy-v1", 1, false); + let journal = published_journal_path(&target); + + project_persistence::recover_project_publication(&target) + .expect_err("identity-only published journals must not retire rollback material"); + + assert_eq!( + fs::read(&target).expect("candidate should remain inspectable"), + candidate + ); + assert!( + displaced.exists(), + "legacy rollback material must remain available" + ); + assert!( + journal.exists(), + "legacy journal must remain for explicit recovery handling" + ); + fs::remove_dir_all(root).expect("fixture directory should be removable"); +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_read_identity.case b/apps/desktop/src-tauri/tests/project_persistence_read_identity.case new file mode 100644 index 000000000..57c5b1e0d --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_read_identity.case @@ -0,0 +1,53 @@ +//! Regression coverage for read-handle-bound project predecessor identity. + +use crate::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-read-identity-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).expect("test directory should be created"); + path +} + +#[cfg(any(unix, windows))] +#[test] +fn bounded_read_retains_the_opened_predecessor_identity_after_path_replacement() { + let root = test_dir("path-replacement"); + let selected = root.join("selected.bscope"); + let parked = root.join("parked.bscope"); + let replacement = root.join("replacement.bscope"); + let original = r#"{"id":"original"}"#; + let competing = r#"{"id":"competing"}"#; + + fs::write(&selected, original).expect("original project should be written"); + fs::write(&replacement, competing).expect("competing project should be written"); + + let read = project_persistence::read_project_file_with_identity(&selected) + .expect("bounded project read should return content and opened-file identity"); + + fs::rename(&selected, &parked).expect("the original project should be parked"); + fs::rename(&replacement, &selected).expect("the competing project should take the path"); + + let parked_identity = project_persistence::project_file_identity(&parked) + .expect("parked original identity should be readable"); + let competing_identity = project_persistence::project_file_identity(&selected) + .expect("competing target identity should be readable"); + + assert_eq!(read.content(), original); + assert_eq!(read.identity(), &parked_identity); + assert_ne!(read.identity(), &competing_identity); + + fs::remove_dir_all(root).expect("test directory should be removable"); +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_recovery_cleanup.case b/apps/desktop/src-tauri/tests/project_persistence_recovery_cleanup.case new file mode 100644 index 000000000..10924f862 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_recovery_cleanup.case @@ -0,0 +1,204 @@ +use crate::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": 2, + "target_name": target_name, + "candidate_name": stage_name, + "displaced_name": stage.file_name().unwrap().as_bytes().to_vec(), + "expected": expected, + "candidate": candidate_identity, + "validation": { "kind": "identity_only" }, + }); + 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": 2, + "target_name": journal_name(&target), + "candidate_name": journal_name(&stage), + "displaced_name": journal_name(&stage), + "expected": expected, + "candidate": candidate_identity, + "validation": { "kind": "identity_only" }, + }); + 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.case b/apps/desktop/src-tauri/tests/project_persistence_rollback_identity.case new file mode 100644 index 000000000..c8d47a352 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_rollback_identity.case @@ -0,0 +1,230 @@ +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +mod rollback_identity { + use crate::project_persistence; + use std::{ + fs, + io::Write, + 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-rollback-identity-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).expect("test directory should be created"); + path + } + + fn journal_path(target: &Path, published: bool) -> PathBuf { + let phase = if published { "published" } else { "prepared" }; + target.parent().expect("fixture target should have a parent").join(format!( + ".bandscope-recovery-{}.{phase}.journal", + project_persistence::journal_target_key(target) + .expect("fixture target key should be derivable") + )) + } + + #[cfg(unix)] + fn journal_name(path: &Path) -> Vec { + use std::os::unix::ffi::OsStrExt; + + path.file_name() + .expect("fixture path should have a file name") + .as_bytes() + .to_vec() + } + + #[cfg(windows)] + fn journal_name(path: &Path) -> Vec { + use std::os::windows::ffi::OsStrExt; + + path.file_name() + .expect("fixture path should have a file name") + .encode_wide() + .collect() + } + + fn write_identity_journal( + target: &Path, + candidate_stage: &Path, + displaced: &Path, + expected: &project_persistence::ProjectFileIdentity, + candidate: &project_persistence::ProjectFileIdentity, + published: bool, + ) -> PathBuf { + let journal = journal_path(target, published); + let record = serde_json::json!({ + "version": 2, + "target_name": journal_name(target), + "candidate_name": journal_name(candidate_stage), + "displaced_name": journal_name(displaced), + "expected": expected, + "candidate": candidate, + "validation": { "kind": "identity_only" }, + }); + let bytes = serde_json::to_vec(&record).expect("recovery journal fixture should serialize"); + let mut file = fs::File::create_new(&journal).expect("recovery journal fixture should be created"); + file.write_all(&bytes) + .expect("recovery journal fixture should be written"); + file.sync_all() + .expect("recovery journal fixture should be synchronized"); + journal + } + + fn has_published_journal(root: &Path) -> bool { + root.read_dir() + .expect("test directory should remain readable") + .filter_map(Result::ok) + .any(|entry| { + entry + .file_name() + .to_string_lossy() + .contains(".published.journal") + }) + } + + #[test] + fn successful_cleanup_preserves_a_displaced_path_that_is_no_longer_the_predecessor() { + let root = test_dir("successful-foreign-displaced"); + 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(&stage, original).expect("displaced predecessor fixture should be written"); + fs::write(&target, candidate).expect("published candidate fixture should be written"); + + let expected = project_persistence::project_file_identity(&stage) + .expect("predecessor identity should be capturable"); + let candidate_identity = project_persistence::project_file_identity(&target) + .expect("candidate identity should be capturable"); + let journal = write_identity_journal( + &target, + &stage, + &stage, + &expected, + &candidate_identity, + true, + ); + + fs::remove_file(&stage).expect("predecessor pathname should be replaceable by the race fixture"); + fs::write(&stage, foreign).expect("foreign displaced-path occupant should be written"); + + let error = project_persistence::recover_project_publication(&target).expect_err( + "published recovery must fail closed rather than delete a displaced pathname whose identity changed", + ); + + assert_eq!(error, "Could not recover the project publication safely."); + assert_eq!( + fs::read(&stage).expect("foreign displaced-path occupant must be preserved"), + foreign + ); + assert_eq!( + fs::read(&target).expect("published candidate must remain intact"), + candidate + ); + assert!(journal.exists(), "published recovery evidence must remain"); + assert!(has_published_journal(&root)); + + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[test] + fn successful_cleanup_preserves_rollback_evidence_when_the_published_target_is_replaced() { + let root = test_dir("successful-foreign-target"); + 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(&stage, original).expect("displaced predecessor fixture should be written"); + fs::write(&target, candidate).expect("published candidate fixture should be written"); + + let expected = project_persistence::project_file_identity(&stage) + .expect("predecessor identity should be capturable"); + let candidate_identity = project_persistence::project_file_identity(&target) + .expect("candidate identity should be capturable"); + let journal = write_identity_journal( + &target, + &stage, + &stage, + &expected, + &candidate_identity, + true, + ); + + fs::remove_file(&target).expect("published target should be replaceable by the race fixture"); + fs::write(&target, foreign).expect("foreign target occupant should be written"); + + let error = project_persistence::recover_project_publication(&target).expect_err( + "published recovery must fail closed when the target no longer denotes the published candidate", + ); + + assert_eq!(error, "Could not recover the project publication safely."); + assert_eq!( + fs::read(&stage).expect("known-good rollback material must be preserved"), + original + ); + assert_eq!( + fs::read(&target).expect("foreign target occupant must not be clobbered"), + foreign + ); + assert!(journal.exists(), "published recovery evidence must remain"); + assert!(has_published_journal(&root)); + + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[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 = write_identity_journal( + &target, + &stage, + &stage, + &expected, + &candidate_identity, + false, + ); + + 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::recover_project_publication(&target).expect_err( + "prepared recovery 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.case b/apps/desktop/src-tauri/tests/project_persistence_windows_identity.case new file mode 100644 index 000000000..1042f9746 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_windows_identity.case @@ -0,0 +1,68 @@ +#![cfg(windows)] + +use crate::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/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..5a49eb9d4 --- /dev/null +++ b/docs/doctoring/local-audio-source-materialization.md @@ -0,0 +1,149 @@ +# 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 owns creation and verification of the app-owned `source.` bytes and the native content identity for that publication. Project Persistence owns the filesystem publication primitive and the later versioned project reference that consumes this evidence. Neither context re-hashes the user's original media for persistence, and neither copies the other's platform publication implementation. + +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; +- hard-link publication removed the private stage after syncing its bytes but did not prove the buyer-visible `source.` directory entry durable before returning publication identity; +- 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 #970 Project Persistence branch now owns durable publication of an already-synchronized source stage. Production local-file materialization consumes the Resource Admission receipt, synchronizes the private stage, delegates publication to `project_persistence::publish_synced_file_noreplace`, 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. Linux/macOS publish with the native no-replace rename and synchronize the parent directory before success; Windows uses `MoveFileExW(MOVEFILE_WRITE_THROUGH)`. A competing destination is never replaced. If the durability step fails after the complete target becomes visible, bootstrap/publication identity is not returned. + +The remaining integration is across the same owning persistence boundary: durable project `sourceReference` and restart re-admission are already represented on #970, while broader startup recovery, final-result durability, autosave/backup UX and product release acceptance remain open. Platform-atomic no-follow descriptor acquisition, YouTube durable-source policy, and decoder licensing remain separate 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. Project Persistence uses the supported platform's native no-replace move; a competing destination leaves the synchronized candidate stage intact at that owner boundary. +- Production success is stronger than target visibility: Unix must successfully synchronize the target's parent directory after native publication, and Windows relies on the native write-through move. A durability failure must not mint bootstrap or persistence identity. +- 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. + +## 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 immediately remove the private stage — superseded. It solved no-clobber publication but did not establish crash durability of the new directory entry, and it would require a second platform durability implementation outside Project Persistence. +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 retains the typed identity in native Tauri state keyed by project id before returning. +15. Reuse Project Persistence's platform-native no-replace publication owner for the synchronized local-audio stage — selected. Linux/macOS use no-replace rename plus parent-directory synchronization; Windows uses the existing write-through native move. This closes the local-source directory-durability gap without copying platform code into Resource Admission. + +## 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. +- Local-source durability RED `f408b5a4e322b16159fb87df6c5e3e07692fd36c` adds executable success, competing-destination, and injected directory-sync-failure cases for the Project Persistence owner port. +- `b758debc93b7b11eff22b515642d1265bc83da51` implements the reusable native no-replace publication port; `834b45fe00371c2bae455fb7a43e7df48a1e13fd` moves the production local-audio materializer onto it; `920bacfc9b5ae833473b4f17bfac82391a45160c` replaces the obsolete hard-link implementation assertion with the owner-boundary contract. + +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 private stage and delegates filesystem publication to Project Persistence. The owner verifies a safe sibling directory and a regular non-link stage, uses the platform-native no-replace move, and establishes the supported platform's durability condition before the materializer continues. The materializer then 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, conflicting target, durability failure, or read failure is normalized to the bounded project-workspace diagnosis; source/destination paths, raw OS errors, and audio bytes are not exposed. If directory synchronization fails after native publication, the complete target is intentionally left in place but no bootstrap/publication identity is returned. + +`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 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. + +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 Tauri local-file materialization consumes receipt and publication-verification ports, not the compatibility byte-count adapter; +- synchronized local-source publication delegates to the Project Persistence no-replace owner rather than hard-linking or overwrite-renaming in `main.rs`; +- the Project Persistence executable contract covers successful parent durability, a competing destination with preserved candidate stage, and failure after target visibility but before directory durability; +- 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 #970 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 has separate native contracts: `LocalAudioCopyReceipt` proves the exact bytes staged/published, Project Persistence proves no-replace publication durability on supported platforms, and retained `LocalAudioPublicationIdentity` represents the path-free durable evidence consumed by project serialization and restart re-admission. This does not yet establish global startup recovery after an interrupted project overwrite, final-result/cache power-loss durability, disk-full fault-injection coverage, or autosave/backup UX. + +Restart must continue to 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 remains open. Issue #1129 remains the commercial decoder-dependency gate. + +## Unix local-source stage permission hardening + +A later storage-boundary audit found that the production `.source-.stage` was created with ambient `OpenOptions::create_new` mode. Under a permissive inherited `umask(000)`, Unix can therefore create the stage as `0666`; the no-replace publication path moves that same inode into buyer-visible `source.`, so the final app-owned raw rehearsal audio can retain the broad mode. The newly hardened `0700` project directory normally contains that exposure for fresh projects, but file confidentiality must not silently depend on an ancestor mode staying restrictive. + +The selected contract is create-private-at-first-visibility, not create-broad-then-`chmod` and not a process-global `umask` mutation. RED `9639876f08e908e8497159e40d235e071d6aa91f` adds an isolated Unix child-process regression that sets `umask(000)`, calls the native production stage-creation boundary, writes/synchronizes real WAV fixture bytes, and requires mode `0600`. Hosted macOS run `35444853322`, job `105901906648`, reached the native Project Persistence regression step on that exact test-only head and failed; checkout, Rust 1.97.1, and the frontend fixture had succeeded. The Windows owner lane on that test-only head remained unaffected because the regression and POSIX mode contract are Unix-only. + +`35548eaff7670814924880f049b17799d5713f29` adds a narrow production-wiring contract: the Tauri materializer must call `create_private_local_audio_stage(&stage)` rather than reopening an ambient-mode `create_new` path. Source fix `d2ede94d2633ba88a679a48e0f6aad8c41baa584` adds that boundary in `analysis_source.rs`: Unix uses `OpenOptionsExt::mode(0o600)` at creation, while non-Unix keeps native ACL inheritance. `a984dd5a108737f123c5cae77484546664087012` changes only the Tauri import and stage-construction call site to consume the tested boundary; Resource Admission byte limits, copy/hash receipt semantics, Project Persistence publication semantics, and destination verification remain unchanged. + +### Security Notes + +The protected object is raw rehearsal audio selected by the user. The stage lives under an app-owned project root, but its own mode is now restrictive by construction on Unix so a permissive launcher `umask` cannot widen it to group/world read/write. Because Project Persistence publishes the synchronized stage itself rather than copying to a fresh destination inode, the buyer-visible `source.` retains that private Unix mode. Windows continues to use native ACL inheritance; this change does not claim a Windows ACL baseline. No source path, mode, OS error, audio bytes, or new PII is logged or transmitted. + +The remaining boundary is explicit: `0600` is the requested Unix creation mode and a more restrictive inherited umask may narrow it; descriptor-bound no-follow acquisition is still open; existing files are not silently chmodded; cache/temp/score child artifacts have separate owners/creation paths; YouTube import does not inherit this local-file claim; and this is not rights-cleared MIR accuracy, packaged interruption, disk-full, permission-failure, cancellation, or power-loss evidence. + +The executable acceptance point is the isolated permissive-umask child regression. The source-text wiring contract is only a guard that the buyer path calls the tested native boundary; it is not counted as runtime success by itself. Final acceptance still requires unchanged exact-head macOS/Windows owner workflows and repository/security gates after this documentation commit. + +## 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..5e4004f2b 100644 --- a/docs/engineering/local-project-format.md +++ b/docs/engineering/local-project-format.md @@ -1,33 +1,95 @@ # 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` is recomputed by native Resource Admission before an app-owned source is accepted after restart. + +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. Active Player still has to resolve the reopened semantic through freshly re-admitted native Full mix/current-stem availability rather than treating the persisted semantic as audible authority. + +`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, validated canonical-copy preparation, content-addressed migration receipt, idempotent current serialization, and selected-source preservation. +- `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. + +`prepare_project_migration` is the canonical migration-copy preparation boundary. It uses the same version dispatch, hashes the exact input bytes, serializes the admitted typed document through the canonical v3 writer, hashes those exact candidate bytes, reopens the candidate through the current parser, and requires the reopened document to serialize byte-for-byte identically before returning `PreparedProjectMigration`. The returned value contains the current typed document, the validated canonical candidate bytes, and `ProjectMigrationReceipt`: source version (`None` only for legacy unversioned input), target version, exact input SHA-256, exact canonical-output SHA-256, and `migrated`. Re-preparing those canonical bytes yields source version 3, `migrated = false`, and the same bytes/digest. `project_document_with_migration_receipt` is a compatibility wrapper over the same preparation boundary, not a second parser. + +The prepared candidate and receipt are deterministic transformation evidence. They are not a signature, backup, filesystem-publication receipt, or proof that recovery completed. Production `load_project` still reads and admits a document without publishing the prepared v3 copy back to disk, so migration-on-copy publication remains a separate Project Persistence step. + +The SHA-256 source-reference 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 +107,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 +128,31 @@ 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 derives the app-owned artifact from the validated project id and fixed artifact basename and validates the artifact without following persisted path input. +6. **Content-identity separation** — `fileSizeBytes` is not treated as identity. Re-admission compares bounded byte length and recomputes 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. +8. **Migration evidence separation** — migration receipts contain only source/target versions and content digests. `PreparedProjectMigration` keeps current canonical publication bytes in memory but neither contract includes a filesystem path, user identity, runtime playback authority, or a claim that publication/recovery completed. A candidate must reopen as current v3 and reproduce identical canonical bytes before the preparation boundary returns it. + +## Current boundary and next migration slices + +Version 3, retained native publication identity, Save injection, and restart re-admission now form one path-free full-mix continuity contract. Resource Admission materializes the admitted source as app-owned `source.`, records exact size/SHA-256 evidence, Project Persistence stores only that identity in `sourceReference`, and restart derives and rechecks the app-owned artifact before restoring native source identity. Production analysis then consumes a verified private byte snapshot rather than trusting a later pathname reopen. -## Security Constraints +This does not make the full rehearsal workspace release-ready. Active Player must still reconcile persisted `selectedPlaybackSource` with fresh Full mix/current-stem authorities and fail closed to Full mix when the preferred stem is absent. Descriptor-bound ancestor authority remains incomplete on all supported paths. YouTube durable-source policy and decoder licensing remain separate owners. -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 migration core now closes both deterministic source/target/input/output evidence and validated current-version copy preparation. The remaining migration durability slice is filesystem orchestration: bind `PreparedProjectMigration` to the selected target's admitted file identity, retain a verified pre-migration known-good artifact, publish only while that identity remains the expected predecessor, reopen the published candidate, and retain/restore the known-good artifact if publication or reopen verification fails. Downgrade/application-rollback behavior must also be explicit. Bounded autosave, known-good backup rotation, global startup recovery discovery, accessible Restore / Compare / Discard UX, and exhaustive interruption/disk-full/power-loss fault injection remain open under #962. ## 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 new version 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/feature-cache-integrity-durability.md b/docs/traceability/feature-cache-integrity-durability.md new file mode 100644 index 000000000..b0095cdd0 --- /dev/null +++ b/docs/traceability/feature-cache-integrity-durability.md @@ -0,0 +1,133 @@ +# Reusable feature-cache integrity, durability, admission, and MIR generation + +## Scope + +This note covers the derived local-audio feature cache written as a compressed NumPy stem archive plus a JSON manifest. It does not make the cache a Resource Admission authority and it does not make cached stems scientific ground truth. Native Resource Admission remains authoritative for the admitted source byte count and SHA-256; Signal/MIR Analysis owns the scientific separation generation; Project Persistence owns durable publication and cache reuse policy. + +## Problem + +The earlier feature cache wrote `*.features.npz.tmp` and `*.features.json.tmp`, then renamed the arrays file and the metadata file. The manifest did not contain a digest of the exact NPZ bytes and did not carry the native admitted-audio identity. A valid-looking NPZ with the expected stem keys could therefore be substituted under an existing manifest and still be reused. The two pathname replacements also did not establish the same file-sync and directory-entry durability boundary already required for final rehearsal-result publication. + +The integrity repair then exposed a second boundary: locally replaceable cache artifacts were still admitted through unbounded `json.load`, an unbounded NPZ digest pass, and NumPy allocation after only structural member-name checks. Python JSON decoding also permits duplicate object names unless the caller rejects them. A malicious or corrupted cache could therefore turn a disposable optimization into excessive read/parse/allocation work or ambiguous last-key-wins metadata. + +After those repairs, cache equivalence still depended on source identity and artifact integrity alone. The same admitted audio could reuse stems after a BandScope separation-code change, a Demucs/torch runtime change, or a configured `htdemucs` checkpoint-generation change. Integrity answers “are these the same cached bytes?”; it does not answer “were these bytes produced by the same scientific computation?” + +That distinction matters for a rehearsal decision tool. A reusable cache may skip expensive MIR work, but it must not silently carry a prior model/runtime generation across a scientifically material change. + +## Decision + +Feature-cache schema version 2 uses a manifest-last protocol: + +1. Serialize the stem arrays to a unique same-directory stage. +2. Flush Python buffers and `fsync` the staged NPZ file. +3. Compute SHA-256 over the exact closed staged NPZ bytes. +4. Durably publish the NPZ using the existing Project Persistence platform publication owner. +5. Publish the JSON manifest last using the existing durable JSON cache publisher. +6. Reuse only when the manifest schema is current, the native admitted-audio identity agrees with the current scoped evidence when present, and SHA-256 of the current NPZ bytes matches `arraysSha256` before NumPy deserialization. + +Admission is additionally resource-bounded and fail closed: + +- the JSON manifest must be one regular file of at most 64 KiB and is decoded with duplicate-object-key rejection; +- the encoded NPZ must be one regular file and is rejected before hashing when it exceeds four source-policy decoded-audio budgets plus a 16 MiB archive-overhead allowance; +- a feature cache may name at most four unique stems, matching the canonical local `htdemucs` stem cardinality; +- ZIP members must exactly match the declared stem set and each NPY member is header-preflighted before `np.load` so a small archive cannot declare an oversized allocation; +- declared per-stem bytes may not exceed one source-policy decoded-audio budget and total declared stem bytes may not exceed four budgets; +- loaded stems must remain one-dimensional, non-empty, floating-point, finite, and within the same per-stem bound; +- schema-v2 `stemRoleTypes` must cover exactly the admitted stem keys. + +Manifest and archive descriptors are opened read-only with close-on-exec and `O_NOFOLLOW` where the host exposes it, then checked with `fstat`. The archive SHA-256, ZIP preflight, and NumPy load operate on the same opened descriptor rather than reopening the mutable pathname between checks. + +Cache reuse is now also bound to a versioned MIR generation. `admitted_audio_cache_identity()` still consumes native source byte-count/SHA-256 evidence rather than recomputing source identity, but its cache-only envelope additionally includes `mirGeneration` from Signal/MIR Analysis. That generation records: + +- BandScope separation implementation generation; +- model name and canonical checkpoint filename; +- checkpoint signature and encoded checksum prefix parsed by the existing Demucs model-admission owner; +- installed Demucs and torch distribution versions; +- production default target sample rate, overlap, and device. + +The generation adapter reads the checkpoint mapping and parser from the existing `audio_separator` owner rather than copying model identifiers into Project Persistence. Installed package versions come from `importlib.metadata.version()` without importing the heavy ML runtimes. If required distribution metadata or a canonical checkpoint identity is unavailable, cache identity construction fails closed and reuse is disabled. The final-result analysis generation is advanced at the same boundary so final rehearsal-result reuse cannot outlive a separation-generation change either. + +If NPZ publication succeeds but manifest publication fails, an older manifest cannot authorize the new arrays unless the bytes are exactly identical, because its digest must still match. A missing or rejected manifest is a cache miss. This is deliberate fail-closed behavior; the feature cache is derived and recomputable. + +The NPZ digest is a derived-artifact integrity binding, and `mirGeneration` is a cache-equivalence discriminator. Neither becomes a second source-admission authority. + +## Alternatives rejected + +- **Path/size-only reuse:** rejected because neither identifies the admitted source bytes nor the stem archive bytes. +- **Source SHA-256 + NPZ SHA-256 as scientific equivalence:** rejected because identical input and cached bytes do not identify the code/model/runtime generation that produced those bytes. +- **Bind only the string `htdemucs`:** rejected because package runtime, checkpoint generation, and BandScope implementation changes can alter the computation while keeping that model alias. +- **Use the full checkpoint digest on every cache hit:** rejected for this cache-admission slice because model admission already verifies the canonical checksum prefix before inference and repeated full-checkpoint hashing would move release provenance work into the hot reuse path. Full release digest/signature provenance remains Distribution evidence. +- **Import torch/Demucs to discover versions before every cache hit:** rejected. Installed distribution metadata supplies the version strings without executing heavyweight runtime imports or extension loading. +- **Validate only NPZ member names and shapes:** rejected because a different valid archive can preserve those structural properties. +- **Trust ZIP member size and call NumPy directly:** rejected because an NPY header can claim a larger logical array than a small stored member should authorize; the shape and dtype are preflighted before NumPy allocation. +- **Unbounded local-cache reads because the cache is app-owned:** rejected because local replacement, corruption, downgrade residue, and partially recovered state remain untrusted inputs. +- **Write manifest first:** rejected because a crash can make a new manifest authorize absent or stale arrays. +- **Plain rename without pre-publication sync:** rejected because atomic pathname replacement alone does not prove that acknowledged cache bytes are durable after power loss. +- **Duplicate platform publication code in the analysis API:** rejected. The analysis API serializes derived stem data; the existing Project Persistence cache publication primitive remains the platform-specific durability owner. + +## RED → repair evidence + +- `1b433605444844a09eb018f1c9249ea552215fef` — executable RED for native source binding, exact NPZ-byte binding, arrays-before-manifest ordering, fail-closed array-publication failure, and schema advancement. +- `4530cd5e55929a90cba263f2a81485dab3cdc687` — schema v2, source evidence consumption, exact NPZ SHA-256 admission, unique staged NPZ, staged-file `fsync`, existing durable publication owner reuse, and manifest-last adoption. The same integrity envelope is applied to file-backed stem process handoff before the arrays are trusted by downstream analysis. +- `e8b2b59f7aa2316cc5572808cdc751ba323f56f3` — executable RED for duplicate JSON names, manifest size bounding, and encoded NPZ rejection before digest work. +- `9f7cb56a5da977af0e615bf6aada6ae326daaa78` — bounded regular-file manifest/NPZ admission, duplicate-key rejection, descriptor-bound digest/load, exact archive-member set, stem-count/byte limits, and write-side resource checks. +- `f44e0d9c762145ade763d71eba4e67326c048d8b` — regression proving a tiny NPZ member with an oversized NPY shape declaration must fail before NumPy allocation. +- `eebc09345bdc5970814e2ec2f723d11c61ae317f` — NPY header preflight closes that declared-shape allocation path before `np.load`. +- `efcffad68c5f0e1af4f14729bc7e063d9ce5bc82` — executable RED proving cache identity did not yet bind MIR generation and did not fail closed when that generation was unavailable. +- `33ebea0219e0341dd574af395b1b6b4c93d888d5` — canonical separation-generation adapter consumes the existing checkpoint owner and installed Demucs/torch package metadata without loading the ML runtime. +- `3b1c2378abfad1a08f0e20e22617bbc06447b283` — Project Persistence cache identity adopts that generation, advances the final-result analysis generation, and treats missing MIR generation as a cache-disable condition. +- `f17051f8e2fe9fc2b2857232573f617f12d4da44` — edge coverage for canonical generation composition plus missing/malformed checkpoint and missing-runtime-metadata failure paths. + +Hosted exact-head checks remain authoritative. These commits are source evidence, not protected or release evidence. + +## Security Notes + +### Attack surface + +The reusable feature cache admits locally replaceable JSON and ZIP/NPY bytes and also consumes version/model-generation metadata that decides whether expensive MIR work may be skipped. Local corruption, stale recovery residue, or deliberate replacement can therefore target parsing/allocation cost, cache identity, or derived-audio reuse without modifying the original admitted audio. + +### Trust boundary + +Native Resource Admission remains the sole authority for source byte count and SHA-256. Signal/MIR Analysis owns the separation/model generation. Project Persistence owns only durable publication, bounded admission, exact derived-byte integrity, and reuse policy. Neither the NPZ digest nor `mirGeneration` is source authenticity or scientific-accuracy authority. + +### Mitigations + +Manifest and NPZ admission is bounded and regular-file-only, rejects duplicate JSON keys, limits archive/stem cardinality and declared bytes, preflights NPY headers before NumPy allocation, keeps `allow_pickle=False`, validates finite floating-point stems, and binds archive digest/load to one opened descriptor. Publication is arrays-first/manifest-last through the existing durable publication owner. Reuse fails closed when native source evidence, exact derived-byte integrity, or MIR generation evidence is missing or mismatched. + +### Test points + +Regression coverage includes native source binding, exact NPZ-byte binding, publication order/failure, duplicate-key rejection, manifest/archive byte ceilings, exact ZIP member sets, oversized NPY shape declarations before allocation, malformed role metadata, canonical MIR generation composition, malformed/missing checkpoint identity, missing package metadata, and final-result propagation of the same generation identity. + +### Realistic threats + +Realistic threats include local replacement of cache artifacts, partial cache recovery after a crash or downgrade, archive/header values crafted to trigger excessive work, stale stems surviving a code/model/runtime change, and path replacement between validation steps. Same-descriptor admission, resource ceilings, generation binding, and fail-closed recomputation address these cases without trusting cache location or filenames. + +### Remaining risk + +The current generation identity prevents reuse across the declared BandScope separation generation, canonical checkpoint filename/signature/checksum prefix, installed Demucs/torch versions, target sample rate, overlap, or device. It is not full checkpoint release provenance and does not prove MIR accuracy. Full checkpoint digest/signature/acquisition provenance/rights, packaged SBOM linkage, rights-cleared real decoded audio metrics, and destructive packaged Windows/macOS crash/power-loss/disk-full evidence remain release/scientific acceptance work. + +## Remaining scientific/release boundary + +The current generation identity is sufficient to prevent reuse across a changed BandScope separation generation, canonical checkpoint filename/signature/checksum prefix, installed Demucs/torch version, target sample rate, overlap, or device. It is **not** release provenance for the checkpoint bytes. `AudioStemSeparator` verifies that the local checkpoint's full SHA-256 begins with the checksum prefix encoded by `955717e8-8726e21a.th`, but immutable release evidence still needs the full checkpoint digest/signature, acquisition provenance, license/rights record, and package/SBOM linkage. + +Scientific acceptance also remains distinct from cache identity. Rights-cleared real decoded audio must demonstrate recognized source-separation/MIR metrics, uncertainty and claim boundaries, and reproducibility on the packaged Windows/macOS paths. A feature-cache hit now means “same admitted source + same declared MIR generation + same admitted derived bytes”; it still does not by itself prove accuracy. + +Packaged Windows/macOS power-loss, disk-full, and fault-injection acceptance also remains release evidence. Unit/integration tests cannot substitute for that destructive packaged-build evidence. + +## Primary implementation and scientific references + +Python Software Foundation. (2026). *importlib.metadata — Accessing package metadata*. Python documentation. https://docs.python.org/3/library/importlib.metadata.html + +Python Software Foundation. (2026). *json — JSON encoder and decoder*. Python 3 documentation. https://docs.python.org/3/library/json.html + +Python Software Foundation. (2026). *os — Miscellaneous operating system interfaces: `os.fsync`*. Python 3 documentation. https://docs.python.org/3/library/os.html#os.fsync + +Python Software Foundation. (2026). *zipfile — Work with ZIP archives*. Python 3 documentation. https://docs.python.org/3/library/zipfile.html + +Microsoft. (2024). *MoveFileExW function (winbase.h)*. Microsoft Learn. https://learn.microsoft.com/windows/win32/api/winbase/nf-winbase-movefileexw + +NumPy Developers. (2026). *Input and output: NumPy binary files (`npz`)*. NumPy reference. https://numpy.org/doc/stable/reference/routines.io.html + +NumPy Developers. (2026). *numpy.load*. NumPy reference. https://numpy.org/doc/stable/reference/generated/numpy.load.html + +Rouard, S., Massa, F., & Défossez, A. (2023). Hybrid Transformers for music source separation. In *ICASSP 2023 - 2023 IEEE International Conference on Acoustics, Speech and Signal Processing (ICASSP)*. IEEE. https://arxiv.org/abs/2211.08553 diff --git a/docs/traceability/final-result-cache-durability.md b/docs/traceability/final-result-cache-durability.md new file mode 100644 index 000000000..5ace42e4c --- /dev/null +++ b/docs/traceability/final-result-cache-durability.md @@ -0,0 +1,73 @@ +# Final-result cache durability traceability + +## Problem + +BandScope's final rehearsal-result cache influenced a buyer-visible rehearsal decision path, but `_store_cached_analysis()` previously wrote JSON to a fixed temporary pathname and used pathname replacement without synchronizing the completed file or the containing directory. A process crash was covered by atomic replacement, but a power loss could occur after the application reported `cacheStatus: stored` and before the new bytes or directory entry reached durable media. Atomicity and durability are different guarantees. + +## Decision + +Project Persistence keeps this cache-publication responsibility on #970. Resource Admission remains the sole authority for admitted audio byte count and SHA-256 identity; the Python cache boundary only consumes that evidence. + +The final-result writer now follows this order: + +1. create a unique temporary file in the target cache directory; +2. serialize the complete JSON payload; +3. flush Python's buffered writer and call `os.fsync()` on the staged file; +4. publish the staged file only after that sync succeeds; +5. on POSIX, replace the target and then fsync the parent directory; +6. on Windows, use `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH` rather than silently falling back to a plain rename; +7. return success only after the platform publication boundary succeeds, otherwise return a cache miss through the existing API caller. + +The temporary name is unique rather than `analysis.tmp`, so concurrent analysis jobs do not share one staging pathname. A publication failure removes only the writer-owned stage when it still exists; it does not fabricate a stored result. + +## RED → fix evidence + +- `6c39f6a33195e75505d57fd9a2a95084437980e6` introduced executable REDs requiring staged-file sync before publication, POSIX parent-directory sync after replacement, and fail-closed API status on publication failure. +- `57c305a5eda541be95a2abfbc1dfa585fbd22340` added the cache durability adapter and platform publication paths. +- `14db687ad69d14b83b0dc1e5c2f1466329f97da0` routed production `_store_cached_analysis()` through that adapter. +- `2cfc06bd925df2c7278ec999f2f0d6a57c258528` bound the simulated durability failure to the actual API import used in production. +- `abc4cf1a59990d241d2ac93ac2444ab3201b4f7f`, `901a50a82766d4a33bf6fca0950ca4594387fc02`, and `8d9c43206d42f48fb4164d486835c2a08d07327a` expanded platform/failure coverage and removed cross-platform strict-check assumptions about Win32-only `ctypes` attributes. + +Hosted exact-head checks remain authoritative. These source commits are not by themselves a release or merge claim. + +## Alternatives rejected + +- **Keep `Path.replace()` only:** atomic namespace replacement does not prove the staged bytes and replacement metadata survived a power loss. +- **Call `fsync()` only on the JSON file:** on POSIX this still leaves the renamed directory entry outside the explicit durability boundary. +- **Use one predictable `.tmp` filename:** concurrent jobs can contend for or overwrite the same staging pathname. +- **Silently use plain `os.replace()` on Windows:** it would make the Windows durability claim weaker than the product contract. The implementation fails closed if the Win32 write-through binding is unavailable. +- **Move source identity into the cache layer:** source authenticity remains Resource Admission's contract; duplicating it here would create a second owner. + +## Security Notes + +### Attack surface + +The final-result cache is a local derived-artifact boundary. Cache bytes, cache directory entries, staging files, and publication/recovery state can be corrupted, replaced, or interrupted independently of the admitted source audio. + +### Trust boundary + +Native Resource Admission remains the sole source-identity authority. Project Persistence owns only the derived rehearsal-result bytes and their durable publication state; it must not infer source authority from filenames, pathnames, or cache location. + +### Mitigations + +Publication uses a unique writer-owned stage, flush plus file `fsync`, platform-specific atomic publication, POSIX parent-directory `fsync`, and Windows `MOVEFILE_WRITE_THROUGH`. Any serialization, sync, publication, or cleanup failure is fail-closed for cache reuse and never reports `stored`. + +### Test points + +Tests cover file-sync-before-publication ordering, unique writer-owned staging cleanup, stage creation failure, parent-directory descriptor cleanup on both success and fsync failure, POSIX replace-before-parent-fsync ordering, Windows replace/write-through flags, unavailable Win32 bindings, Win32 move failure, platform dispatch, and API fail-closed status. + +### Realistic threats + +Realistic failures include process interruption during staging, power loss after byte write but before durable namespace publication, concurrent writers sharing a target, filesystem or recovery tooling leaving partial cache state, and local replacement of derived cache bytes. The boundary adds no network path and logs no raw audio or original source path. + +### Remaining risk + +Filesystem and storage hardware may provide weaker guarantees than requested sync primitives. The current tests prove API ordering and platform calls, not destructive physical power-cut behavior on every supported filesystem. Packaged Windows/macOS crash/power-loss fault injection remains release evidence, as do signing, notarization, updater rollback, and actual-audio scientific acceptance. + +## References + +Microsoft. (2024, November 20). *MoveFileExW function (winbase.h)*. Microsoft Learn. https://learn.microsoft.com/windows/win32/api/winbase/nf-winbase-movefileexw + +Python Software Foundation. (2026). *os — Miscellaneous operating system interfaces: `os.fsync`*. Python 3 documentation. https://docs.python.org/3/library/os.html#os.fsync + +Python Software Foundation. (2026). *tempfile — Generate temporary files and directories*. Python 3 documentation. https://docs.python.org/3/library/tempfile.html 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-migration-receipts.md b/docs/traceability/project-migration-receipts.md new file mode 100644 index 000000000..45445913b --- /dev/null +++ b/docs/traceability/project-migration-receipts.md @@ -0,0 +1,123 @@ +# Project migration receipts + +## Problem + +BandScope accepts legacy, v1, and v2 project documents and migrates them to the current v3 typed document. The first implementation returned only the migrated document, so Project Persistence could not prove which exact input bytes were admitted or which exact current-version bytes were produced. Later revisions added deterministic receipts and a validated `PreparedProjectMigration`, but filesystem and buyer-path gaps remained. + +First, the bounded reader originally returned only UTF-8 content. A publisher would have had to recapture native identity from the pathname after parsing, allowing a different file to occupy the same pathname after the read. `ProjectFileReadSnapshot` fixed that by binding the bounded content to the native identity of the exact opened handle. + +Second, native identity is not a content compare-and-swap token. A cooperating or external writer can modify the same inode/file index in place while preserving device/inode on Unix or volume serial/file index on Windows. The ordinary atomic replacement path therefore could still accept a migration derived from stale bytes if it checked only the displaced object's native identity. The same publication also needed to prove that the buyer-visible target still contained the exact canonical migration candidate before rollback material was retired. + +Third, those primitives were not part of the production load path. `load_project` recovered a selected target, read it through the compatibility bounded string reader, migrated only in memory through `project_document_from_content`, and returned that document. A successful open therefore did not durably publish the validated current-format copy even when the selected project was historical. + +Fourth, the first receipt-aware replacement validated both exact byte sets before promoting its journal to `published`, but the durable journal stored only native identities. A crash after journal promotion and before cleanup therefore reopened a gap: while BandScope was down, the published candidate or displaced known-good predecessor could be modified in place without changing native file identity, and restart recovery would have deleted rollback material after checking identities only. + +## Decision + +`ProjectMigrationReceipt` remains the canonical content-addressed migration evidence. It records source format version, target format version, SHA-256 of the exact input bytes, SHA-256 of the deterministic current-version output bytes, and whether historical migration occurred. + +`prepare_project_migration` remains the migration-on-copy preparation boundary. It parses through the existing version dispatch, serializes through `project_content_for_document`, hashes the candidate, reopens those exact candidate bytes through the current parser, serializes them again, and returns `PreparedProjectMigration` only when the reopened version is current and canonical bytes reproduce exactly. The prepared value is sealed; external crates receive immutable `document()`, `canonical_content()`, and `receipt()` views and cannot construct or mutate its validated state. + +The receipt owns executable byte verification rather than requiring persistence adapters to duplicate SHA-256 comparison logic. `verify_input_reader` hashes an already-authorized reader with the repository-owned SHA-256 kernel and fails closed unless it equals `input_sha256`. `verify_output_reader` does the same for `output_sha256`. Neither method opens a pathname or claims filesystem authority; the caller must supply the exact native object it intends to validate. + +Project Persistence keeps `read_project_file_with_identity` as the predecessor-authority primitive. It performs the existing bounded, no-follow/reparse-safe, before/opened/after stability checks and returns a sealed `ProjectFileReadSnapshot` with content plus native identity from the same opened file. + +For migration replacement, Project Persistence has a receipt-aware replacement path layered on the existing journal/exchange owner. Linux/macOS use the existing atomic exchange; Windows uses the existing `ReplaceFileW` backup path. After replacement, but before the prepared journal is promoted to the durable `published` marker or rollback material is removed, the migration validator: + +1. opens the displaced predecessor through the existing no-follow native reader; +2. requires that exact opened handle to retain the read-bound native identity; +3. applies `receipt.verify_input_reader` to that same handle; +4. opens the published candidate through the same native authority boundary; +5. requires the exact opened target handle to retain the staged candidate identity; and +6. applies `receipt.verify_output_reader` before publication is committed. + +If either native identity or either digest check fails, the existing rollback path restores the displaced object and cleans the candidate/journal. A crash after native replacement but before those validations leaves the journal in `prepared`, whose recovery behavior is conservative rollback. + +The recovery journal is now schema version 2 and carries an explicit validation mode. Ordinary saves use `identity_only`; migration publication embeds the exact `ProjectMigrationReceipt` in `migration` validation. Published migration recovery therefore reopens the buyer-visible candidate through its expected native identity and replays `verify_output_reader`; if the displaced known-good artifact still exists, it also reopens that exact object and replays `verify_input_reader` before deleting either rollback material or journal. Prepared migration recovery likewise verifies the predecessor digest before restoring it or discarding an uncommitted candidate. A version-1 identity-only journal is not silently upgraded: recovery fails closed and leaves its artifacts intact for explicit handling. + +This closes the post-validation crash window without making content identity a signature or changing generic save semantics. Native identity remains the filesystem-object authority; the migration receipt remains the exact-byte authority. Cleanup is legal only when both are still true at recovery time. + +The ordinary save path still uses the same shared replacement implementation with `identity_only` validation. This preserves existing save semantics while keeping the migration-specific content-CAS rule isolated to the migration contract instead of changing generic user-save concurrency policy implicitly. + +Production load composes those owners through `project_load::load_project_document`. It first runs publication recovery, acquires one `ProjectFileReadSnapshot`, prepares those exact bytes, and does nothing to on-disk bytes when `receipt.migrated` is false. For historical input it creates a same-directory generated candidate through the existing crash-safe `publish_new_project_file` owner, preserves existing Unix project-data permission bits through no-follow file handles, and invokes `replace_existing_project_file_for_migration` with the original snapshot identity and receipt. A successful migration returns `prepared.document().clone()` rather than reparsing the pathname after publication. + +This orchestration does not implement a second rename, journal, rollback, or hash engine. Candidate staging is delegated to the existing Project Persistence publisher and final replacement is delegated to the receipt-aware compare-and-swap primitive. The Tauri `load_project` command now calls this owner before restart source re-admission. + +## RED / GREEN evidence + +- RED `ef4e0a4e6f0809d611a50d51b30870edd899732f` required source/target versions plus exact input/output digests. GREEN `e833187ac50fe3e71aa51850a5266872ab6a7bb6` added `ProjectMigrationReceipt`; `74d5bcc3245056d5b62003e89b5a6deaa657dc47` proved current-version re-admission idempotency. +- RED `2c46d0c2ea98604ae7241f0390e8e9cfea4de6d7` required a self-validating canonical candidate. GREEN `a137fd9d2258b573df719de27a0043a8351a928f` added `PreparedProjectMigration` / `prepare_project_migration`; `04ab3cc33539c16568db73f41884cd618129bc78` exported it through the GUI-independent crate root. +- RED `5ff80277e24e82c7803c8910fe8b892f779230fd` required immutable accessor consumption. GREEN `fd2e4f2b5e781ad8cd0597038a728d3840584d1c` sealed the prepared value. +- RED `f1f374a34a6eae44f8c7f28f285c404cf1aa8fef` required bounded reads to retain the exact opened-file identity after a pathname replacement. GREEN `cc0755bfd830dc187bfdc18f2ee1d718ec060ce7` added `ProjectFileReadSnapshot` and `read_project_file_with_identity`. +- RED `eeb2b71a94c39c00c0dbf410b1ef0f428b7246c6` required the receipt itself to verify exact input bytes instead of leaving digest comparison to each adapter. GREEN `53ab33133550dbeaca8c7c404ba4b6bbfd2afd81` added fail-closed `verify_input_reader` using the existing SHA-256 owner. +- RED `26cf9e6586e127c6bcab3f500ba75b07f0d85d05` extended the same contract to the validated output bytes. GREEN `7b4c6e5e609f7074eabd2fca5cee5da4dfb08017` added `verify_output_reader` through the same private receipt-verification helper. +- RED `847c31da5203ecf625762322f630c4c8fb302378` added a native migration-publication regression. It modifies the already-read predecessor in place, proves native identity did not change, and requires migration publication to roll back because the exact input digest changed. It also requires the unchanged predecessor plus exact canonical candidate to commit successfully. The predecessor had no receipt-aware replacement API, so the contract could not compile. +- GREEN `a46be0d83531e49e9906d104a16197cb13d44c3b` factors the existing Linux/macOS and Windows replacement implementations through a post-replacement validator and adds `replace_existing_project_file_for_migration`. The migration validator reopens both displaced predecessor and published candidate through no-follow native handles, rechecks their expected native identities on those handles, and verifies exact receipt input/output digests before the existing success path can retire rollback material. +- RED `c406080919eeabb6df868fcfb20b01a29394208d` required the buyer path to publish a checked-in v2 fixture through the receipt-bound migration owner and required a current v3 file with incidental whitespace to remain byte-for-byte unchanged. The requested load owner did not exist at that head. +- GREEN lineage `d50b9e396f4feeeeef2873f5c779f71df6f6ca45` → `ee906cf08e210923f80bea94b40edb9e84818325` → `e610db4f782d0bb032d1a7390425cb54f97bb9b4` added the migrate-on-load application service, exercised it through the native integration fixture, and routed the production Tauri command through it. `28daa667ad72cb918a7f246458d13cbf62d952bc` fixed source formatting before hosted lint, `9a4554e8681a9f5d3bbb5521bc341d1c78f1bc17` repaired the old route-contract test so it checks the new identity-bearing owner rather than the removed compatibility call, and `bd318f91e882dbfa538e28e9ae0f6197b859769b` added Unix regression coverage proving migration preserves existing project-data permission bits. +- RED `927c4aa4c28bacacaaf8641594abd26ea282caf8` models the crash window after a durable published marker exists. It requires a version-2 migration journal with the receipt to clean exact candidate/predecessor artifacts, requires an in-place candidate byte mutation with unchanged native identity to preserve rollback evidence, and requires legacy version-1 identity-only published journals to fail closed instead of deleting the known-good predecessor. +- GREEN `44525806a1c69895d94704f578c64fd92e740691` versions the recovery journal, records `PublicationValidation::Migration { receipt }`, replays receipt verification during both published cleanup and prepared rollback decisions, and rejects version-1 identity-only journals. `bc892e34105d5098ad24344560d2365e1b57b4c4` moves the existing recovery-cleanup fixtures to the explicit version-2 `identity_only` schema without weakening their behavior. + +Hosted exact-head CI/security/SBOM/SAST/native-build evidence is required separately; a source commit is not treated as hosted GREEN until those checks are terminal on the exact head. + +## Rejected alternatives + +A timestamp or random migration id was rejected because it makes receipts nondeterministic. Hashing parsed JSON rather than exact bytes was rejected because whitespace or byte-level changes would disappear from evidence. A second hashing implementation was rejected because SHA-256 already has one repository owner. + +Recapturing identity from the target pathname immediately before replacement was rejected because a post-read pathname occupant is not necessarily the file that produced the migration input. Native identity alone was rejected because in-place writes can retain it. Comparing free-form digest strings inside the Tauri adapter was rejected because it duplicates receipt semantics and makes input/output error handling diverge. + +Validating only the displaced predecessor was rejected because the published target is also buyer truth: a same-object post-replacement write could change candidate bytes while native identity remains stable. Both predecessor and candidate therefore use identity plus exact receipt digest. + +Deleting rollback material immediately after the native exchange was rejected. Validation occurs while the displaced object still exists. A failure restores or retains it rather than turning a detected race into data loss. + +Treating journal promotion as permanent proof that bytes can never change was rejected. The published marker records that validation completed at commit time; it does not freeze the target inode/file-index or displaced backup while the process is down. The receipt therefore travels with the version-2 migration journal and is replayed before crash recovery retires rollback material. + +Accepting identity-only version-1 published journals as if they carried the new evidence was rejected. They cannot prove exact candidate/predecessor bytes, so recovery preserves them and fails closed instead of silently deleting the only known-good copy. + +Writing a second migration-specific atomic-publication engine was rejected. The receipt-aware path composes the existing journal, exchange/`ReplaceFileW`, rollback, synchronization, and recovery machinery and changes only the post-replacement acceptance predicate. + +Reusing `publish_new_project_file` directly on the selected historical project was rejected because that generic publisher snapshots whichever target occupies the pathname at publication time. The migrate-on-load owner instead uses it only to create the adjacent candidate; final authority comes from `replace_existing_project_file_for_migration` with the original identity-bearing read snapshot and receipt. + +Reparsing the selected pathname after a successful migration was rejected because another process can replace that pathname after publication. The command returns the sealed prepared document that generated the committed candidate; restart source re-admission consumes that document's typed source reference separately. + +## Security Notes + +### Trust boundary + +Project bytes and filesystem state are untrusted inputs to Project Persistence. A receipt proves deterministic byte identity only; it is not a signature or authenticity claim. Native identity proves local filesystem object continuity only; it is not content identity. Migration publication accepts only the conjunction of both forms of evidence. + +Receipt verification accepts an already-authorized `Read` object and never opens paths. The Tauri persistence layer owns path/native-handle authority and uses its existing no-follow/reparse-safe open contract. The exact opened handle used for digest verification is also checked against the expected native identity before its bytes are consumed. + +The migrate-on-load layer never treats its generated staging pathname as predecessor authority. Staging goes through the existing safe publisher, final commit goes through the receipt-aware replacement, and publication failure returns an error without substituting a different document. On Unix only read/write permission bits from the selected project data file are copied to the candidate; executable and special bits are not introduced. + +The adjacent journal is crash-recovery evidence, not an authentication artifact. A local actor with write authority over the project directory is outside the integrity guarantee of an unsigned local file. Within the crash-consistency boundary, however, migration recovery never interprets native identity alone as proof of exact bytes and never discards a still-present known-good predecessor after a receipt mismatch. + +### Safe failure and crash boundary + +Malformed or unsupported projects fail before a prepared migration exists. Candidate parser/serializer disagreement fails before staging. After atomic replacement, native-identity or receipt-digest mismatch routes to the existing rollback path and returns the bounded project-publication error. + +A process interruption before the receipt-aware validator finishes leaves the durable journal in `prepared`; recovery verifies the exact predecessor before restoring it. Promotion to `published` records that validation succeeded at commit time, but restart recovery still replays candidate and available predecessor receipt checks because an in-place write can preserve native identity while BandScope is down. Cleanup is therefore an evidence-consuming operation, not a blind consequence of journal phase. + +A current v3 project is parsed through the same prepared boundary but is not rewritten merely to canonicalize insignificant byte representation. This avoids turning ordinary open into an unexpected write for already-current projects. + +### Privacy + +Receipts contain versions and SHA-256 values only. `ProjectFileReadSnapshot` carries bounded content and local native identity in memory but no path. No username, hostname, audio bytes, playback URL, account id, or secret is added to migration evidence. Raw project JSON remains publication material, not routine diagnostic payload. + +### Test points + +`apps/desktop/core/tests/project_migration_receipt_input_binding.rs` verifies exact input and output bytes and rejects parse-equivalent byte changes. + +`apps/desktop/src-tauri/tests/project_persistence_read_identity.rs` proves the bounded read retains the original opened-file identity after the selected pathname is replaced. + +`apps/desktop/src-tauri/tests/project_persistence_migration_content_cas.rs` proves that an in-place predecessor byte change with unchanged native identity cannot commit migration publication, an exact predecessor plus exact candidate can commit, a checked-in historical project is migrated through the load owner, a current v3 project is not spuriously rewritten, and Unix migration preserves existing project-data permission bits. + +`apps/desktop/src-tauri/tests/project_persistence_published_recovery_content_cas.rs` proves that published recovery cleans only exact receipt-bound migration artifacts, preserves the known-good predecessor and journal after an in-place candidate mutation, and rejects identity-only version-1 published journals. + +The `project_persistence.rs` source-route regression verifies that the Tauri command uses `project_load::load_project_document` and that the load owner derives authority from `read_project_file_with_identity` rather than the obsolete compatibility string route. + +## Remaining risk and follow-up + +Source-level migrate-on-open wiring and receipt-bound crash-window recovery are now present, but exact-head hosted CI/native-build/SBOM/SAST/security and qualifying independent review still gate any GREEN or merge claim. Packaged fault injection must still prove the complete load-time migration path under interruption, disk-full, permission failure, and power-loss conditions on supported Windows and macOS targets. + +Known-good retention beyond the immediate rollback window, application downgrade behavior, bounded autosave, global startup recovery discovery, accessible Restore / Compare / Discard UX, and packaged Windows/macOS cancellation evidence remain open under #962. Resource Admission owner #866 remains a separate protected prerequisite for source-audio re-admission; this Project Persistence slice does not copy or bypass it. diff --git a/docs/traceability/project-persistence-native-ci.md b/docs/traceability/project-persistence-native-ci.md new file mode 100644 index 000000000..61fd9b5ab --- /dev/null +++ b/docs/traceability/project-persistence-native-ci.md @@ -0,0 +1,138 @@ +# Project Persistence native CI coverage + +## Problem + +Project Persistence has platform-specific behavior that Linux-only or compile/package smoke evidence cannot substitute for. Windows uses `ReplaceFileW`, native volume/file-index identity, and reparse-point boundaries. macOS uses `renamex_np`/`RENAME_SWAP`, no-follow file opens, and trusted root-alias handling. `build-baseline` can prove that the native shell compiles and packages, but it does not execute the Tauri integration regressions for migration publication, rollback, recovery journals, path authority, or exact native identity. + +The first native-CI repair found two evidence defects. The original Windows workflow did not track every direct persistence owner input and there was no symmetric macOS integration lane. After the trigger contract was repaired, macOS materialized but the old Windows workflow repeatedly did not. Repository evidence established the non-materialization, not its GitHub backend cause, so the workflow was replaced by a single canonical successor rather than being described as “disabled.” + +Once Windows actually ran, it exposed a fixture portability defect: run `35389890487`, job `105745527622` reached the real Tauri suite and failed only `synced_source_publication_moves_the_owned_stage_only_after_durable_no_replace_publish`. The test had reopened a staged file read-only before `sync_all`; Windows correctly returned `ERROR_ACCESS_DENIED` because durable file-buffer flushing requires write authority on that handle. The fixture was repaired without skipping Windows or weakening the durability assertion. + +A later migration-recovery change exposed a second hosted integration defect. Exact `b1edaf362d837002b833201fadca2a3cf29c4775`, macOS run `35400310428`, job `105778460931`, failed with Rust `E0061` because a rollback regression still used the pre-`PublicationValidation` test adapter. That adapter was repaired to exercise explicit `IdentityOnly` semantics rather than inventing migration evidence. + +Native logs then exposed warning debt under #1235. The first root cause was semantic: compatibility `runtime_core` still carried a v1 project serializer and a public `CURRENT_PROJECT_FORMAT_VERSION = 1` surface after canonical current writing had moved to v3 `project_format`. The second root cause was test architecture: thirteen Project Persistence integration targets each `#[path]`-compiled or `include!`-compiled the complete private `project_persistence.rs`, and some separately compiled `project_load.rs` or `project_root.rs`. That multiplied unrelated `dead_code` diagnostics and re-ran the embedded `#[cfg(test)]` persistence tests in each integration crate. + +The consolidation removed that multiplication, but exact predecessor `522ef9036a7b0b1a90ee1a9234a23298a0f42303` still exposed two genuine warning owners on Windows run `35405419970`, job `105794080572`: `trusted_macos_root_alias_target` was compiled on Windows only because generic `test` was part of its source gate, and `read_project_file` remained a production String-only projection after migrate-on-load had moved to the identity-bearing reader. + +## Decision + +Windows and macOS Project Persistence workflows are owner evidence, not optional packaging smoke tests. They remain read-only (`contents: read`), use the repository-pinned checkout SHA and Rust 1.97.1, and execute the same native test command with the owner-scoped warning feature enabled: + +`cargo +1.97.1 test --manifest-path apps/desktop/src-tauri/Cargo.toml --no-default-features --features persistence_warning_gate --tests` + +The Windows owner is `.github/workflows/project-persistence-windows-native.yml`; the legacy Windows workflow file is absent. The macOS owner is `.github/workflows/project-persistence-macos.yml`. Both workflows track the direct persistence source, the core crate root that owns the warning gate, core project-format contracts and fixtures, native test inputs, workflow-policy regression, and this traceability document. + +The warning-debt repair keeps canonical ownership narrow. `runtime_core` parses strict historical/v1 input only; `project_format` owns current v3 parsing, migration normalization, and serialization. No deprecated duplicate writer, fake reference, lint allowlist, broad `RUSTFLAGS`, log filtering, test skip, or gate reduction is used. + +For native integration tests, the production persistence owner is included once in `apps/desktop/src-tauri/tests/project_persistence.rs`. Case bodies live in `project_persistence_*.case` modules so Cargo does not auto-discover each as a separate integration crate. `project_load.rs` and `project_root.rs` are likewise included once where those cases need the private application boundary. The rollback regression’s access to private journal helpers remains a test-only adapter inside the same included persistence module; production visibility is not widened. + +The source boundary matches platform and production use. `trusted_macos_root_alias_target` exists only on macOS, where the trusted-root alias policy and its native case consume it. The legacy String-only `read_project_file` projection is explicitly `#[cfg(test)]`; production project loads remain on `read_project_file_with_identity`, while the generic injected-opener helper continues to serve bounded recovery-journal reads and TOCTOU regressions. + +Warning absence is enforced at compile time rather than inferred from text logs. The opt-in Cargo feature `persistence_warning_gate` propagates from `bandscope-desktop` to `bandscope-desktop-core`. Under that feature, the GUI-independent core crate root and the single Project Persistence integration harness apply `deny(warnings)`. This covers the two owned compilation boundaries that produced #1235 findings while leaving third-party dependency lint levels unchanged. The native workflow-policy regression rejects a missing feature invocation and also rejects `RUSTFLAGS` or grep-based warning gates. + +## RED / GREEN evidence + +### Native lane registration and portability + +- RED `4008dab7a5aa9092c37559fcc17632dfee8d7e57`: policy regression requires `project_load.rs`, `project_root.rs`, and a macOS owner lane. +- GREEN `cc80424c4e048cbbc0337eae530c572d083a5200`: repairs the original Windows path contract. +- GREEN `e0566c363604c2a29a17c09105123d220e57effc`: adds the macOS native lane. +- RED `1726e9bfa1455dd3b9cb6afb93b18aede2befcd7`: both workflows must track traceability/policy inputs. +- GREEN `488b89d56b3dc8d2e9d34ac72dd69bb065e0350a` and `ad9087ac83e8e513e7a665c7d6bc6757d27d79b6`: exact-head trigger refresh contract. +- RED `488b2decf961d2ce28c8fdefe48d456cfdbe2f27`: policy requires the Windows successor identity and absence of the legacy file. +- GREEN `c6045d8d743f4d0b997f706fc1b35c27a666b16b` + `61f7d5654f9618e098ba6c031980d4cdc48a2c06`: creates the successor and removes the duplicate Windows owner. +- HOSTED RED `05ed6c1b10d89d470281edaae2a7c3c68785ad68`, run `35389890487`, job `105745527622`: Windows reaches the suite; 20/21 atomic-publication tests pass and the read-only `sync_all` fixture fails with OS error 5. +- GREEN SOURCE `62ac8f8e46acb26fc542c4e995cc530ebc295090`: stage is reopened read/write before the same durability flush. Exact descendant `fddd9965043c9b67a5542d6db45f79661a032734` subsequently passes both native lanes. + +### Recovery-journal adapter + +- HOSTED RED `b1edaf362d837002b833201fadca2a3cf29c4775`, macOS run `35400310428`, job `105778460931`: stale rollback adapter fails Rust `E0061` after `PublicationValidation` becomes explicit. +- GREEN SOURCE `d8edb4a9f6eb422eb7561a763c9692a499dee492`: adapter explicitly uses `PublicationValidation::IdentityOnly`; that exact head passes both native lanes. + +### #1235 current-format writer ownership + +- FINDING: native warning output showed that v1 compatibility code still exposed a second project writer and a misleading public “current version = 1” surface. +- GREEN SOURCE `5c1a26e0ce8945614ea740320492936fff0365eb`: removes the obsolete v1 serializer, replaces the public current-version surface with private `LEGACY_PROJECT_FORMAT_VERSION`, and retains strict historical parsing only. +- GREEN HYGIENE `6156d35a5665d19bde7424e2f6cc4f7021aeb963`: consumes the deserialized v1 version field and removes incidental declaration-order churn. +- TRACEABILITY `7f55bfc8ce049bc08525257a7a69ed59a97e2627`: records the single-writer decision and leaves the per-test compilation architecture explicitly open. + +### #1235 single-compile integration harness + +- FINDING at `7f55bfc8ce049bc08525257a7a69ed59a97e2627`: thirteen native Project Persistence integration targets independently compiled the complete private persistence source, multiplying unrelated `dead_code` diagnostics and embedded unit-test execution. +- GREEN SOURCE `69d54d40191fca3e39fbd540d4a2a165675c1eaa`: consolidates the integration cases under one `tests/project_persistence.rs` harness; former auto-discovered `.rs` case files become `.case` modules; private rollback helpers stay test-only inside the included owner module. +- GREEN CI CONTRACT `582ec084cf56838d1d2a06048cef716b03ee2796`: Windows/macOS workflows and policy regression track the new `.case` inputs. +- HOSTED RED `582ec084...`, macOS native job `105791463150`: the real consolidated harness fails with `E0432` because the nested rollback case imported `super::project_persistence`; after consolidation its immediate parent is the case module, not the integration crate. +- GREEN SOURCE `92136baa8538a68a527be863ec3c9606256f77ac`: changes only that case import to `crate::project_persistence`. +- HOSTED GREEN for structural repair: exact `92136baa...` macOS run `35404731489` and Windows run `35404731472` both complete successfully. macOS runs one consolidated `tests/project_persistence.rs` target with 57 passing Project Persistence cases. This proves test consolidation, not warning-free production. +- GREEN COVERAGE `2b0d5d9670b7b6906d469ba96b12d0a7386a77b0`: exercises the real `publish_synced_file_noreplace` production wrapper through a durably flushed stage and native no-replace publication, removing the harness-only unused-symbol cause with behavioral coverage rather than an artificial symbol reference. + +### #1235 platform/test ownership cleanup + +- HOSTED FINDING at exact `522ef9036a7b0b1a90ee1a9234a23298a0f42303`, Windows run `35405419970`, job `105794080572`: the consolidated harness is functionally GREEN (`46 passed`, 0 failed), but rustc reports `trusted_macos_root_alias_target` unused in both the Windows integration/test build and binary test build, plus production `read_project_file` unused in the binary. A passing test verdict is not treated as warning-free evidence. +- GREEN SOURCE `b947e559ff32bf50476d808b870692f68401f74c`: narrows `trusted_macos_root_alias_target` from `cfg(any(target_os = "macos", test))` to macOS only and scopes the String-only `read_project_file` projection to `cfg(test)`. Identity-bearing production load, publication, migration and recovery logic are unchanged. +- HOSTED WARNING-CLEAN EVIDENCE for `b947e559...`: Windows run `35406483645`, job `105797186346`, succeeds with the consolidated 46-case Project Persistence target; macOS run `35406483687`, job `105797186480`, succeeds with the 58-case Project Persistence target. The inspected rustc/cargo output contains no `warning:` diagnostics. This proves the source cleanup on that exact predecessor, but it is not transferred as the final verdict for later semantic descendants. + +### #1235 compile-time warning enforcement + +- RED `29e52d88952ae5dae37f3a1a0216d89db4f73c1c`: workflow-policy regression requires an explicit owner warning-gate feature and compile-time warning denial without `RUSTFLAGS` or grep-based filtering. The then-current workflows/manifests do not satisfy it. +- GREEN CORE FEATURE `a2d8c74b5e7862b42381dbe24c5bfe3c3a659c31` + `e0ead7d5ed05ed82d337ceee59914d0f0da1b130`: defines `bandscope-desktop-core/persistence_warning_gate` and applies `deny(warnings)` at the GUI-independent core crate root only when that gate is active. +- GREEN DESKTOP FEATURE `61aca5c27cd9bf78dcf8ed2422cf57484c9ae842`: propagates the opt-in feature from the Tauri crate to the core crate. +- GREEN HARNESS `a3861a0e40934429475e6452d00b60023d4ba928`: applies the same feature-gated `deny(warnings)` to the single Project Persistence integration harness; production visibility is unchanged. +- GREEN POLICY SCOPE `9b2b8548f1aca5c9635c69dea9f9cf0c81198299` + `14e54bcc974609a46aff9cad9c2b2f4f44fd7e3c`: narrows the policy to the owned core + persistence harness boundaries and requires the core `root.rs` to be a native workflow trigger input. +- GREEN WINDOWS `2f1d4c7a0ad07c736caf1eebed603cda3a513e16` and GREEN macOS `bb832ae049abacedbbf000b4ed7d508f5b929e02`: both native workflows invoke `--features persistence_warning_gate`, track `core/src/root.rs`, and retain the same real native test suite. No global/dependency `RUSTFLAGS` or output filtering is introduced. +- EXACT-HEAD VERDICT: every semantic/document descendant must reacquire both native owner results under the compile-time warning gate before #1235 can be considered complete. + +### Warning-gate crate-root export regression + +- HOSTED RED exact `15d030521b598daad0fc503213921e4862cdc279`: Windows native run `35407025318`, job `105798775166`, and macOS native run `35407025272`, job `105798775295`, both fail before test execution with Rust `E0432`. `core/src/root.rs` tries to re-export `readmit_project_source_reference`, `SourceReadmissionDecision`, `SourceReadmissionFailure`, and `SourceReadmissionResult`, but `source_readmission.rs` owns `re_admit_local_audio_publication`, `re_admit_local_audio_publication_from_project_root`, and `ReAdmittedLocalAudioPublication`. `build-baseline` run `35407025301` independently fails the native shell build for the same unresolved imports, so this is a source regression rather than a dedicated-workflow artifact. +- RCA: the warning-gate crate-root edit at `e0ead7d5ed05ed82d337ceee59914d0f0da1b130` did not preserve the already-established source-readmission export contract visible in its parent `a2d8c74b5e7862b42381dbe24c5bfe3c3a659c31`. The intervening API was read and restored; the concurrent history is not treated as a race. +- GREEN SOURCE `269db5b726bdeb98f08498daae58c62824268d0a`: restores only the existing `re_admit_*` functions and `ReAdmittedLocalAudioPublication` crate-root exports while retaining the feature-gated `deny(warnings)`, migration receipt/preparation exports, private module boundary, and downstream analysis-source contract. No Resource Admission implementation is copied and no visibility is widened. +- EXACT-HEAD VERDICT: this source repair is not terminal evidence. Windows/macOS warning-gated native jobs, build-baseline, and other applicable gates must materialize and finish on the final descendant; predecessor success is not transferred. + +### Recovery-journal path confinement + +- FINDING: schema-v2 recovery journals are adjacent local state and therefore untrusted on reopen. The serializer writes `target_name`, `candidate_name`, and `displaced_name` from `file_name()`, but the deserializer previously reconstructed them with `parent.join(decoded_name)` without proving the decoded value was still one path component. `generated_stage_name` inspected only the resulting `file_name()`, so `../.bandscope-stage-.stage` could satisfy the generated-name check. This is a concrete CWE-22 restricted-directory escape: external serialized input influenced a pathname intended to remain under the selected project parent. +- RED SOURCE `4b617a5c67487faaec7ae364fb44f2f5e6d1517d` + harness activation `49bc47ceb47d03925e9f19fa6c6177d097f5522d`: a hostile durable journal rewrites `candidate_name` to a parent traversal and binds `candidate` to the real native identity of an external generated-looking sentinel. +- HOSTED RED exact `49bc47ceb47d03925e9f19fa6c6177d097f5522d`: macOS run `35420208765`, job `105836323266`, compiles successfully under `persistence_warning_gate`; the Project Persistence harness runs 59 cases with 58 passing and only `journal_path_boundary::recovery_rejects_parent_traversal_in_serialized_candidate_name` failing because recovery accepted the escaped candidate path. +- GREEN SOURCE `e36447421cd1ea9492685c0f91fc28865c9e5035`: `path_from_journal_name` now decodes the native Unix bytes or Windows UTF-16 units to a relative `PathBuf`, requires exactly one `Component::Normal`, and only then joins it to the selected project parent. Rust distinguishes normal components from `ParentDir`, `RootDir`, `CurDir`, and Windows `Prefix`, so parent traversal, root, Windows prefix, empty, and multi-component names fail before identity lookup, cleanup, rollback, or alias comparison. Valid journals are unchanged because the canonical writer already persists only `file_name()`. +- SECURITY NOTES: attack surface is mutable adjacent recovery-journal state; the trust boundary is serialized path metadata becoming a filesystem authority during crash recovery. Before this fix, a forged journal plus a matching native identity could direct cleanup/rollback at an external generated-looking file. The fix applies an allow-known-good structural rule at the decoding boundary while preserving bounded no-follow journal reads, regular-file checks, native identity checks, migration SHA-256 receipts, and the existing no-global-scan model. No filesystem path is added to logs or persisted product data. Descriptor-bound protection against a parent-directory swap and packaged power-loss behavior remain separate #962 work. +- REJECTED: checking only the `.bandscope-stage-*.stage` basename is insufficient because it ignores containing-directory authority. Canonicalizing hostile names before validation is rejected because it would follow filesystem state outside the selected parent. Call-site-only filtering is rejected because `recover_project_publication` is the Project Persistence owner and must itself fail closed. +- EXACT-HEAD VERDICT: `e364474...` is the source repair, not terminal release evidence. This traceability descendant must reacquire Windows/macOS warning-gated owner success plus all repository-wide applicable gates before any Ready/merge claim. + +## References + +MITRE. (n.d.). *CWE-22: Improper limitation of a pathname to a restricted directory ('Path Traversal')* (CWE 4.20). Retrieved September 19, 2026, from https://cwe.mitre.org/data/definitions/22.html + +The Rust Project. (n.d.). *Component in std::path*. Retrieved September 19, 2026, from https://doc.rust-lang.org/std/path/enum.Component.html + +## Rejected alternatives + +`build-baseline` alone is insufficient because it does not execute platform-specific persistence/recovery tests. + +Predecessor check results are not transferred to a later source head. No-op commits used only to retrigger Actions are rejected; every commit here changes a test, trigger, workflow identity, ownership boundary, warning gate, or traceability contract. + +Keeping both Windows workflow files is rejected because it creates duplicate evidence owners. Describing the old workflow as disabled is rejected because the available evidence proves only repeated non-materialization, not the backend cause. + +A Windows `cfg` skip, accepting OS error 5, or removing `sync_all` is rejected because it would erase the durability condition that the platform-specific lane exists to test. + +A deprecated v1 writer, test-only duplicate writer, fake call, `#[allow(dead_code)]`, broad warning suppression, or output filtering is rejected. Current-format serialization has one owner and warning debt is repaired at its cause. + +Making private Project Persistence capabilities broadly `pub` for integration tests is rejected. The consolidated harness preserves the crate-private production boundary and supplies only narrow test adapters inside its private included module. + +Generating copied test source with a build script/codemod is rejected because it creates a self-modifying/source-copy workflow and another mutable representation of the owner. + +Keeping the macOS alias helper alive on Windows merely because `cfg(test)` is set is rejected; the mapping is a macOS policy and has no Windows semantic consumer. Keeping the String-only reader in production merely to silence diagnostics is also rejected; production migration authority is intentionally identity-bearing. + +`RUSTFLAGS=-Dwarnings` is rejected because it is process-global to the Cargo invocation and also changes dependency compilation. Grep/log parsing is rejected because it treats rendered output as the contract. The feature-gated crate/harness attributes make warning absence a Rust compile-time property of the owned Project Persistence boundaries instead. + +Reintroducing the stale `SourceReadmission*` API names or manufacturing compatibility aliases merely to satisfy `root.rs` is rejected. The actual source-readmission owner and its downstream consumers already use the `re_admit_*` contract; the causal fix is to restore the crate root to that contract, not create a second API surface. + +## Claim boundary + +Dedicated native Windows/macOS success proves only that the relevant integration suite compiles and passes on those hosted platforms for the exact head tested. It does not prove packaged process-kill, disk-full, permission-failure, power-loss, signing/notarization, updater rollback, or release immutability. + +The single-harness repair proves that Project Persistence integration cases no longer each compile their own copy of the production persistence source. The feature-gated warning policy proves that owned core/harness warnings become compile errors when the native owner workflows run; it does not claim that third-party dependencies are warning-free. + +Recovery-journal path confinement proves that serialized journal names cannot lexically escape the selected project parent through path components. It does not upgrade the existing parent-path authority to a descriptor-bound directory capability and does not substitute for packaged interruption or power-loss fault injection. + +General CI, security/SAST, SBOM, build-baseline, protected ancestry, Resource Admission #866 integration, and independent current-head review remain separate gates. No Ready transition, merge, tag, signing, or release is authorized solely by this document or by a predecessor native run. diff --git a/docs/traceability/project-persistence-private-artifacts.md b/docs/traceability/project-persistence-private-artifacts.md new file mode 100644 index 000000000..84a067f7a --- /dev/null +++ b/docs/traceability/project-persistence-private-artifacts.md @@ -0,0 +1,38 @@ +# Project Persistence private durable artifacts + +## Problem + +Project Persistence already preserved an existing Unix project file's read/write mode during overwrite, but two owner-created durable artifact classes still relied on the ambient process `umask`: the staging inode for a brand-new project and the adjacent recovery journal. With a permissive `umask(000)`, Rust's ordinary `File::create_new` path produced mode `0666`. The no-replace hard-link/rename publication path then retained that mode for the final new project, while recovery-journal creation exposed the same default-mode behavior. + +That is a buyer-visible confidentiality and integrity gap on multi-user Unix hosts. A project may contain rehearsal structure, local source references, notes, and other user-owned state, while a recovery journal carries publication identities and migration evidence used to decide cleanup or rollback. Neither artifact should become group/world readable or writable merely because the host process inherited a permissive `umask`. + +## Constraints and decision + +Project Persistence remains the single writer for project publication and recovery metadata. The repair does not introduce a second persistence API, mutate the process-wide `umask`, or change Windows ACL semantics. + +On Unix, owner-created staging files and recovery journals are now opened with `create_new(true)` and an explicit `0600` creation mode before any bytes are written. A replacement of an existing project keeps the established policy: after staging, the existing target's read/write bits are applied as `mode & 0666`, so a pre-existing `0644` project remains `0644`, a `0600` project remains `0600`, and executable/special bits are not propagated. A brand-new project has no predecessor permission contract, so its default durable mode stays `0600`. + +Windows continues to use its native ACL inheritance and the existing no-follow/native-identity/flush boundaries. This change makes no claim that POSIX mode bits model Windows ACLs. + +## RED / GREEN evidence + +- RED `18b381b2d6dc46b9f70de9c78f0ead8cebf77500` adds `permissions::new_project_and_recovery_journal_remain_owner_private_with_permissive_umask`. The regression launches a child test process, sets only that child to `umask(000)`, publishes a real new project, then creates a real recovery journal through the Project Persistence owner. +- HOSTED RED: macOS run `35422988939`, job `105843929746`, reaches the native Project Persistence harness and fails only the new privacy regression. The child reports the new durable project as decimal mode `438` (`0666`) instead of decimal `384` (`0600`). This proves the issue without globally mutating the parallel parent test process. +- GREEN SOURCE `cac722ea6110504d496f12480b8c8d228199cbad` introduces one non-clobbering private-file creation primitive. Unix uses `OpenOptionsExt::mode(0o600)`; non-Unix keeps `File::create_new`. Both new-project staging and recovery-journal creation consume that primitive. Existing-target permission preservation remains downstream and unchanged. +- Exact-head hosted Windows/macOS evidence must be reacquired on the final descendant before this repair is treated as GREEN for delivery. Predecessor success is not transferred. + +## Rejected alternatives + +Relying on the host's default `umask` is rejected because the application does not control how it was launched and a permissive inherited value makes durable project data unnecessarily accessible. + +Changing the process-wide `umask` around a save is rejected because `umask` is process-global, not thread-local; concurrent file creation in other bounded contexts could inherit the temporary policy. + +Creating with broad permissions and calling `chmod` after publication is rejected because it leaves a visibility window before the permission repair and does not protect the recovery journal at creation time. + +Forcing every existing project to `0600` is rejected because an existing user's deliberate read/write sharing mode is already part of the predecessor contract. Project Persistence only strips executable/special bits; it does not silently revoke established read permissions during overwrite. + +## Security Notes + +The protected boundary is local durable state created by BandScope itself. The control reduces accidental or hostile cross-account access on Unix by making new project bytes and recovery metadata owner-only at creation. It does not defend against a malicious process running as the same account, privileged/root access, compromised parent-directory ownership, or Windows ACL misconfiguration. No new paths, project contents, identities, or migration digests are logged. + +This repair also does not replace packaged interruption testing. Process-kill, disk-full, permission-failure, power-loss, longer-lived known-good retention, global startup recovery discovery, accessible Restore / Compare / Discard UX, signing/notarization, updater rollback, and immutable release evidence remain separate #962 acceptance work. 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-persistence-success-cleanup-authority.md b/docs/traceability/project-persistence-success-cleanup-authority.md new file mode 100644 index 000000000..07ad95a7c --- /dev/null +++ b/docs/traceability/project-persistence-success-cleanup-authority.md @@ -0,0 +1,48 @@ +# Project Persistence successful-cleanup authority + +## Problem + +An existing-project publication validates the displaced predecessor and the published candidate before entering successful cleanup. Until this change, `finish_successful_publication` then deleted the displaced pathname and retired the published recovery journal without re-binding either cleanup action to the durable journal identities. + +That leaves a TOCTOU window: after commit validation but before cleanup, another local process can replace either the displaced/backup pathname or the target pathname. The old cleanup would still remove whatever currently occupied the displaced pathname and could retire recovery evidence even though the target no longer denoted the candidate that had been validated. This is a cleanup-authority defect, not evidence that normal concurrent commits are races by themselves. + +MITRE CWE-367 describes the relevant class: a resource property can change between a check and a later operation, invalidating the earlier authorization. The mitigation used here is a second native-identity check immediately at the destructive cleanup boundary, with fail-closed retention of recovery evidence on mismatch. + +Reference: MITRE. (2026). *CWE-367: Time-of-check Time-of-use (TOCTOU) race condition* (CWE 4.20). https://cwe.mitre.org/data/definitions/367.html + +## RED + +- `d3daecf148a10a085df98ea26fc2c8b8680cf274` adds a native regression in which a validated displaced predecessor pathname is replaced by a foreign regular file before successful cleanup. The required behavior is to preserve that foreign file, retain published recovery evidence, and fail closed. +- The predecessor implementation fails this contract because `finish_successful_publication` calls best-effort `remove_stage(stage)` without proving that `stage` still denotes the journal's `expected` identity. + +## Causal fix + +`83a40b255297763ee55ef1519ddc409f0d2421c3` changes only the successful-cleanup authority boundary: + +- promote the prepared journal to the durable published phase first; +- reopen and parse that durable journal instead of relying on stale caller assumptions; +- require the selected target to still have the journal's `candidate` native identity before retiring rollback material; +- for migration journals, re-run the candidate receipt check at this final cleanup boundary; +- delete the displaced/backup pathname only when its current native identity still equals the journal's `expected` predecessor; +- for migration journals, re-run the predecessor receipt check immediately before deletion; +- if either pathname has changed identity, return the recovery error without deleting the foreign object or the published journal. + +No lock, force overwrite, filename-only trust, warning suppression, or journal deletion on ambiguous authority was introduced. + +## Edge-case reinforcement + +`1d537922228f2988d2a7fbc5b12e6999800e0a6c` adds a second native case for the symmetric target race: after the candidate had been the validated target, another file occupies that pathname before cleanup. Successful cleanup must retain the known-good predecessor and published journal and must not clobber the new occupant. + +The two cases deliberately exercise path replacement rather than generated arrays or mocks. They run inside the same native Project Persistence integration harness used on Windows and macOS. + +## Security notes + +- **Untrusted inputs:** selected project pathnames, adjacent generated artifacts, and durable recovery journals remain untrusted filesystem state. +- **Trust boundary:** a pathname is not cleanup authority. The durable journal's native identities plus, for migrations, receipt digests define the objects that may be retired. +- **Safe failure:** identity/content mismatch retains rollback/recovery evidence and leaves the unexpected object untouched. +- **Privacy/logging:** no project/audio bytes, pathnames, or recovery content are added to ordinary logs. +- **Residual risk:** parent-directory operations remain pathname based; this change does not claim descriptor-bound ancestor-swap protection. Regular non-migration saves also do not gain a content digest merely from native identity. Packaged process-kill, disk-full, permission-failure, and power-loss evidence remains required by #962. + +## Verification boundary + +Source-level GREEN is not a release claim. The exact descendant containing this document must receive terminal Windows/macOS Project Persistence native results, repository-wide CI/security/SBOM/SAST/build settlement, and qualifying independent approval before #970 can move out of Draft or be merged. Any later semantic commit invalidates those exact-head results and must be re-evaluated rather than borrowing predecessor evidence. diff --git a/docs/traceability/project-persistence-windows-durability.md b/docs/traceability/project-persistence-windows-durability.md new file mode 100644 index 000000000..366fa9979 --- /dev/null +++ b/docs/traceability/project-persistence-windows-durability.md @@ -0,0 +1,66 @@ +# Project Persistence Windows durability boundary + +Last updated: 2026-09-19 + +## Problem + +Project Persistence stages and `sync_all`s complete project bytes before publication, but the Windows existing-target path used `ReplaceFileW(..., dwReplaceFlags = 0)` and then treated replacement plus the existing `sync_parent_directory` no-op as if `ReplaceFileW` itself supplied write-through durability. That claim was not supported by the Win32 contract. + +Microsoft documents `REPLACEFILE_WRITE_THROUGH` (`0x00000001`) for `ReplaceFileW` as **not supported**. Microsoft also documents that file-system metadata is always cached and that metadata changes must be flushed with `FlushFileBuffers` or written through. `MoveFileExW(MOVEFILE_WRITE_THROUGH)` does provide a write-through move contract, but that is a different API and did not cover the `ReplaceFileW` existing-target path or the hard-link first-save path. + +The defect is therefore not “candidate bytes were never synchronized”: staged candidate bytes were already flushed before replacement. The defect is narrower and buyer-relevant: BandScope could acknowledge an existing-target replacement or retire rollback material without an explicit post-`ReplaceFileW` durability boundary for the exact published/restored file object. That is incompatible with the current crash-safe/power-loss claim boundary. + +## Constraints + +- Keep `ReplaceFileW` as the Windows atomic existing-target owner; do not introduce a second persistence engine. +- Preserve the durable prepared journal and rollback artifact until the published candidate is verified and flushed. +- If candidate verification or flush fails, restore the expected predecessor and flush that exact restored file before retiring rollback artifacts. +- Bind every post-replacement flush to the expected native volume/file-index identity so a pathname swap cannot redirect the durability operation. +- Keep Linux/macOS exchange + directory `fsync` behavior unchanged. +- Do not pretend that file-level flushing proves full packaged power-loss safety; packaged fault injection remains required. + +## RED + +Commit `eebe2eea6f43637f3fb3ee3a0709d6e0f9b43c90` adds the Windows-native regression `existing_project_replacement_rolls_back_when_candidate_flush_fails`. The regression requires the existing-target replacement owner to expose a test-only injected durability boundary. On the RED commit the required adapter did not exist, so the Windows owner could not satisfy the contract. + +The regression models the causal failure after `ReplaceFileW`: the candidate is visible at the target pathname, its durability flush fails, the known-good predecessor is restored, and cleanup is allowed only after the restored predecessor reaches the durability boundary. Commit `336836a459c2f218d5c03e8430cd55b87a46674a` strengthens the same test to require candidate-stage and recovery-journal cleanup only after restored-target durability succeeds. + +## GREEN source + +Commit `5455fc84d7c465285ec2fa53cd01ed59204b10e6` makes the minimal causal repair: + +- adds an identity-bound Windows file flush using a read/write no-reparse handle, exact `WindowsFileIdentity`, and `File::sync_all` (`FlushFileBuffers` on Windows); +- keeps `ReplaceFileW` for existing-target publication but requires the exact candidate flush before the prepared journal is promoted and rollback material is retired; +- if candidate validation/flush fails, restores the predecessor with `ReplaceFileW`, requires an identity-bound flush of the restored predecessor, and only then permits rollback cleanup; +- applies the same conservative flush rule when recovery encounters a published candidate or a prepared rollback state; +- requires Windows first-save hard-link publication to flush the exact linked file before removing its staging name; the `MoveFileExW(MOVEFILE_WRITE_THROUGH)` fallback retains its documented write-through move and is additionally identity-flushed before success. + +The production path does not use a test fake. The injectable flush exists only under `cfg(all(windows, test))` and delegates to the same replacement state machine; normal production calls supply the identity-bound flush owner directly. + +## Rejected alternatives + +Using `REPLACEFILE_WRITE_THROUGH` is rejected because Microsoft explicitly documents the flag as unsupported. + +Treating `MoveFileExW(MOVEFILE_WRITE_THROUGH)` as evidence for `ReplaceFileW` is rejected because the APIs have different contracts and the existing-target path calls `ReplaceFileW` with zero flags. + +Changing the Windows parent-directory helper into a fabricated Unix-style directory `fsync` is rejected. The current repair uses a supported file-handle durability boundary and leaves directory-handle semantics outside the claim. + +Deleting the journal/backup after a failed candidate flush is rejected because it would destroy the only recovery evidence before the restored predecessor is durably acknowledged. + +Skipping the Windows regression, accepting a flush error, or weakening it to a source-string assertion is rejected because the buyer-visible invariant is state-machine behavior: failed candidate durability must not be reported as successful publication. + +## Evidence boundary + +This repair closes the unsupported source-level assertion that `ReplaceFileW` itself provides write-through durability and gives the Windows replacement/recovery state machine an explicit identity-bound flush before success or cleanup. It does **not** prove survival of hypervisor reset, physical power loss, controller-cache failure, filesystem corruption, disk-full during all journal phases, or packaged-app termination at every instruction boundary. Those remain #962 packaged fault-injection and recovery-evidence work. + +The RED/GREEN source commits are not terminal release evidence. The final semantic/document descendant must reacquire Windows and macOS Project Persistence native results, general CI/build/security/SBOM/SAST gates, and qualifying independent review before merge or release. + +## Primary references + +Microsoft. (2023). *ReplaceFileW function (winbase.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-replacefilew + +Microsoft. (2021). *File caching*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/fileio/file-caching + +Microsoft. (2021). *FlushFileBuffers function (fileapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-flushfilebuffers + +Microsoft. (2023). *MoveFileExW function (winbase.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw diff --git a/docs/traceability/project-persistence-windows-raced-target-recovery.md b/docs/traceability/project-persistence-windows-raced-target-recovery.md new file mode 100644 index 000000000..7e593f384 --- /dev/null +++ b/docs/traceability/project-persistence-windows-raced-target-recovery.md @@ -0,0 +1,35 @@ +# Windows raced-target rollback authority + +## Problem + +Exact head `40847ed016e4507dd7834808a76f5f1d796695ef` exposed a Windows-only recovery defect in the native Project Persistence owner lane. Run `35414912901`, job `105821531885`, reached the real persistence harness: 46 of 47 tests passed and `overwrite::existing_project_never_clobbers_a_target_swapped_after_authority_snapshot` failed because the rejected candidate stage remained after rollback. The same job also reported the test-only `replace_existing_project_file_with_flush_for_test` wrapper as unused. + +The failing state is a legitimate compare-and-swap race, not permission to overwrite the competing file. Project Persistence captures an `expected` identity before staging. If another writer replaces the pathname before `ReplaceFileW`, Windows publishes the staged candidate and stores the file that actually occupied the target at replacement time in the backup path. That displaced object can therefore be a racer whose identity differs from `expected`. + +The previous rollback restored that displaced object correctly but then called the identity-bound durability flush with stale `expected` authority. The restored target was the racer, so the flush necessarily failed and conservative cleanup left the candidate stage and recovery journal behind. Prepared crash recovery carried the same assumption: it treated every displaced object as the admitted predecessor, verified migration input against it, and flushed a restored Windows target against `journal.expected` even when the displaced object was a later racer. + +## Invariants + +`expected` has one role: commit authorization. A replacement may commit only when the displaced object still has the captured expected identity and any migration receipt also validates its exact input bytes. A raced displaced object must never authorize the candidate. + +Rollback has a different authority. Once the candidate is rejected, the product must preserve the object that actually occupied the selected pathname at replacement time. Its native identity is therefore the durability authority for the restored target. If that identity cannot be established, recovery remains fail-closed and retains recovery material. + +For migration journals, a displaced object is verified as the migration predecessor only when its native identity equals `journal.expected`. A different displaced identity is a concurrent occupant to restore, not migration input to authenticate. + +## Repair + +Source repair `b4214ad83747f8bbcbb71a8513c9ea155a9b7322` keeps the success gate unchanged: `displaced == expected` is still required before validation and candidate flush can authorize commit. On the rejection path, Windows rollback now flushes the restored target against the exact displaced identity captured from the backup rather than stale `expected` identity. + +Prepared recovery follows the same distinction. Migration predecessor verification runs only for a displaced object matching `journal.expected`. Windows restores the actual displaced object and performs the identity-bound flush against that object's identity before candidate, displaced artifact, or journal cleanup. Linux/macOS exchange behavior is unchanged. + +The cross-platform competing-file recovery regression now has a Windows branch that models the real `ReplaceFileW` state: the originally admitted project is parked, a racer wins the pathname, the staged candidate replaces the racer into a generated backup, and recovery must restore the racer while leaving the separately parked admitted file untouched. Candidate, displaced backup, and journal cleanup are required only after the restored target reaches the durability boundary. + +The unused test-only flush wrapper was removed rather than suppressed. The consolidated harness calls the crate-private production `replace_existing_project_file_with_validation_and_flush` function with explicit `PublicationValidation::IdentityOnly`, so the injected failure exercises the same state machine without a duplicate test API. This is crate-private visibility for the existing owner, not a second public persistence surface. + +## Rejected alternatives + +Flushing the restored racer against `expected` is rejected because it confuses admission authority with rollback durability authority and makes successful rollback cleanup impossible by construction. Treating a raced displaced object as migration input is rejected because its bytes were never the source of the prepared migration receipt. Deleting recovery material after a failed restored-target flush is rejected because it would turn an unproven rollback into false success. Skipping the Windows regression, accepting leftover artifacts, using `#[allow(dead_code)]`, or weakening the warning gate is also rejected. + +## Evidence boundary + +The hosted RED proves the prior exact head's Windows rollback/cleanup defect. The source repair proves the causal state-machine change in repository history; it is not itself terminal native evidence. The final descendant must reacquire the warning-gated Windows and macOS Project Persistence lanes on its own exact head. General CI, build-baseline, SBOM, security/SAST, independent review, protected ancestry, Resource Admission #866 integration, packaged process-kill/disk-full/permission/power-loss testing, signing/notarization, and immutable release/updater rollback remain separate gates. diff --git a/docs/traceability/project-root-provisioning-authority.md b/docs/traceability/project-root-provisioning-authority.md new file mode 100644 index 000000000..a0140d570 --- /dev/null +++ b/docs/traceability/project-root-provisioning-authority.md @@ -0,0 +1,90 @@ +# Project-root and app-owned workspace authority + +## Problem + +BandScope already rejected Unix symlinks and Windows reparse points when reopening an existing app-local project root, but the new-project path originally called `std::fs::create_dir_all` from the Tauri command boundary. That made creation authority weaker than reopen authority: a stable linked app-local base could redirect a newly minted project directory into a different filesystem subtree before local-audio materialization wrote project-owned bytes. + +After the project-root repair, a second authority asymmetry remained. Cache and temp workspaces still used `create_dir_all(/)`, and score storage still used `create_dir_all(/scores)`. A pre-existing symlink or junction at one of those locations could therefore redirect analysis output, YouTube import artifacts, temporary material, or attached score PDFs into a different local subtree even though the canonical project root itself was link-safe. + +A separate confidentiality boundary remained after link authority was fixed: missing Unix project/cache/temp/score directory components were still created with ordinary `create_dir`. Their effective mode therefore depended on the inherited process `umask`; under `umask(000)` a newly created app-owned directory could become `0777`. Project files and recovery journals were already created privately, so leaving their containing app-owned directories world-searchable/writable would make the directory authority contract materially weaker than the durable-file policy. + +These are local filesystem authority defects, not claims of remote arbitrary-file access. The link-resolution defects map to CWE-59 because an intended path can resolve through a link to an unintended resource. MITRE classifies the weakness as applicable to both Unix and Windows and notes confidentiality, integrity, and access-control consequences. Rust documents `create_dir_all` as recursive and non-atomic, implemented through repeated platform directory-creation calls; it does not provide a no-follow ancestor contract. Rust's Unix `DirBuilderExt::mode` allows a restrictive creation mode to be requested before the directory becomes visible, avoiding a create-then-chmod exposure window. + +## Constraints + +- Keep Project Persistence as the owner of app-local directory authority; do not move Resource Admission source semantics, score-content validation, or analysis-engine semantics into this module. +- Preserve the already-reviewed macOS `/etc`, `/tmp`, and `/var` root aliases only when they remain root-owned and resolve to the exact `/private/...` system target. +- Cache, temp, and score directories are reusable workspaces, unlike a newly minted project root, so hardening must not reject an ordinary existing real directory or silently rewrite its established permission mode. +- Do not mutate the process-global `umask`, create broad permissions and repair them later, introduce broad filesystem canonicalization as authorization, forcefully delete foreign paths, or require a platform-specific privilege. +- Do not claim that POSIX mode bits model Windows ACLs. Windows continues to use its native directory ACL inheritance. +- Do not claim descriptor-bound ancestor stability: a component can still be replaced after metadata validation and before the next filesystem operation. + +## RED + +Project-root creation RED `834e561263224744f5a1af3c2c3e36d76155c216` adds Unix and Windows regressions requiring a linked/reparse app-local base to fail new-project provisioning without creating the project in the redirected target. `6e235a42dd3096b51a0f26f11dd17a72b772b61e` and `93da927696826149a9f742fbdc630fb74ab4d80a` add the previously missing project-root authority test path to the macOS and Windows owner workflows so this contract is hosted rather than test-file-only evidence. + +Workspace RED `f45df9e73c815dca10e5e78326d6c2abb1db4073` extends the same owner harness with a reusable-workspace contract and real linked fixtures. Unix tests require a symlinked cache/temp ancestor and a symlinked `scores` directory to fail closed without creating data in the redirect target. Windows tests use junctions/reparse points for the same cases. The RED deliberately calls the missing `ensure_owned_directory` owner primitive, so a pre-fix descendant cannot compile or satisfy the contract merely because an unrelated path check exists elsewhere. + +Unix permission RED `fcfb00a0bef4eb30a98e56233dde448aecd87666` extends the existing isolated permissive-umask regression pattern. The test launches only its own test binary child, sets `umask(000)` inside that child, provisions a real new project root plus a reusable app-owned workspace through the production Project Persistence directory owner, and requires every newly created app-owned directory component to be `0700`. This avoids mutating process-global umask state in the parallel parent test process. + +## Fix + +`f566951e581d0fcf48ac8c9c806cb5ab5ff9570e` adds the original creation-side authority in `project_root.rs`: + +- validate the minted `project_id` before joining; +- walk the lexical base chain from the filesystem root downward; +- inspect every existing component with `symlink_metadata` and reject Unix symlinks / Windows reparse points, subject only to the existing narrow macOS root-alias exception; +- create each missing base component with single-directory semantics, then immediately verify that component as a real directory; +- create the final project directory with single-directory create semantics and refuse silent reuse of an existing project root. + +`066a274015a1b0101d3b2ba0442448e63ca4c4e7` wires the Tauri new-project entry points (`select_local_audio_source` and `import_youtube_url`) to that owner. Existing-project callers use the existing-root resolver rather than creation semantics. `79eee50e7be4e6bac913f700be938115867261bc` adds the ordinary positive case and verifies that a second new-project provision attempt cannot silently reuse the directory. + +Workspace fix `acd29bab38d9affd0333ca871efd4bca8af330be` adds `ensure_owned_directory`, reusing the same component-by-component no-follow/reparse checks while allowing an ordinary existing final directory. `4b178020c681097f205bd474f62ab6c41a59fbb6` replaces the remaining production `create_dir_all` calls for cache/temp roots and `/scores` with that owner primitive. An unrelated queued-progress edit introduced while replacing the large `main.rs` file was detected from the exact commit diff and restored immediately by ordinary descendant `c6d8c5e44d2f58a2d0b61758f11a9ae0f639fa66`; no force-push or destructive history edit was used. + +Unix permission fix `73b2fbb7efd3e30d7909511f00f8f0daf7adcbec` centralizes creation of missing app-owned directory components in `create_owned_directory`. Unix uses `DirBuilderExt::mode(0o700)` before the directory becomes visible; non-Unix platforms retain native `create_dir`/ACL inheritance. The same primitive is used for missing project-base components, the final new project root, and missing reusable cache/temp/scores components. Existing directory modes are deliberately left unchanged. + +## Alternatives rejected + +`create_dir_all` followed by validation was rejected because filesystem objects can already be created under a redirected ancestor before the later check. Canonicalizing a base and accepting the canonical target was rejected because that turns redirection into authority rather than rejecting it. A process-wide lock does not prevent another process or local principal from replacing a directory entry. Treating cache/temp/scores exactly like a fresh project root was also rejected because those workspaces have legitimate reuse semantics. Platform-specific symlink creation privileges are irrelevant to detecting already-present Windows reparse points, so Windows regressions use junction fixtures. + +Relying on the inherited Unix `umask` was rejected because BandScope does not control how the desktop process is launched. Temporarily changing the process-wide `umask` was rejected because it is not thread-local and could alter file/directory creation in another bounded context. Creating `0777` and applying `chmod(0700)` afterward was rejected because it creates an avoidable permission exposure window. Retrofitting `0700` onto already-existing workspaces was rejected because an existing mode can encode an intentional local sharing/administration policy; this change constrains newly created app-owned directories only. + +## Security Notes + +### Attack surface + +The affected surface is local project bootstrap, analysis workspace preparation, YouTube import output, restart restoration of cache/temp state, and score attachment/read/remove preparation at the Tauri filesystem boundary. The untrusted conditions are a pre-existing Unix symlink, Windows reparse-point/junction, replaced directory component, or a permissive inherited Unix `umask` during first creation of an app-owned directory. No renderer-supplied absolute path is accepted by these directory-authority APIs; `project_id` remains a BandScope-shaped identifier. + +### Trust boundary + +Authority crosses the `Storage Boundary` defined by `docs/security/app-security.md`: BandScope may create project, cache, temp, and score artifacts only under the Tauri-resolved app-owned roots and a validated project root. A linked ancestor or linked final workspace must not silently convert that path into authority over another filesystem subtree. Newly created Unix app-owned directories must also not become group/world writable/searchable merely because the host process inherited a permissive umask. Resource Admission remains a consumer of the resulting project/cache paths; score-content validation remains in its existing core helpers. + +### Realistic threats + +A local process or user with permission to prepare an entry inside a relevant app-owned parent could place a symlink or junction before BandScope provisions or reopens a workspace. Without the checks, later source materialization, analysis/YouTube output, or score-copy operations could write beneath the redirected destination. Separately, launch environments that inherit `umask(000)` can otherwise create new app-owned directories as `0777`, unnecessarily widening cross-account visibility and mutation opportunities on multi-user Unix hosts. The current model does not treat an attacker who can replace ancestors between individual metadata checks and filesystem operations as fully mitigated. + +### Mitigations + +Every existing lexical component is inspected with `symlink_metadata`; Unix symlinks and Windows reparse points fail closed. Missing components are created one at a time and immediately revalidated rather than recursively followed through `create_dir_all`. Only the existing exact root-owned macOS `/etc`, `/tmp`, and `/var` aliases are admitted. Fresh project roots still use one-shot creation and refuse reuse, while cache/temp/scores use `ensure_owned_directory` so a verified ordinary existing directory can be reused without accepting a link/reparse endpoint. On Unix, every missing app-owned component is created with requested mode `0700`; the umask may narrow that mode but cannot broaden it. Existing modes and Windows ACL inheritance are preserved. + +### Safe failure, logging, and privacy + +Rejected authority returns the existing generic workspace-specific error at the Tauri boundary. The implementation does not log the rejected path, local username, project payload, raw audio, URL, score bytes, or effective mode. It does not delete or rewrite a foreign link target when validation fails. No new network path, telemetry, or PII collection is introduced. + +### Test points + +Unix coverage uses real symlinks for app-local project creation/reopen, cache/temp workspace redirection, and `scores` redirection. Windows coverage uses real junction/reparse-point fixtures for the corresponding cases. Positive cases cover ordinary nested project provisioning, project-root no-reuse, and reusable ordinary app-owned workspace directories. A separate Unix child-process regression sets `umask(000)` only inside the child and verifies real production provisioning creates new project-base/project-root and reusable-workspace directories at `0700`. These cases remain inside the single warning-gated Project Persistence native harness and must be GREEN on the unchanged exact source head before acceptance. + +### Remaining risk + +The implementation remains path-based, not descriptor-relative. A directory component replaced after validation but before a following operation can still change path resolution; closing that requires a platform-specific descriptor/handle-bound design. `0700` is a Unix creation contract only; Windows directory ACL policy remains native inheritance and still needs packaged/host-policy acceptance rather than a POSIX analogy. The present change also does not establish restrictive mode/ACL policy for every child file created inside cache/temp/scores/project directories, cleanup/retention policy, packaged interruption behavior, disk-full handling, permission-failure UX, cancellation behavior, or power-loss durability. + +## Evidence and claim boundary + +Primary references: + +- MITRE. (2026). *CWE-59: Improper Link Resolution Before File Access (Link Following).* https://cwe.mitre.org/data/definitions/59 +- Rust Project Developers. (2026). *std::fs::create_dir_all.* https://doc.rust-lang.org/std/fs/fn.create_dir_all.html +- Rust Project Developers. (2026). *std::os::unix::fs::DirBuilderExt.* https://doc.rust-lang.org/std/os/unix/fs/trait.DirBuilderExt.html + +Acceptance requires exact-source Windows and macOS owner workflow GREEN after the directory-mode repair and this traceability update; predecessor native results do not transfer. The current repair closes **stable linked/reparse redirection for new/existing app-local project-root authority and reusable cache/temp/scores directory authority**, and on Unix makes newly created app-owned directory components owner-only by construction (`0700`) even under a permissive inherited umask. It does not prove descriptor-relative no-follow semantics against a component swapped between checks, does not define Windows ACL hardening or every child-file permission/retention policy, does not duplicate Resource Admission or score-content semantics, and is not packaged process-kill or power-loss 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/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..79bcc02f1 100644 --- a/services/analysis-engine/src/bandscope_analysis/api.py +++ b/services/analysis-engine/src/bandscope_analysis/api.py @@ -6,14 +6,25 @@ import json import logging import multiprocessing as mp +import os import queue +import stat +import tempfile import time +import zipfile from contextlib import suppress from pathlib import Path from typing import Any, Literal, NotRequired, TypedDict, cast import numpy as np +from bandscope_analysis.audio_resource_policy import DEFAULT_AUDIO_RESOURCE_POLICY +from bandscope_analysis.final_result_cache import ( + _publish_synced_cache_stage as publish_synced_cache_stage, + admitted_audio_cache_identity, + load_admitted_rehearsal_song, + store_durable_cache_payload, +) from bandscope_analysis.health import HealthReport, build_health_report from bandscope_analysis.roles import RoleExtractor from bandscope_analysis.sections import extract_sections @@ -24,7 +35,16 @@ MAX_SECTION_TIME_SECONDS = 4_294_967_295 ANALYSIS_CACHE_SCHEMA_VERSION = 1 -FEATURE_CACHE_SCHEMA_VERSION = 1 +FEATURE_CACHE_SCHEMA_VERSION = 2 +FEATURE_CACHE_MANIFEST_MAX_BYTES = 64 * 1024 +FEATURE_CACHE_MAX_STEMS = 4 +FEATURE_CACHE_ARRAYS_MAX_BYTES = ( + DEFAULT_AUDIO_RESOURCE_POLICY.max_decoded_audio_bytes * FEATURE_CACHE_MAX_STEMS + + 16 * 1024 * 1024 +) +FEATURE_CACHE_UNCOMPRESSED_MAX_BYTES = ( + DEFAULT_AUDIO_RESOURCE_POLICY.max_decoded_audio_bytes * FEATURE_CACHE_MAX_STEMS +) STEM_SEPARATION_TIMEOUT_SECONDS = 20.0 logger = logging.getLogger(__name__) @@ -192,6 +212,7 @@ class CachedFeaturePayload(TypedDict): schemaVersion: int source: dict[str, object] + arraysSha256: str sampleRate: int separation: dict[str, object] stemKeys: list[str] @@ -306,8 +327,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, @@ -601,15 +626,21 @@ def _analysis_cache_path(request: AnalysisJobRequest) -> Path | None: cache_root = request.get("cacheRoot") if not cache_root: return None + try: + admitted_identity = admitted_audio_cache_identity() + except ValueError: + return None local_source = request["localSource"] - key_payload = { + key_payload: dict[str, object] = { "schemaVersion": ANALYSIS_CACHE_SCHEMA_VERSION, "projectId": request.get("projectId", ""), "sourcePath": local_source["sourcePath"], "fileName": local_source["fileName"], "fileSizeBytes": local_source["fileSizeBytes"], } + if admitted_identity is not None: + key_payload["admittedAudio"] = admitted_identity digest = hashlib.sha256( json.dumps(key_payload, sort_keys=True, separators=(",", ":")).encode("utf-8") ).hexdigest() @@ -635,15 +666,21 @@ def _stem_work_arrays_path(request: AnalysisJobRequest) -> Path | None: temp_root = request.get("tempRoot") if not temp_root: return None + try: + admitted_identity = admitted_audio_cache_identity() + except ValueError: + return None local_source = request["localSource"] - key_payload = { + key_payload: dict[str, object] = { "schemaVersion": FEATURE_CACHE_SCHEMA_VERSION, "projectId": request.get("projectId", ""), "sourcePath": local_source["sourcePath"], "fileName": local_source["fileName"], "fileSizeBytes": local_source["fileSizeBytes"], } + if admitted_identity is not None: + key_payload["admittedAudio"] = admitted_identity digest = hashlib.sha256( json.dumps(key_payload, sort_keys=True, separators=(",", ":")).encode("utf-8") ).hexdigest() @@ -651,45 +688,39 @@ def _stem_work_arrays_path(request: AnalysisJobRequest) -> Path | None: def _load_cached_analysis(path: Path) -> RehearsalSong | None: - """Load a cached rehearsal result, treating malformed cache as a miss.""" - try: - with path.open("r", encoding="utf-8") as cache_file: - payload = json.load(cache_file) - except (OSError, json.JSONDecodeError): - return None - - if not isinstance(payload, dict): - return None - if payload.get("schemaVersion") != ANALYSIS_CACHE_SCHEMA_VERSION: - return None - result = payload.get("result") - if not isinstance(result, dict): - return None - return cast(RehearsalSong, result) + """Load a bounded, source-bound rehearsal result, treating rejection as a miss.""" + result = load_admitted_rehearsal_song( + path, + schema_version=ANALYSIS_CACHE_SCHEMA_VERSION, + ) + return cast(RehearsalSong | None, result) def _store_cached_analysis(path: Path, request: AnalysisJobRequest, result: RehearsalSong) -> bool: """Persist cache metadata without storing the original absolute source path.""" if "localSource" not in request: return False + try: + admitted_identity = admitted_audio_cache_identity() + except ValueError: + return False local_source = request["localSource"] + source_metadata: dict[str, object] = { + "fileName": local_source["fileName"], + "extension": local_source["extension"], + "fileSizeBytes": local_source["fileSizeBytes"], + } + if admitted_identity is not None: + source_metadata["admittedAudio"] = admitted_identity payload: CachedAnalysisPayload = { "schemaVersion": ANALYSIS_CACHE_SCHEMA_VERSION, - "source": { - "fileName": local_source["fileName"], - "extension": local_source["extension"], - "fileSizeBytes": local_source["fileSizeBytes"], - }, + "source": source_metadata, "result": result, } try: - path.parent.mkdir(parents=True, exist_ok=True) - temp_path = path.with_suffix(".tmp") - with temp_path.open("w", encoding="utf-8") as cache_file: - json.dump(payload, cache_file, separators=(",", ":")) - temp_path.replace(path) - except OSError: + store_durable_cache_payload(path, payload) + except (OSError, TypeError, ValueError): return False return True @@ -717,50 +748,238 @@ def _normalize_stem_role_types( return normalized +def _sha256_file(path: Path) -> str | None: + """Return a streaming SHA-256 for a derived cache artifact, or ``None`` on read failure.""" + digest = hashlib.sha256() + try: + with path.open("rb") as artifact: + while chunk := artifact.read(1024 * 1024): + digest.update(chunk) + except OSError: + return None + return digest.hexdigest() + + +def _valid_sha256(value: object) -> bool: + """Return whether a persisted digest is canonical lowercase SHA-256 hex.""" + return ( + isinstance(value, str) + and len(value) == 64 + and value == value.lower() + and all(character in "0123456789abcdef" for character in value) + ) + + +def _reject_duplicate_json_pairs(pairs: list[tuple[str, object]]) -> dict[str, object]: + """Build a JSON object while rejecting ambiguous duplicate member names.""" + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate feature-cache JSON key") + result[key] = value + return result + + +def _load_bounded_feature_manifest(path: Path) -> dict[str, object] | None: + """Read one small regular manifest without following a final-component symlink.""" + descriptor: int | None = None + try: + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(path, flags) + descriptor_stat = os.fstat(descriptor) + if ( + not stat.S_ISREG(descriptor_stat.st_mode) + or descriptor_stat.st_size <= 0 + or descriptor_stat.st_size > FEATURE_CACHE_MANIFEST_MAX_BYTES + ): + return None + with os.fdopen(descriptor, "rb", closefd=False) as manifest_file: + encoded = manifest_file.read(FEATURE_CACHE_MANIFEST_MAX_BYTES + 1) + if len(encoded) > FEATURE_CACHE_MANIFEST_MAX_BYTES: + return None + payload = json.loads( + encoded.decode("utf-8"), + object_pairs_hook=_reject_duplicate_json_pairs, + ) + except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError): + return None + finally: + if descriptor is not None: + with suppress(OSError): + os.close(descriptor) + return payload if isinstance(payload, dict) else None + + +def _npz_member_declared_nbytes( + archive: zipfile.ZipFile, + info: zipfile.ZipInfo, +) -> int | None: + """Return bounded declared NPY payload bytes without allocating the persisted array.""" + try: + with archive.open(info) as member: + version = np.lib.format.read_magic(member) + if version == (1, 0): + shape, _, dtype = np.lib.format.read_array_header_1_0(member) + elif version == (2, 0): + shape, _, dtype = np.lib.format.read_array_header_2_0(member) + else: + return None + except (EOFError, ValueError): + return None + if dtype.hasobject or len(shape) != 1 or shape[0] <= 0 or dtype.itemsize <= 0: + return None + declared_nbytes = shape[0] * dtype.itemsize + if ( + declared_nbytes > DEFAULT_AUDIO_RESOURCE_POLICY.max_decoded_audio_bytes + or declared_nbytes > info.file_size + ): + return None + return declared_nbytes + + +def _load_bounded_feature_arrays( + arrays_path: Path, + stem_keys: list[str], + expected_sha256: str, +) -> dict[str, np.ndarray] | None: + """Admit one regular NPZ by bounded encoded and declared-uncompressed size before NumPy.""" + descriptor: int | None = None + try: + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(arrays_path, flags) + descriptor_stat = os.fstat(descriptor) + if ( + not stat.S_ISREG(descriptor_stat.st_mode) + or descriptor_stat.st_size <= 0 + or descriptor_stat.st_size > FEATURE_CACHE_ARRAYS_MAX_BYTES + ): + return None + + with os.fdopen(descriptor, "rb", closefd=False) as arrays_file: + digest = hashlib.sha256() + observed_bytes = 0 + while chunk := arrays_file.read(1024 * 1024): + observed_bytes += len(chunk) + if observed_bytes > FEATURE_CACHE_ARRAYS_MAX_BYTES: + return None + digest.update(chunk) + if digest.hexdigest() != expected_sha256: + return None + + arrays_file.seek(0) + with zipfile.ZipFile(arrays_file) as archive: + infos = archive.infolist() + expected_members = {f"stem_{stem_key}.npy" for stem_key in stem_keys} + if ( + len(infos) != len(expected_members) + or {info.filename for info in infos} != expected_members + ): + return None + total_uncompressed_bytes = 0 + for info in infos: + if info.is_dir() or info.file_size <= 0: + return None + if info.file_size > DEFAULT_AUDIO_RESOURCE_POLICY.max_decoded_audio_bytes: + return None + declared_nbytes = _npz_member_declared_nbytes(archive, info) + if declared_nbytes is None: + return None + total_uncompressed_bytes += declared_nbytes + if total_uncompressed_bytes > FEATURE_CACHE_UNCOMPRESSED_MAX_BYTES: + return None + + arrays_file.seek(0) + with np.load(arrays_file, allow_pickle=False) as stems_archive: + expected_archive_keys = {f"stem_{stem_key}" for stem_key in stem_keys} + if set(stems_archive.files) != expected_archive_keys: + return None + stems: dict[str, np.ndarray] = {} + for stem_key in stem_keys: + stem_array = stems_archive[f"stem_{stem_key}"] + if ( + not isinstance(stem_array, np.ndarray) + or stem_array.ndim != 1 + or stem_array.size == 0 + or not np.issubdtype(stem_array.dtype, np.floating) + or stem_array.nbytes > DEFAULT_AUDIO_RESOURCE_POLICY.max_decoded_audio_bytes + or not np.isfinite(stem_array).all() + ): + return None + stems[stem_key] = stem_array + except (OSError, ValueError, zipfile.BadZipFile, zipfile.LargeZipFile): + return None + finally: + if descriptor is not None: + with suppress(OSError): + os.close(descriptor) + return stems + + def _load_cached_local_audio_features( metadata_path: Path, arrays_path: Path ) -> dict[str, Any] | None: - """Load cached stem/features payload, treating malformed files as cache misses.""" + """Load source-bound stem/features only after bounded manifest and NPZ admission.""" try: - with metadata_path.open("r", encoding="utf-8") as metadata_file: - metadata_payload = json.load(metadata_file) - except (OSError, json.JSONDecodeError): + expected_identity = admitted_audio_cache_identity() + except ValueError: + return None + metadata_payload = _load_bounded_feature_manifest(metadata_path) + if metadata_payload is None: return None - if not isinstance(metadata_payload, dict): + allowed_manifest_keys = { + "schemaVersion", + "source", + "arraysSha256", + "sampleRate", + "separation", + "stemKeys", + "stemRoleTypes", + } + if set(metadata_payload) != allowed_manifest_keys: return None if metadata_payload.get("schemaVersion") != FEATURE_CACHE_SCHEMA_VERSION: return None - if not isinstance(metadata_payload.get("sampleRate"), int): + source = metadata_payload.get("source") + if not isinstance(source, dict): + return None + manifest_identity = source.get("admittedAudio") + if expected_identity is None: + if manifest_identity is not None: + return None + elif manifest_identity != expected_identity: + return None + arrays_sha256 = metadata_payload.get("arraysSha256") + if not _valid_sha256(arrays_sha256): + return None + sample_rate = metadata_payload.get("sampleRate") + if isinstance(sample_rate, bool) or not isinstance(sample_rate, int) or sample_rate <= 0: return None separation = metadata_payload.get("separation") if not isinstance(separation, dict): return None stem_keys = metadata_payload.get("stemKeys") - if not isinstance(stem_keys, list) or not stem_keys: + if ( + not isinstance(stem_keys, list) + or not stem_keys + or len(stem_keys) > FEATURE_CACHE_MAX_STEMS + or len(set(stem_keys)) != len(stem_keys) + or not all(isinstance(stem_key, str) and stem_key for stem_key in stem_keys) + ): return None - if not all(isinstance(stem_key, str) and stem_key for stem_key in stem_keys): + raw_stem_role_types = metadata_payload.get("stemRoleTypes") + if not isinstance(raw_stem_role_types, dict) or set(raw_stem_role_types) != set(stem_keys): return None - stem_role_types = _normalize_stem_role_types(metadata_payload.get("stemRoleTypes"), stem_keys) + stem_role_types = _normalize_stem_role_types(raw_stem_role_types, stem_keys) if stem_role_types is None: return None - try: - with np.load(arrays_path, allow_pickle=False) as stems_archive: - stems: dict[str, np.ndarray] = {} - for stem_key in stem_keys: - archive_key = f"stem_{stem_key}" - if archive_key not in stems_archive: - return None - stem_array = stems_archive[archive_key] - if not isinstance(stem_array, np.ndarray): - return None - stems[stem_key] = stem_array - except (OSError, ValueError): + stems = _load_bounded_feature_arrays(arrays_path, stem_keys, arrays_sha256) + if stems is None: return None return { "stems": stems, - "sr": metadata_payload["sampleRate"], + "sr": sample_rate, "stem_role_types": stem_role_types, "separation": { "duration_seconds": separation.get("duration_seconds"), @@ -772,16 +991,26 @@ def _load_cached_local_audio_features( def _serialize_stem_arrays(stems: object) -> dict[str, np.ndarray] | None: """Return validated stem arrays for compressed npz persistence.""" - if not isinstance(stems, dict) or not stems: + if not isinstance(stems, dict) or not stems or len(stems) > FEATURE_CACHE_MAX_STEMS: return None serialized_stems: dict[str, np.ndarray] = {} + total_bytes = 0 for stem_name, stem_value in stems.items(): if not isinstance(stem_name, str) or not stem_name: return None if not stem_name.isidentifier(): return None - if not isinstance(stem_value, np.ndarray): + if ( + not isinstance(stem_value, np.ndarray) + or stem_value.ndim != 1 + or stem_value.size == 0 + or not np.issubdtype(stem_value.dtype, np.floating) + or stem_value.nbytes > DEFAULT_AUDIO_RESOURCE_POLICY.max_decoded_audio_bytes + ): + return None + total_bytes += stem_value.nbytes + if total_bytes > FEATURE_CACHE_UNCOMPRESSED_MAX_BYTES: return None serialized_stems[f"stem_{stem_name}"] = stem_value return serialized_stems @@ -793,18 +1022,24 @@ def _store_cached_local_audio_features( request: AnalysisJobRequest, audio_features: dict[str, Any], ) -> bool: - """Persist reusable local-audio features with atomic writes.""" + """Persist source-bound reusable features with arrays-first durable publication.""" if "localSource" not in request: return False + if metadata_path.parent != arrays_path.parent: + return False serialized_stems = _serialize_stem_arrays(audio_features.get("stems")) sample_rate = audio_features.get("sr") if serialized_stems is None: return False - if not isinstance(sample_rate, int): + if isinstance(sample_rate, bool) or not isinstance(sample_rate, int) or sample_rate <= 0: return False separation = audio_features.get("separation") if not isinstance(separation, dict): return False + try: + admitted_identity = admitted_audio_cache_identity() + except ValueError: + return False stem_keys = [key.replace("stem_", "", 1) for key in serialized_stems] stem_role_types = _normalize_stem_role_types(audio_features.get("stem_role_types"), stem_keys) @@ -812,34 +1047,58 @@ def _store_cached_local_audio_features( return False local_source = request["localSource"] - metadata_payload: CachedFeaturePayload = { - "schemaVersion": FEATURE_CACHE_SCHEMA_VERSION, - "source": { - "fileName": local_source["fileName"], - "extension": local_source["extension"], - "fileSizeBytes": local_source["fileSizeBytes"], - }, - "sampleRate": sample_rate, - "separation": { - "duration_seconds": separation.get("duration_seconds"), - "chunk_count": separation.get("chunk_count"), - "notes": separation.get("notes"), - }, - "stemKeys": stem_keys, - "stemRoleTypes": stem_role_types, + source_metadata: dict[str, object] = { + "fileName": local_source["fileName"], + "extension": local_source["extension"], + "fileSizeBytes": local_source["fileSizeBytes"], } + if admitted_identity is not None: + source_metadata["admittedAudio"] = admitted_identity + + arrays_temp: Path | None = None try: - metadata_path.parent.mkdir(parents=True, exist_ok=True) - metadata_temp = metadata_path.with_name(f"{metadata_path.name}.tmp") - arrays_temp = arrays_path.with_name(f"{arrays_path.name}.tmp") - with metadata_temp.open("w", encoding="utf-8") as metadata_file: - json.dump(metadata_payload, metadata_file, separators=(",", ":")) - with arrays_temp.open("wb") as arrays_file: - np.savez_compressed(arrays_file, **cast(Any, serialized_stems)) - arrays_temp.replace(arrays_path) - metadata_temp.replace(metadata_path) - except OSError: + arrays_path.parent.mkdir(parents=True, exist_ok=True) + arrays_stage = tempfile.NamedTemporaryFile( + mode="w+b", + dir=arrays_path.parent, + prefix=".bandscope-feature-arrays-", + suffix=".npz", + delete=False, + ) + arrays_temp = Path(arrays_stage.name) + with arrays_stage: + np.savez_compressed(arrays_stage, **cast(Any, serialized_stems)) + arrays_stage.flush() + os.fsync(arrays_stage.fileno()) + arrays_stat = arrays_temp.stat() + if arrays_stat.st_size <= 0 or arrays_stat.st_size > FEATURE_CACHE_ARRAYS_MAX_BYTES: + return False + arrays_sha256 = _sha256_file(arrays_temp) + if arrays_sha256 is None: + return False + + metadata_payload: CachedFeaturePayload = { + "schemaVersion": FEATURE_CACHE_SCHEMA_VERSION, + "source": source_metadata, + "arraysSha256": arrays_sha256, + "sampleRate": sample_rate, + "separation": { + "duration_seconds": separation.get("duration_seconds"), + "chunk_count": separation.get("chunk_count"), + "notes": separation.get("notes"), + }, + "stemKeys": stem_keys, + "stemRoleTypes": stem_role_types, + } + publish_synced_cache_stage(arrays_temp, arrays_path) + arrays_temp = None + store_durable_cache_payload(metadata_path, metadata_payload) + except (OSError, TypeError, ValueError): return False + finally: + if arrays_temp is not None: + with suppress(OSError): + arrays_temp.unlink(missing_ok=True) return True @@ -982,14 +1241,26 @@ def _run_stem_separation_with_timeout( if kind == "ok_file": if not isinstance(payload, dict): raise RuntimeError("Stem separation returned invalid metadata.") + arrays_output_path = Path(str(payload.get("arraysPath", ""))) + arrays_sha256 = _sha256_file(arrays_output_path) + if arrays_sha256 is None: + raise RuntimeError("Stem separation returned unreadable stem arrays.") + try: + admitted_identity = admitted_audio_cache_identity() + except ValueError as error: + raise RuntimeError("Stem separation source identity became invalid.") from error + source_metadata: dict[str, object] = {} + if admitted_identity is not None: + source_metadata["admittedAudio"] = admitted_identity metadata_payload = { "schemaVersion": FEATURE_CACHE_SCHEMA_VERSION, + "source": source_metadata, + "arraysSha256": arrays_sha256, "sampleRate": payload.get("sampleRate"), "separation": payload.get("separation"), "stemKeys": payload.get("stemKeys"), "stemRoleTypes": payload.get("stemRoleTypes"), } - arrays_output_path = Path(str(payload.get("arraysPath", ""))) metadata_temp = arrays_output_path.with_suffix(".json") try: metadata_temp.write_text(json.dumps(metadata_payload), encoding="utf-8") 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/final_result_cache.py b/services/analysis-engine/src/bandscope_analysis/final_result_cache.py new file mode 100644 index 000000000..db388cb93 --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/final_result_cache.py @@ -0,0 +1,536 @@ +"""Final rehearsal-result cache admission and publication helpers. + +Native Resource Admission remains the authority for source byte-count/SHA-256 evidence. +This module owns the Python analysis boundary for final-result cache validation and +crash-aware publication only; it consumes Signal/MIR Analysis generation evidence +without creating a second source-identity or model-admission authority. +""" + +from __future__ import annotations + +import json +import math +import os +import tempfile +from contextlib import suppress +from pathlib import Path +from typing import Any + +from bandscope_analysis.separation.audio_separator import ( + _admitted_audio_evidence_from_environment, +) +from bandscope_analysis.separation.generation import separation_generation_identity + +MAX_FINAL_RESULT_CACHE_BYTES = 4 * 1024 * 1024 +FINAL_RESULT_ANALYSIS_GENERATION = 2 +_MAX_SECTION_TIME_SECONDS = 4_294_967_295 +_ROLE_TYPES = frozenset({"instrument", "vocal", "hand"}) +_CONFIDENCE_LEVELS = frozenset({"low", "medium", "high"}) +_PROVENANCE_SOURCES = frozenset({"model", "user"}) +_CUE_KINDS = frozenset({"lyric", "count", "transition"}) +_REHEARSAL_PRIORITIES = frozenset({"low", "medium", "high"}) +_SECTION_FORM_LABELS = frozenset( + { + "intro", + "verse", + "pre-chorus", + "chorus", + "bridge", + "outro", + "tag", + "pickup", + "stop", + "handoff", + } +) +_EXPORT_FORMATS = frozenset({"cue-sheet", "chart-summary"}) +_ASSIGNMENT_STATUSES = frozenset({"todo", "in_progress", "ready", "blocked"}) +_COMMENT_STATUSES = frozenset({"open", "resolved"}) +_APPROVAL_STATUSES = frozenset({"pending", "approved", "changes_requested"}) +_COLLABORATION_SYNC_MODES = frozenset({"local_only", "planned_cloud"}) +_CONFIDENCE_KEYS = frozenset({"level", "source", "notes"}) +_HARMONY_KEYS = frozenset({"chord", "functionLabel", "source"}) +_CUE_KEYS = frozenset({"kind", "value"}) +_RANGE_KEYS = frozenset({"lowestNote", "highestNote"}) +_MANUAL_OVERRIDE_KEYS = frozenset({"field", "value", "source"}) +_TRANSCRIPTION_NOTE_KEYS = frozenset({"pitch", "onset", "offset", "velocity"}) +_ROLE_KEYS = frozenset( + { + "id", + "name", + "roleType", + "harmony", + "harmonicExplanation", + "cue", + "range", + "confidence", + "rehearsalPriority", + "simplification", + "setupNote", + "transpositionPlan", + "manualOverrides", + "overlapWarnings", + "transcription", + "practiceProgress", + } +) +_PART_GRAPH_NODE_KEYS = frozenset({"role_id", "is_active", "handoff_to", "handoff_from"}) +_TIME_RANGE_KEYS = frozenset({"start", "end"}) +_SECTION_KEYS = frozenset( + {"id", "label", "groove", "timeRange", "confidence", "roles", "partGraph"} +) +_EXPORT_SUMMARY_KEYS = frozenset({"format", "headline", "focusSections"}) +_ASSIGNMENT_KEYS = frozenset({"id", "assignee", "summary", "sectionId", "roleId", "status"}) +_COMMENT_KEYS = frozenset({"id", "author", "body", "sectionId", "roleId", "status"}) +_APPROVAL_KEYS = frozenset({"id", "scope", "owner", "status"}) +_COLLABORATION_KEYS = frozenset({"syncMode", "syncNote", "assignments", "comments", "approvals"}) +_SCORE_ATTACHMENT_KEYS = frozenset({"id", "fileName"}) +_REHEARSAL_SONG_KEYS = frozenset( + {"id", "title", "tempo", "sections", "exportSummary", "collaboration", "scoreAttachments"} +) +_WINDOWS_MOVEFILE_REPLACE_EXISTING = 0x00000001 +_WINDOWS_MOVEFILE_WRITE_THROUGH = 0x00000008 + + +def _sync_parent_directory(directory: Path) -> None: + """Flush a POSIX parent directory after an atomic cache-name replacement.""" + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) + directory_fd = os.open(directory, flags) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + + +def _replace_windows_write_through(stage: Path, target: Path) -> None: + """Replace a Windows cache entry with write-through rename semantics.""" + import ctypes + import ctypes.wintypes + + win_dll = getattr(ctypes, "WinDLL", None) + get_last_error = getattr(ctypes, "get_last_error", None) + if win_dll is None or get_last_error is None: + raise OSError("Windows write-through publication is unavailable") + + kernel32 = win_dll("kernel32", use_last_error=True) + move_file_ex = kernel32.MoveFileExW + move_file_ex.argtypes = [ + ctypes.wintypes.LPCWSTR, + ctypes.wintypes.LPCWSTR, + ctypes.wintypes.DWORD, + ] + move_file_ex.restype = ctypes.wintypes.BOOL + flags = _WINDOWS_MOVEFILE_REPLACE_EXISTING | _WINDOWS_MOVEFILE_WRITE_THROUGH + if not move_file_ex(str(stage), str(target), flags): + error_code = int(get_last_error()) + raise OSError(error_code, "Could not durably publish the final-result cache") + + +def _publish_synced_cache_stage(stage: Path, target: Path) -> None: + """Publish a fully synced cache stage and durably commit its directory entry.""" + if os.name == "nt": + _replace_windows_write_through(stage, target) + return + os.replace(stage, target) + _sync_parent_directory(target.parent) + + +def store_durable_cache_payload(path: Path, payload: object) -> None: + """Write one JSON cache payload and return only after durable publication succeeds.""" + path.parent.mkdir(parents=True, exist_ok=True) + cache_file = tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=".bandscope-final-cache-", + suffix=".tmp", + delete=False, + ) + temp_path = Path(cache_file.name) + try: + with cache_file: + json.dump(payload, cache_file, separators=(",", ":")) + cache_file.flush() + os.fsync(cache_file.fileno()) + _publish_synced_cache_stage(temp_path, path) + except (OSError, TypeError, ValueError): + with suppress(OSError): + temp_path.unlink(missing_ok=True) + raise + with suppress(OSError): + temp_path.unlink(missing_ok=True) + + +def admitted_audio_cache_identity() -> dict[str, object] | None: + """Return source plus MIR-generation evidence for one analysis child process. + + Native byte-count/SHA-256 remains Resource Admission truth. Persistence adds + the current Signal/MIR generation solely to decide whether derived cache bytes + may be reused. Missing native evidence preserves direct-library compatibility; + partial native evidence or unverifiable MIR generation fails closed so callers + disable cache reuse rather than guessing scientific equivalence. + """ + evidence = _admitted_audio_evidence_from_environment() + if evidence is None: + return None + mir_generation = separation_generation_identity() + if mir_generation is None: + raise ValueError("MIR generation identity is unavailable") + file_size_bytes, content_sha256 = evidence + return { + "fileSizeBytes": file_size_bytes, + "contentSha256": content_sha256, + "analysisGeneration": FINAL_RESULT_ANALYSIS_GENERATION, + "mirGeneration": mir_generation, + } + + +def load_admitted_rehearsal_song(path: Path, *, schema_version: int) -> dict[str, Any] | None: + """Load one bounded, duplicate-free, semantically valid rehearsal result cache.""" + try: + expected_identity = admitted_audio_cache_identity() + except ValueError: + return None + + try: + with path.open("rb") as cache_file: + raw_payload = cache_file.read(MAX_FINAL_RESULT_CACHE_BYTES + 1) + except OSError: + return None + if not raw_payload or len(raw_payload) > MAX_FINAL_RESULT_CACHE_BYTES: + return None + + try: + payload = json.loads( + raw_payload.decode("utf-8"), + object_pairs_hook=_reject_duplicate_json_keys, + ) + except (UnicodeDecodeError, ValueError, json.JSONDecodeError): + return None + + if not isinstance(payload, dict) or payload.get("schemaVersion") != schema_version: + return None + if expected_identity is not None: + source = payload.get("source") + if not isinstance(source, dict) or source.get("admittedAudio") != expected_identity: + return None + result = payload.get("result") + if not _valid_rehearsal_song(result): + return None + return result + + +def _reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]: + """Build one JSON object while rejecting duplicate keys at every nesting level.""" + payload: dict[str, object] = {} + for key, value in pairs: + if key in payload: + raise ValueError(f"duplicate JSON key: {key}") + payload[key] = value + return payload + + +def _has_only_allowed_keys(value: dict[object, object], allowed_keys: frozenset[str]) -> bool: + """Keep persisted JSON key-strict with the canonical shared rehearsal contract.""" + return all(isinstance(key, str) and key in allowed_keys for key in value) + + +def _nonempty_string(value: object) -> bool: + """Return whether a value is a non-blank string.""" + return isinstance(value, str) and bool(value.strip()) + + +def _string_list(value: object) -> bool: + """Return whether a value is a list containing only non-blank strings.""" + return isinstance(value, list) and all(_nonempty_string(item) for item in value) + + +def _finite_number(value: object) -> bool: + """Return whether a value is a finite JSON-style number rather than a Boolean.""" + return ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(float(value)) + ) + + +def _valid_confidence(value: object) -> bool: + """Validate the persisted confidence payload needed by rehearsal views.""" + return ( + isinstance(value, dict) + and _has_only_allowed_keys(value, _CONFIDENCE_KEYS) + and value.get("level") in _CONFIDENCE_LEVELS + and value.get("source") in _PROVENANCE_SOURCES + and isinstance(value.get("notes"), str) + ) + + +def _valid_harmony(value: object) -> bool: + """Validate one persisted harmony payload without inventing musical evidence.""" + return ( + isinstance(value, dict) + and _has_only_allowed_keys(value, _HARMONY_KEYS) + and isinstance(value.get("chord"), str) + and isinstance(value.get("functionLabel"), str) + and value.get("source") in _PROVENANCE_SOURCES + ) + + +def _valid_cue(value: object) -> bool: + """Validate one persisted rehearsal cue consumed by timeline and role views.""" + return ( + isinstance(value, dict) + and _has_only_allowed_keys(value, _CUE_KEYS) + and value.get("kind") in _CUE_KINDS + and isinstance(value.get("value"), str) + ) + + +def _valid_range(value: object) -> bool: + """Validate one persisted note-range summary.""" + return ( + isinstance(value, dict) + and _has_only_allowed_keys(value, _RANGE_KEYS) + and isinstance(value.get("lowestNote"), str) + and isinstance(value.get("highestNote"), str) + ) + + +def _valid_manual_override(value: object) -> bool: + """Validate the only currently supported persisted manual override shape.""" + if not isinstance(value, dict) or not _has_only_allowed_keys(value, _MANUAL_OVERRIDE_KEYS): + return False + harmony = value.get("value") + return ( + value.get("field") == "harmony" + and value.get("source") == "user" + and _valid_harmony(harmony) + and isinstance(harmony, dict) + and harmony.get("source") == "user" + ) + + +def _valid_transcription_note(value: object) -> bool: + """Validate an optional persisted transcription note consumed by Groove Map.""" + return ( + isinstance(value, dict) + and _has_only_allowed_keys(value, _TRANSCRIPTION_NOTE_KEYS) + and isinstance(value.get("pitch"), str) + and _finite_number(value.get("onset")) + and _finite_number(value.get("offset")) + and _finite_number(value.get("velocity")) + ) + + +def _valid_role(value: object) -> bool: + """Validate required and consumer-visible optional persisted role fields.""" + if not isinstance(value, dict) or not _has_only_allowed_keys(value, _ROLE_KEYS): + return False + if not all(_nonempty_string(value.get(field)) for field in ("id", "name")): + return False + if value.get("roleType") not in _ROLE_TYPES: + return False + if not _valid_harmony(value.get("harmony")): + return False + if "harmonicExplanation" in value and not isinstance(value["harmonicExplanation"], str): + return False + if not _valid_cue(value.get("cue")): + return False + if not _valid_range(value.get("range")): + return False + if not _valid_confidence(value.get("confidence")): + return False + if value.get("rehearsalPriority") not in _REHEARSAL_PRIORITIES: + return False + if not isinstance(value.get("simplification"), str): + return False + if not isinstance(value.get("setupNote"), str): + return False + if "transpositionPlan" in value and not isinstance(value["transpositionPlan"], str): + return False + manual_overrides = value.get("manualOverrides") + if not isinstance(manual_overrides, list) or not all( + _valid_manual_override(override) for override in manual_overrides + ): + return False + overlap_warnings = value.get("overlapWarnings") + if not isinstance(overlap_warnings, list) or not all( + isinstance(warning, str) for warning in overlap_warnings + ): + return False + if "transcription" in value: + transcription = value["transcription"] + if not isinstance(transcription, list) or not all( + _valid_transcription_note(note) for note in transcription + ): + return False + if "practiceProgress" in value: + practice_progress = value["practiceProgress"] + if ( + not isinstance(practice_progress, int) + or isinstance(practice_progress, bool) + or not 0 <= practice_progress <= 100 + ): + return False + return True + + +def _valid_part_graph_node(value: object) -> bool: + """Validate one section part-graph node and its handoff references.""" + return ( + isinstance(value, dict) + and _has_only_allowed_keys(value, _PART_GRAPH_NODE_KEYS) + and _nonempty_string(value.get("role_id")) + and isinstance(value.get("is_active"), bool) + and _string_list(value.get("handoff_to")) + and _string_list(value.get("handoff_from")) + ) + + +def _valid_time_range(value: object) -> bool: + """Validate the shared unsigned section-time interval contract.""" + if not isinstance(value, dict) or not _has_only_allowed_keys(value, _TIME_RANGE_KEYS): + return False + start = value.get("start") + end = value.get("end") + return ( + isinstance(start, int) + and not isinstance(start, bool) + and isinstance(end, int) + and not isinstance(end, bool) + and 0 <= start < end <= _MAX_SECTION_TIME_SECONDS + ) + + +def _valid_section(value: object) -> bool: + """Validate one persisted rehearsal section before exposing a cache hit.""" + if not isinstance(value, dict) or not _has_only_allowed_keys(value, _SECTION_KEYS): + return False + if not _nonempty_string(value.get("id")): + return False + if value.get("label") not in _SECTION_FORM_LABELS: + return False + if not _nonempty_string(value.get("groove")): + return False + if not _valid_time_range(value.get("timeRange")) or not _valid_confidence( + value.get("confidence") + ): + return False + roles = value.get("roles") + part_graph = value.get("partGraph") + return ( + isinstance(roles, list) + and all(_valid_role(role) for role in roles) + and isinstance(part_graph, list) + and all(_valid_part_graph_node(node) for node in part_graph) + ) + + +def _valid_export_summary(value: object) -> bool: + """Validate the cached cue-sheet summary consumed by export and rehearsal UI.""" + return ( + isinstance(value, dict) + and _has_only_allowed_keys(value, _EXPORT_SUMMARY_KEYS) + and value.get("format") in _EXPORT_FORMATS + and _nonempty_string(value.get("headline")) + and _string_list(value.get("focusSections")) + ) + + +def _valid_assignment(value: object) -> bool: + """Validate one optional persisted collaboration assignment.""" + if not isinstance(value, dict) or not _has_only_allowed_keys(value, _ASSIGNMENT_KEYS): + return False + if not all( + isinstance(value.get(field), str) + for field in ("id", "assignee", "summary", "sectionId") + ): + return False + if "roleId" in value and not isinstance(value["roleId"], str): + return False + return value.get("status") in _ASSIGNMENT_STATUSES + + +def _valid_comment(value: object) -> bool: + """Validate one optional persisted rehearsal comment.""" + if not isinstance(value, dict) or not _has_only_allowed_keys(value, _COMMENT_KEYS): + return False + if not all( + isinstance(value.get(field), str) + for field in ("id", "author", "body", "sectionId") + ): + return False + if "roleId" in value and not isinstance(value["roleId"], str): + return False + return value.get("status") in _COMMENT_STATUSES + + +def _valid_approval(value: object) -> bool: + """Validate one optional persisted rehearsal approval.""" + return ( + isinstance(value, dict) + and _has_only_allowed_keys(value, _APPROVAL_KEYS) + and all(isinstance(value.get(field), str) for field in ("id", "scope", "owner")) + and value.get("status") in _APPROVAL_STATUSES + ) + + +def _valid_collaboration(value: object) -> bool: + """Validate optional persisted collaboration state before a cache hit is trusted.""" + if not isinstance(value, dict) or not _has_only_allowed_keys(value, _COLLABORATION_KEYS): + return False + if value.get("syncMode") not in _COLLABORATION_SYNC_MODES: + return False + if not isinstance(value.get("syncNote"), str): + return False + assignments = value.get("assignments") + comments = value.get("comments") + approvals = value.get("approvals") + return ( + isinstance(assignments, list) + and all(_valid_assignment(item) for item in assignments) + and isinstance(comments, list) + and all(_valid_comment(item) for item in comments) + and isinstance(approvals, list) + and all(_valid_approval(item) for item in approvals) + ) + + +def _valid_score_attachment(value: object) -> bool: + """Validate optional score metadata exposed by rehearsal views.""" + return ( + isinstance(value, dict) + and _has_only_allowed_keys(value, _SCORE_ATTACHMENT_KEYS) + and _nonempty_string(value.get("id")) + and _nonempty_string(value.get("fileName")) + ) + + +def _valid_rehearsal_song(value: object) -> bool: + """Validate the persisted RehearsalSong envelope before trusting cached content.""" + if not isinstance(value, dict) or not _has_only_allowed_keys(value, _REHEARSAL_SONG_KEYS): + return False + if not _nonempty_string(value.get("id")) or not _nonempty_string(value.get("title")): + return False + tempo = value.get("tempo") + if tempo is not None and ( + not isinstance(tempo, int) or isinstance(tempo, bool) or tempo <= 0 + ): + return False + sections = value.get("sections") + if not ( + isinstance(sections, list) + and bool(sections) + and all(_valid_section(section) for section in sections) + and _valid_export_summary(value.get("exportSummary")) + ): + return False + if "collaboration" in value and not _valid_collaboration(value["collaboration"]): + return False + if "scoreAttachments" in value: + score_attachments = value["scoreAttachments"] + if not isinstance(score_attachments, list) or not all( + _valid_score_attachment(attachment) for attachment in score_attachments + ): + return False + return True 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/separation/generation.py b/services/analysis-engine/src/bandscope_analysis/separation/generation.py new file mode 100644 index 000000000..391dddce4 --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/separation/generation.py @@ -0,0 +1,71 @@ +"""Versioned scientific-generation identity for local source separation. + +The model/checkpoint mapping stays owned by :mod:`audio_separator`. This adapter +reads that canonical mapping instead of copying it so persistence can invalidate +derived caches when the MIR implementation, runtime, or admitted model generation +changes. The checkpoint filename contains only the Demucs checksum prefix; full +release digest/signature/rights provenance remains a Distribution responsibility. +""" + +from __future__ import annotations + +import importlib.metadata as importlib_metadata +from typing import TypedDict + +from . import audio_separator + +MIR_IMPLEMENTATION_GENERATION = 1 + + +class SeparationGenerationIdentity(TypedDict): + """Stable fields that determine whether persisted separation features are reusable.""" + + implementation: str + implementationGeneration: int + modelName: str + checkpointName: str + checkpointSignature: str + checkpointChecksumPrefix: str + demucsVersion: str + torchVersion: str + targetSampleRate: int + overlap: float + device: str + + +def separation_generation_identity() -> SeparationGenerationIdentity | None: + """Return the current default separation generation, or ``None`` if it is unverifiable. + + Cache admission uses the production default separator configuration. Package + metadata lookup avoids importing Demucs or torch just to decide whether stale + derived features may be reused. Missing package metadata therefore disables + cache reuse rather than treating two runtimes as scientifically equivalent. + """ + config = audio_separator.AudioSeparationConfig() + checkpoint_name = audio_separator._DEMUCS_LOCAL_CHECKPOINTS.get(config.model_name) + if checkpoint_name is None: + return None + checkpoint_identity = audio_separator._checkpoint_signature_and_checksum(checkpoint_name) + if checkpoint_identity is None: + return None + checkpoint_signature, checkpoint_checksum_prefix = checkpoint_identity + + try: + demucs_version = importlib_metadata.version("demucs") + torch_version = importlib_metadata.version("torch") + except importlib_metadata.PackageNotFoundError: + return None + + return { + "implementation": "bandscope-demucs", + "implementationGeneration": MIR_IMPLEMENTATION_GENERATION, + "modelName": config.model_name, + "checkpointName": checkpoint_name, + "checkpointSignature": checkpoint_signature, + "checkpointChecksumPrefix": checkpoint_checksum_prefix, + "demucsVersion": demucs_version, + "torchVersion": torch_version, + "targetSampleRate": config.target_sample_rate, + "overlap": config.overlap, + "device": config.device, + } 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_analysis_cache_admission_identity.py b/services/analysis-engine/tests/test_analysis_cache_admission_identity.py new file mode 100644 index 000000000..423020450 --- /dev/null +++ b/services/analysis-engine/tests/test_analysis_cache_admission_identity.py @@ -0,0 +1,398 @@ +"""Regression contracts for final rehearsal-result cache admission.""" + +from __future__ import annotations + +import copy +import hashlib +import json +from unittest.mock import patch + +import pytest + +from bandscope_analysis.api import ( + ANALYSIS_CACHE_SCHEMA_VERSION, + _analysis_cache_path, + _load_cached_analysis, + _store_cached_analysis, + build_demo_rehearsal_song, + run_analysis_job_updates, + validate_analysis_job_request, +) +from bandscope_analysis.final_result_cache import ( + FINAL_RESULT_ANALYSIS_GENERATION, + MAX_FINAL_RESULT_CACHE_BYTES, + _nonempty_string, + _reject_duplicate_json_keys, + _string_list, + _valid_confidence, + _valid_cue, + _valid_export_summary, + _valid_harmony, + _valid_manual_override, + _valid_part_graph_node, + _valid_range, + _valid_rehearsal_song, + _valid_role, + _valid_section, + _valid_time_range, + admitted_audio_cache_identity, +) +from bandscope_analysis.separation.generation import separation_generation_identity + + +def _same_size_replacement(payload: bytes) -> bytes: + """Return different bytes without changing the encoded byte count.""" + replacement = bytearray(payload) + replacement[-1] ^= 0x01 + return bytes(replacement) + + +def _local_request(tmp_path, *, file_size_bytes: int = 64): + """Build one app-rooted local-audio request for cache tests.""" + return validate_analysis_job_request( + { + "sourceKind": "local_audio", + "projectId": "project-cache-identity", + "sourceLabel": "source.wav", + "roleFocus": ["bass-guitar"], + "localSource": { + "sourcePath": str(tmp_path / "source.wav"), + "fileName": "source.wav", + "extension": "wav", + "fileSizeBytes": file_size_bytes, + }, + "cacheRoot": str(tmp_path / "cache"), + "tempRoot": str(tmp_path / "temp"), + } + ) + + +def _set_admitted_identity(monkeypatch, payload: bytes) -> str: + """Install one native-style evidence pair and return its digest.""" + digest = hashlib.sha256(payload).hexdigest() + monkeypatch.setenv("BANDSCOPE_ADMITTED_AUDIO_BYTES", str(len(payload))) + monkeypatch.setenv("BANDSCOPE_ADMITTED_AUDIO_SHA256", digest) + return digest + + +def test_final_cache_cannot_cross_native_content_identity(tmp_path, monkeypatch) -> None: + """Do not reuse a final result produced for another admitted content digest.""" + original = b"RIFF-admitted-cache-source" + replacement = _same_size_replacement(original) + source_path = tmp_path / "source.wav" + source_path.write_bytes(original) + request = _local_request(tmp_path, file_size_bytes=len(original)) + + _set_admitted_identity(monkeypatch, original) + cache_path = _analysis_cache_path(request) + assert cache_path is not None + assert _store_cached_analysis(cache_path, request, build_demo_rehearsal_song()) is True + + source_path.write_bytes(replacement) + _set_admitted_identity(monkeypatch, replacement) + + with patch( + "bandscope_analysis.api._run_stem_separation_with_timeout", + side_effect=ValueError("source changed before decode"), + ) as separator: + updates = run_analysis_job_updates( + "job-cache-identity-mismatch", + request, + "2026-09-14T05:00:00Z", + ) + + assert updates[-1]["state"] == "failed" + assert updates[-1].get("cacheStatus") == "miss" + separator.assert_called_once() + + +def test_final_cache_round_trip_uses_same_native_identity(tmp_path, monkeypatch) -> None: + """Reuse a valid result only while native admission and MIR generation remain identical.""" + payload = b"RIFF-stable-cache-source" + _set_admitted_identity(monkeypatch, payload) + request = _local_request(tmp_path, file_size_bytes=len(payload)) + cache_path = _analysis_cache_path(request) + assert cache_path is not None + + song = build_demo_rehearsal_song() + assert _store_cached_analysis(cache_path, request, song) is True + assert _load_cached_analysis(cache_path) == song + + mir_generation = separation_generation_identity() + assert mir_generation is not None + stored = json.loads(cache_path.read_text(encoding="utf-8")) + assert stored["source"]["admittedAudio"] == { + "fileSizeBytes": len(payload), + "contentSha256": hashlib.sha256(payload).hexdigest(), + "analysisGeneration": FINAL_RESULT_ANALYSIS_GENERATION, + "mirGeneration": mir_generation, + } + + +def test_final_cache_loader_rejects_native_identity_mismatch(tmp_path, monkeypatch) -> None: + """Reject a copied cache payload whose stored admitted identity is not current.""" + first = b"RIFF-cache-source-a" + second = _same_size_replacement(first) + _set_admitted_identity(monkeypatch, first) + request = _local_request(tmp_path, file_size_bytes=len(first)) + cache_path = _analysis_cache_path(request) + assert cache_path is not None + assert _store_cached_analysis(cache_path, request, build_demo_rehearsal_song()) is True + + _set_admitted_identity(monkeypatch, second) + assert _load_cached_analysis(cache_path) is None + + +def test_partial_native_identity_disables_cache_reuse(tmp_path, monkeypatch) -> None: + """Fail cache admission closed when the native trust handoff is only partial.""" + request = _local_request(tmp_path) + monkeypatch.setenv("BANDSCOPE_ADMITTED_AUDIO_BYTES", "64") + monkeypatch.delenv("BANDSCOPE_ADMITTED_AUDIO_SHA256", raising=False) + + with pytest.raises(ValueError): + admitted_audio_cache_identity() + assert _analysis_cache_path(request) is None + assert _store_cached_analysis( + tmp_path / "cache.json", + request, + build_demo_rehearsal_song(), + ) is False + assert _load_cached_analysis(tmp_path / "cache.json") is None + + +def test_missing_native_identity_preserves_direct_library_cache(tmp_path, monkeypatch) -> None: + """Keep the existing direct-library path while no native evidence is in scope.""" + monkeypatch.delenv("BANDSCOPE_ADMITTED_AUDIO_BYTES", raising=False) + monkeypatch.delenv("BANDSCOPE_ADMITTED_AUDIO_SHA256", raising=False) + assert admitted_audio_cache_identity() is None + + request = _local_request(tmp_path) + cache_path = _analysis_cache_path(request) + assert cache_path is not None + song = build_demo_rehearsal_song() + assert _store_cached_analysis(cache_path, request, song) is True + assert _load_cached_analysis(cache_path) == song + + +def test_final_cache_rejects_structurally_invalid_rehearsal_song(tmp_path) -> None: + """Treat a schema-versioned but semantically incomplete result as a cache miss.""" + cache_path = tmp_path / "analysis-cache.json" + cache_path.write_text( + json.dumps( + { + "schemaVersion": ANALYSIS_CACHE_SCHEMA_VERSION, + "source": { + "fileName": "source.wav", + "extension": "wav", + "fileSizeBytes": 1024, + }, + "result": {"id": "incomplete-song"}, + } + ), + encoding="utf-8", + ) + + assert _load_cached_analysis(cache_path) is None + + +@pytest.mark.parametrize( + "raw_payload", + [ + b"", + b"{", + b"\xff", + b"[]", + b'{"schemaVersion":999,"result":{}}', + b'{"schemaVersion":1,"schemaVersion":1,"result":{}}', + ], +) +def test_final_cache_loader_rejects_malformed_or_noncanonical_json(tmp_path, raw_payload) -> None: + """Reject empty, undecodable, malformed, wrong-schema, or duplicate-key cache data.""" + cache_path = tmp_path / "analysis-cache.json" + cache_path.write_bytes(raw_payload) + assert _load_cached_analysis(cache_path) is None + + +def test_final_cache_loader_rejects_oversized_payload(tmp_path) -> None: + """Bound cache parsing before JSON allocation or semantic admission.""" + cache_path = tmp_path / "analysis-cache.json" + cache_path.write_bytes(b" " * (MAX_FINAL_RESULT_CACHE_BYTES + 1)) + assert _load_cached_analysis(cache_path) is None + + +def test_duplicate_json_hook_accepts_unique_keys_and_rejects_duplicates() -> None: + """Reject ambiguous JSON objects at every nesting level.""" + assert _reject_duplicate_json_keys([("a", 1), ("b", 2)]) == {"a": 1, "b": 2} + with pytest.raises(ValueError, match="duplicate JSON key"): + _reject_duplicate_json_keys([("a", 1), ("a", 2)]) + + +def test_semantic_cache_predicates_cover_valid_and_invalid_edges() -> None: + """Exercise every final-result semantic boundary with representative bad values.""" + song = build_demo_rehearsal_song() + section = song["sections"][0] + role = section["roles"][0] + node = section["partGraph"][0] + + assert _nonempty_string("role") is True + assert _nonempty_string("") is False + assert _nonempty_string(7) is False + assert _string_list([]) is True + assert _string_list(["bass", "keys"]) is True + assert _string_list("bass") is False + assert _string_list([""]) is False + + assert _valid_confidence(section["confidence"]) is True + assert _valid_confidence(None) is False + for field, value in ( + ("level", ""), + ("level", "certain"), + ("source", 1), + ("source", "cache"), + ("notes", 1), + ): + candidate = dict(section["confidence"]) + candidate[field] = value + assert _valid_confidence(candidate) is False + + assert _valid_harmony(role["harmony"]) is True + assert _valid_harmony(None) is False + for field, value in (("chord", 1), ("functionLabel", 1), ("source", "cache")): + candidate = dict(role["harmony"]) + candidate[field] = value + assert _valid_harmony(candidate) is False + + assert _valid_cue(role["cue"]) is True + assert _valid_cue(None) is False + for field, value in (("kind", "bar"), ("value", 1)): + candidate = dict(role["cue"]) + candidate[field] = value + assert _valid_cue(candidate) is False + + assert _valid_range(role["range"]) is True + assert _valid_range(None) is False + for field in ("lowestNote", "highestNote"): + candidate = dict(role["range"]) + candidate[field] = 1 + assert _valid_range(candidate) is False + + valid_override = { + "field": "harmony", + "value": {"chord": "C", "functionLabel": "I", "source": "user"}, + "source": "user", + } + assert _valid_manual_override(valid_override) is True + assert _valid_manual_override(None) is False + for field, value in ( + ("field", "cue"), + ("source", "model"), + ("value", None), + ("value", {"chord": "C", "functionLabel": "I", "source": "model"}), + ): + candidate = copy.deepcopy(valid_override) + candidate[field] = value + assert _valid_manual_override(candidate) is False + + assert _valid_role(role) is True + assert _valid_role(None) is False + for field, value in ( + ("id", ""), + ("name", 1), + ("roleType", "other"), + ("harmony", None), + ("cue", None), + ("range", None), + ("confidence", None), + ("rehearsalPriority", "urgent"), + ("simplification", 1), + ("setupNote", 1), + ("manualOverrides", "bad"), + ("manualOverrides", [None]), + ("overlapWarnings", "bad"), + ("overlapWarnings", [1]), + ): + candidate = copy.deepcopy(role) + candidate[field] = value + assert _valid_role(candidate) is False + + assert _valid_part_graph_node(node) is True + assert _valid_part_graph_node(None) is False + for field, value in ( + ("role_id", ""), + ("is_active", 1), + ("handoff_to", "bad"), + ("handoff_from", [""]), + ): + candidate = dict(node) + candidate[field] = value + assert _valid_part_graph_node(candidate) is False + + assert _valid_time_range(section["timeRange"]) is True + assert _valid_time_range(None) is False + for candidate in ( + {"start": True, "end": 2}, + {"start": -1, "end": 2}, + {"start": 1, "end": True}, + {"start": 2, "end": 2}, + {"start": 0, "end": 4_294_967_296}, + ): + assert _valid_time_range(candidate) is False + + assert _valid_section(section) is True + assert _valid_section(None) is False + for field, value in ( + ("id", ""), + ("label", 1), + ("groove", ""), + ("timeRange", None), + ("confidence", None), + ("roles", "bad"), + ("roles", [None]), + ("partGraph", "bad"), + ("partGraph", [None]), + ): + candidate = copy.deepcopy(section) + candidate[field] = value + assert _valid_section(candidate) is False + + summary = song["exportSummary"] + assert _valid_export_summary(summary) is True + assert _valid_export_summary(None) is False + for field, value in (("format", ""), ("headline", 1), ("focusSections", [""])): + candidate = dict(summary) + candidate[field] = value + assert _valid_export_summary(candidate) is False + + assert _valid_rehearsal_song(song) is True + assert _valid_rehearsal_song(None) is False + for field, value in (("id", ""), ("title", 1), ("sections", []), ("exportSummary", None)): + candidate = copy.deepcopy(song) + candidate[field] = value + assert _valid_rehearsal_song(candidate) is False + for invalid_tempo in (True, "120", 0): + candidate = copy.deepcopy(song) + candidate["tempo"] = invalid_tempo + assert _valid_rehearsal_song(candidate) is False + candidate = copy.deepcopy(song) + candidate["tempo"] = 120 + assert _valid_rehearsal_song(candidate) is True + + +def test_final_cache_rejects_role_missing_required_rehearsal_contract_fields() -> None: + """Do not return a cached role that cannot satisfy downstream rehearsal consumers.""" + song = build_demo_rehearsal_song() + required_role_fields = ( + "harmony", + "cue", + "range", + "rehearsalPriority", + "simplification", + "setupNote", + "manualOverrides", + "overlapWarnings", + ) + + for field in required_role_fields: + candidate = copy.deepcopy(song) + del candidate["sections"][0]["roles"][0][field] + assert _valid_rehearsal_song(candidate) is False, field 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_feature_cache_integrity_durability.py b/services/analysis-engine/tests/test_feature_cache_integrity_durability.py new file mode 100644 index 000000000..d52a72c32 --- /dev/null +++ b/services/analysis-engine/tests/test_feature_cache_integrity_durability.py @@ -0,0 +1,213 @@ +"""Integrity and crash-ordering contracts for reusable local-audio feature caches.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from unittest.mock import patch + +import numpy as np + +from bandscope_analysis.api import ( + FEATURE_CACHE_SCHEMA_VERSION, + _load_cached_local_audio_features, + _store_cached_local_audio_features, + validate_analysis_job_request, +) + + +def _request(tmp_path: Path): + return validate_analysis_job_request( + { + "sourceKind": "local_audio", + "projectId": "feature-cache-integrity", + "sourceLabel": "rehearsal.wav", + "roleFocus": ["bass-guitar"], + "localSource": { + "sourcePath": "/app-owned/project/source.wav", + "fileName": "rehearsal.wav", + "extension": "wav", + "fileSizeBytes": 4096, + }, + "cacheRoot": str(tmp_path / "cache"), + "tempRoot": str(tmp_path / "temp"), + } + ) + + +def _features() -> dict[str, object]: + return { + "stems": { + "vocals": np.arange(16, dtype=np.float32), + "bass": np.arange(16, dtype=np.float32) * 0.5, + }, + "sr": 22050, + "stem_role_types": {"vocals": "vocal", "bass": "instrument"}, + "separation": { + "duration_seconds": 1.0, + "chunk_count": 1, + "notes": "Two admitted stems.", + }, + } + + +def _identity(content_sha256: str = "a" * 64) -> dict[str, object]: + return { + "fileSizeBytes": 4096, + "contentSha256": content_sha256, + "analysisGeneration": 1, + } + + +def test_feature_cache_manifest_binds_native_source_and_exact_arrays_bytes( + tmp_path: Path, +) -> None: + """A reusable feature manifest must bind native source evidence and exact NPZ bytes.""" + metadata_path = tmp_path / "features.json" + arrays_path = tmp_path / "features.npz" + admitted_identity = _identity() + + with patch( + "bandscope_analysis.api.admitted_audio_cache_identity", + return_value=admitted_identity, + ): + assert ( + _store_cached_local_audio_features( + metadata_path, + arrays_path, + _request(tmp_path), + _features(), + ) + is True + ) + + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + assert metadata["schemaVersion"] == FEATURE_CACHE_SCHEMA_VERSION + assert metadata["source"]["admittedAudio"] == admitted_identity + assert metadata["arraysSha256"] == hashlib.sha256(arrays_path.read_bytes()).hexdigest() + + +def test_feature_cache_rejects_swapped_arrays_even_when_npz_shape_is_valid(tmp_path: Path) -> None: + """A valid-looking NPZ cannot be substituted underneath an already-published manifest.""" + metadata_path = tmp_path / "features.json" + arrays_path = tmp_path / "features.npz" + + with patch( + "bandscope_analysis.api.admitted_audio_cache_identity", + return_value=_identity(), + ): + assert _store_cached_local_audio_features( + metadata_path, + arrays_path, + _request(tmp_path), + _features(), + ) + + np.savez_compressed( + arrays_path, + stem_vocals=np.ones(16, dtype=np.float32), + stem_bass=np.ones(16, dtype=np.float32), + ) + + with patch( + "bandscope_analysis.api.admitted_audio_cache_identity", + return_value=_identity(), + ): + assert _load_cached_local_audio_features(metadata_path, arrays_path) is None + + +def test_feature_cache_rejects_native_source_identity_change(tmp_path: Path) -> None: + """Invalidate derived stems when source bytes change despite a stable path and size.""" + metadata_path = tmp_path / "features.json" + arrays_path = tmp_path / "features.npz" + + with patch( + "bandscope_analysis.api.admitted_audio_cache_identity", + return_value=_identity("a" * 64), + ): + assert _store_cached_local_audio_features( + metadata_path, + arrays_path, + _request(tmp_path), + _features(), + ) + + with patch( + "bandscope_analysis.api.admitted_audio_cache_identity", + return_value=_identity("b" * 64), + ): + assert _load_cached_local_audio_features(metadata_path, arrays_path) is None + + +def test_feature_cache_publishes_durable_arrays_before_manifest(tmp_path: Path) -> None: + """Publish the manifest only after the exact array payload is durably available.""" + metadata_path = tmp_path / "features.json" + arrays_path = tmp_path / "features.npz" + events: list[str] = [] + + def publish_arrays(stage: Path, destination: Path) -> None: + assert destination == arrays_path + assert stage.exists() + destination.write_bytes(stage.read_bytes()) + stage.unlink() + events.append("arrays") + + def publish_manifest(path: Path, payload: object) -> None: + assert path == metadata_path + assert events == ["arrays"] + path.write_text(json.dumps(payload), encoding="utf-8") + events.append("manifest") + + with ( + patch( + "bandscope_analysis.api.admitted_audio_cache_identity", + return_value=_identity(), + ), + patch("bandscope_analysis.api.publish_synced_cache_stage", publish_arrays, create=True), + patch("bandscope_analysis.api.store_durable_cache_payload", publish_manifest), + ): + assert _store_cached_local_audio_features( + metadata_path, + arrays_path, + _request(tmp_path), + _features(), + ) + + assert events == ["arrays", "manifest"] + + +def test_feature_cache_fails_closed_before_manifest_when_array_publication_fails( + tmp_path: Path, +) -> None: + """A failed durable NPZ publication must not advertise a reusable manifest.""" + metadata_path = tmp_path / "features.json" + arrays_path = tmp_path / "features.npz" + + with ( + patch( + "bandscope_analysis.api.admitted_audio_cache_identity", + return_value=_identity(), + ), + patch( + "bandscope_analysis.api.publish_synced_cache_stage", + side_effect=OSError("array publication failed"), + create=True, + ), + ): + assert ( + _store_cached_local_audio_features( + metadata_path, + arrays_path, + _request(tmp_path), + _features(), + ) + is False + ) + + assert not metadata_path.exists() + + +def test_feature_cache_schema_advances_for_integrity_manifest() -> None: + """Old feature manifests are not silently reinterpreted under the stronger contract.""" + assert FEATURE_CACHE_SCHEMA_VERSION >= 2 diff --git a/services/analysis-engine/tests/test_feature_cache_resource_admission.py b/services/analysis-engine/tests/test_feature_cache_resource_admission.py new file mode 100644 index 000000000..7399272c1 --- /dev/null +++ b/services/analysis-engine/tests/test_feature_cache_resource_admission.py @@ -0,0 +1,150 @@ +"""Resource-admission regressions for reusable feature cache artifacts.""" + +import hashlib +import io +import json +import zipfile +from unittest.mock import patch + +import numpy as np + +import bandscope_analysis.api as api +from bandscope_analysis.audio_resource_policy import DEFAULT_AUDIO_RESOURCE_POLICY + +_EXPECTED_MANIFEST_LIMIT_BYTES = 64 * 1024 +_EXPECTED_ARCHIVE_LIMIT_BYTES = ( + DEFAULT_AUDIO_RESOURCE_POLICY.max_decoded_audio_bytes * 4 + 16 * 1024 * 1024 +) + + +def _valid_feature_cache(tmp_path): + """Write one small v2 feature cache pair accepted by the current contract.""" + arrays_path = tmp_path / "track.features.npz" + metadata_path = tmp_path / "track.features.json" + np.savez_compressed(arrays_path, stem_bass=np.zeros(16, dtype=np.float32)) + arrays_sha256 = hashlib.sha256(arrays_path.read_bytes()).hexdigest() + payload = { + "schemaVersion": api.FEATURE_CACHE_SCHEMA_VERSION, + "source": {}, + "arraysSha256": arrays_sha256, + "sampleRate": 44_100, + "separation": { + "duration_seconds": 1.0, + "chunk_count": 1, + "notes": "fixture", + }, + "stemKeys": ["bass"], + "stemRoleTypes": {"bass": "instrument"}, + } + metadata_path.write_text(json.dumps(payload), encoding="utf-8") + return metadata_path, arrays_path, payload + + +def _write_manifest(metadata_path, arrays_path) -> None: + """Write canonical metadata for one bass-stem archive fixture.""" + payload = { + "schemaVersion": api.FEATURE_CACHE_SCHEMA_VERSION, + "source": {}, + "arraysSha256": hashlib.sha256(arrays_path.read_bytes()).hexdigest(), + "sampleRate": 44_100, + "separation": { + "duration_seconds": 1.0, + "chunk_count": 1, + "notes": "fixture", + }, + "stemKeys": ["bass"], + "stemRoleTypes": {"bass": "instrument"}, + } + metadata_path.write_text(json.dumps(payload), encoding="utf-8") + + +def test_feature_cache_manifest_rejects_duplicate_json_keys(tmp_path) -> None: + """Ambiguous duplicate manifest keys must fail closed instead of using last-one-wins JSON.""" + metadata_path, arrays_path, payload = _valid_feature_cache(tmp_path) + canonical_digest = payload["arraysSha256"] + metadata_path.write_text( + "{" + f'"schemaVersion":{api.FEATURE_CACHE_SCHEMA_VERSION},' + '"source":{},' + '"arraysSha256":"' + ("0" * 64) + '",' + f'"arraysSha256":"{canonical_digest}",' + '"sampleRate":44100,' + '"separation":{"duration_seconds":1.0,"chunk_count":1,"notes":"fixture"},' + '"stemKeys":["bass"],' + '"stemRoleTypes":{"bass":"instrument"}' + "}", + encoding="utf-8", + ) + + with patch("bandscope_analysis.api.admitted_audio_cache_identity", return_value=None): + assert api._load_cached_local_audio_features(metadata_path, arrays_path) is None + + +def test_feature_cache_manifest_is_bounded_before_json_decode(tmp_path) -> None: + """Locally replaced cache metadata must not create unbounded JSON read or parse work.""" + metadata_path, arrays_path, payload = _valid_feature_cache(tmp_path) + encoded = json.dumps(payload) + padding = " " * (_EXPECTED_MANIFEST_LIMIT_BYTES - len(encoded) + 1) + metadata_path.write_text(encoded + padding, encoding="utf-8") + + with patch("bandscope_analysis.api.admitted_audio_cache_identity", return_value=None): + assert api._load_cached_local_audio_features(metadata_path, arrays_path) is None + + +def test_feature_cache_archive_size_is_rejected_before_hashing(tmp_path) -> None: + """An oversized derived archive must miss before linear digest or NumPy work begins.""" + metadata_path = tmp_path / "track.features.json" + arrays_path = tmp_path / "track.features.npz" + arrays_path.touch() + arrays_path.truncate(_EXPECTED_ARCHIVE_LIMIT_BYTES + 1) + payload = { + "schemaVersion": api.FEATURE_CACHE_SCHEMA_VERSION, + "source": {}, + "arraysSha256": "0" * 64, + "sampleRate": 44_100, + "separation": { + "duration_seconds": 1.0, + "chunk_count": 1, + "notes": "fixture", + }, + "stemKeys": ["bass"], + "stemRoleTypes": {"bass": "instrument"}, + } + metadata_path.write_text(json.dumps(payload), encoding="utf-8") + + with ( + patch("bandscope_analysis.api.admitted_audio_cache_identity", return_value=None), + patch( + "bandscope_analysis.api._sha256_file", + side_effect=AssertionError("oversized archive reached digest work"), + ), + ): + assert api._load_cached_local_audio_features(metadata_path, arrays_path) is None + + +def test_feature_cache_declared_array_shape_is_bounded_before_numpy_load(tmp_path) -> None: + """A tiny NPZ member must not authorize an NPY header that requests oversized allocation.""" + metadata_path = tmp_path / "track.features.json" + arrays_path = tmp_path / "track.features.npz" + header = io.BytesIO() + declared_elements = DEFAULT_AUDIO_RESOURCE_POLICY.max_decoded_audio_bytes // 8 + 1 + np.lib.format.write_array_header_1_0( + header, + { + "descr": np.dtype(" None: + """A cache result is publishable only after its staged bytes reach stable storage.""" + target = tmp_path / "analysis.json" + events: list[str] = [] + + def record_fsync(_fd: int) -> None: + events.append("file-fsync") + + def record_publish(stage: Path, destination: Path) -> None: + assert stage.parent == destination.parent == tmp_path + assert stage != destination + assert stage.read_text(encoding="utf-8") == '{"schemaVersion":1}' + events.append("publish") + + monkeypatch.setattr(final_result_cache.os, "fsync", record_fsync) + monkeypatch.setattr(final_result_cache, "_publish_synced_cache_stage", record_publish) + + final_result_cache.store_durable_cache_payload(target, {"schemaVersion": 1}) + + assert events == ["file-fsync", "publish"] + assert list(tmp_path.iterdir()) == [] + + +def test_store_durable_cache_cleans_stage_when_publication_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A failed durable publish leaves no orphaned final-result staging file.""" + target = tmp_path / "analysis.json" + + monkeypatch.setattr(final_result_cache.os, "fsync", lambda _fd: None) + monkeypatch.setattr( + final_result_cache, + "_publish_synced_cache_stage", + lambda _stage, _target: (_ for _ in ()).throw(OSError("disk sync failed")), + ) + + with pytest.raises(OSError, match="disk sync failed"): + final_result_cache.store_durable_cache_payload(target, {"schemaVersion": 1}) + + assert list(tmp_path.iterdir()) == [] + + +def test_store_durable_cache_propagates_stage_creation_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Failure before a staging path exists is propagated without inventing cleanup state.""" + target = tmp_path / "analysis.json" + + def fail_named_temporary_file(**_kwargs: object) -> Any: + raise OSError("stage creation failed") + + monkeypatch.setattr( + final_result_cache.tempfile, + "NamedTemporaryFile", + fail_named_temporary_file, + ) + + with pytest.raises(OSError, match="stage creation failed"): + final_result_cache.store_durable_cache_payload(target, {"schemaVersion": 1}) + + assert list(tmp_path.iterdir()) == [] + + +def test_sync_parent_directory_closes_descriptor_after_flush( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Directory durability does not leak its descriptor after a successful flush.""" + events: list[tuple[str, object]] = [] + + monkeypatch.setattr( + final_result_cache.os, + "open", + lambda path, flags: events.append(("open", (path, flags))) or 41, + ) + monkeypatch.setattr( + final_result_cache.os, + "fsync", + lambda fd: events.append(("fsync", fd)), + ) + monkeypatch.setattr( + final_result_cache.os, + "close", + lambda fd: events.append(("close", fd)), + ) + + final_result_cache._sync_parent_directory(tmp_path) + + assert [event[0] for event in events] == ["open", "fsync", "close"] + assert events[1:] == [("fsync", 41), ("close", 41)] + + +def test_sync_parent_directory_closes_descriptor_when_flush_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A failed directory flush still closes the descriptor and propagates failure.""" + closed: list[int] = [] + monkeypatch.setattr(final_result_cache.os, "open", lambda _path, _flags: 52) + monkeypatch.setattr( + final_result_cache.os, + "fsync", + lambda _fd: (_ for _ in ()).throw(OSError("directory fsync failed")), + ) + monkeypatch.setattr(final_result_cache.os, "close", closed.append) + + with pytest.raises(OSError, match="directory fsync failed"): + final_result_cache._sync_parent_directory(tmp_path) + + assert closed == [52] + + +def test_posix_cache_publication_syncs_parent_after_replace( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """POSIX publication must durably commit the renamed directory entry before success.""" + if os.name == "nt": + pytest.skip("POSIX directory-fsync contract") + + stage = tmp_path / ".cache.stage" + target = tmp_path / "analysis.json" + stage.write_text("payload", encoding="utf-8") + events: list[str] = [] + + def record_replace(source: Path, destination: Path) -> None: + assert source == stage + assert destination == target + events.append("replace") + + def record_parent_sync(directory: Path) -> None: + assert directory == tmp_path + events.append("parent-fsync") + + monkeypatch.setattr(final_result_cache.os, "replace", record_replace) + monkeypatch.setattr(final_result_cache, "_sync_parent_directory", record_parent_sync) + + final_result_cache._publish_synced_cache_stage(stage, target) + + assert events == ["replace", "parent-fsync"] + + +def test_windows_cache_publication_uses_write_through_move( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Windows publication uses replace-existing plus write-through rather than plain rename.""" + stage = tmp_path / ".cache.stage" + target = tmp_path / "analysis.json" + calls: list[tuple[str, str, int]] = [] + + class MoveFileExWMock: + argtypes: object = None + restype: object = None + + def __call__(self, source: str, destination: str, flags: int) -> int: + calls.append((source, destination, flags)) + return 1 + + class Kernel32: + MoveFileExW = MoveFileExWMock() + + monkeypatch.setattr(ctypes, "WinDLL", lambda *_args, **_kwargs: Kernel32(), raising=False) + monkeypatch.setattr(ctypes, "get_last_error", lambda: 5, raising=False) + + final_result_cache._replace_windows_write_through(stage, target) + + assert calls == [(str(stage), str(target), 0x00000001 | 0x00000008)] + + +def test_windows_cache_publication_propagates_move_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Windows write-through failures remain explicit durability failures.""" + stage = tmp_path / ".cache.stage" + target = tmp_path / "analysis.json" + + class MoveFileExWMock: + argtypes: object = None + restype: object = None + + def __call__(self, _source: str, _destination: str, _flags: int) -> int: + return 0 + + class Kernel32: + MoveFileExW = MoveFileExWMock() + + monkeypatch.setattr(ctypes, "WinDLL", lambda *_args, **_kwargs: Kernel32(), raising=False) + monkeypatch.setattr(ctypes, "get_last_error", lambda: 5, raising=False) + + with pytest.raises(OSError) as error: + final_result_cache._replace_windows_write_through(stage, target) + + assert error.value.errno == 5 + + +def test_windows_cache_publication_fails_closed_without_win32_bindings( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A missing Win32 binding cannot silently downgrade to a non-durable rename.""" + monkeypatch.setattr(ctypes, "WinDLL", None, raising=False) + monkeypatch.setattr(ctypes, "get_last_error", None, raising=False) + + with pytest.raises(OSError, match="write-through publication is unavailable"): + final_result_cache._replace_windows_write_through( + tmp_path / ".cache.stage", + tmp_path / "analysis.json", + ) + + +def test_publish_synced_cache_stage_dispatches_windows_owner( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The platform dispatcher never falls through to POSIX replacement on Windows.""" + stage = tmp_path / ".cache.stage" + target = tmp_path / "analysis.json" + calls: list[tuple[Path, Path]] = [] + + monkeypatch.setattr(final_result_cache.os, "name", "nt") + monkeypatch.setattr( + final_result_cache, + "_replace_windows_write_through", + lambda source, destination: calls.append((source, destination)), + ) + monkeypatch.setattr( + final_result_cache.os, + "replace", + lambda *_args: pytest.fail("Windows publication fell through to os.replace"), + ) + + final_result_cache._publish_synced_cache_stage(stage, target) + + assert calls == [(stage, target)] + + +def test_cache_publication_failure_is_not_reported_as_stored( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A durability failure must fail closed instead of acknowledging a reusable cache.""" + from bandscope_analysis.api import ( + _store_cached_analysis, + build_demo_rehearsal_song, + validate_analysis_job_request, + ) + + request = validate_analysis_job_request( + { + "sourceKind": "local_audio", + "projectId": "project-cache", + "sourceLabel": "late-night-set.wav", + "roleFocus": ["bass-guitar"], + "localSource": { + "sourcePath": "/app-owned/project/source.wav", + "fileName": "late-night-set.wav", + "extension": "wav", + "fileSizeBytes": 1024000, + }, + } + ) + monkeypatch.setattr( + "bandscope_analysis.api.admitted_audio_cache_identity", + lambda: { + "fileSizeBytes": 1024000, + "contentSha256": "0" * 64, + "analysisGeneration": 1, + }, + ) + + def fail_publication(_path: Path, _payload: object) -> None: + raise OSError("simulated durability failure") + + monkeypatch.setattr( + "bandscope_analysis.api.store_durable_cache_payload", + fail_publication, + ) + + assert ( + _store_cached_analysis( + tmp_path / "analysis.json", + request, + build_demo_rehearsal_song(), + ) + is False + ) diff --git a/services/analysis-engine/tests/test_final_result_cache_shared_contract.py b/services/analysis-engine/tests/test_final_result_cache_shared_contract.py new file mode 100644 index 000000000..0ac2e1b0a --- /dev/null +++ b/services/analysis-engine/tests/test_final_result_cache_shared_contract.py @@ -0,0 +1,231 @@ +"""Shared-contract regressions for persisted final rehearsal-result admission.""" + +from __future__ import annotations + +import copy +import json + +import pytest + +from bandscope_analysis.api import ANALYSIS_CACHE_SCHEMA_VERSION, build_demo_rehearsal_song +from bandscope_analysis.final_result_cache import load_admitted_rehearsal_song + + +def _load_candidate(tmp_path, monkeypatch, song): + """Persist one cache candidate and run the production admission boundary.""" + monkeypatch.delenv("BANDSCOPE_ADMITTED_AUDIO_BYTES", raising=False) + monkeypatch.delenv("BANDSCOPE_ADMITTED_AUDIO_SHA256", raising=False) + path = tmp_path / "analysis-cache.json" + path.write_text( + json.dumps( + { + "schemaVersion": ANALYSIS_CACHE_SCHEMA_VERSION, + "source": {}, + "result": song, + } + ), + encoding="utf-8", + ) + return load_admitted_rehearsal_song(path, schema_version=ANALYSIS_CACHE_SCHEMA_VERSION) + + +def _attach_valid_collaboration(song): + """Attach one valid optional collaboration payload for nested admission tests.""" + role = song["sections"][0]["roles"][0] + song["collaboration"] = { + "syncMode": "local_only", + "syncNote": "Local rehearsal notes only.", + "assignments": [ + { + "id": "assignment-1", + "assignee": "Bass", + "summary": "Lock the pickup.", + "sectionId": song["sections"][0]["id"], + "roleId": role["id"], + "status": "in_progress", + } + ], + "comments": [ + { + "id": "comment-1", + "author": "MD", + "body": "Keep the entrance short.", + "sectionId": song["sections"][0]["id"], + "roleId": role["id"], + "status": "open", + } + ], + "approvals": [ + { + "id": "approval-1", + "scope": "Verse entrance", + "owner": "MD", + "status": "pending", + } + ], + } + + +def test_cache_rejects_section_and_export_enums_outside_shared_contract( + tmp_path, monkeypatch +) -> None: + """Do not admit persisted enum values that the shared UI contract cannot consume.""" + song = build_demo_rehearsal_song() + + invalid_section = copy.deepcopy(song) + invalid_section["sections"][0]["label"] = "custom-section" + assert _load_candidate(tmp_path, monkeypatch, invalid_section) is None + + invalid_export = copy.deepcopy(song) + invalid_export["exportSummary"]["format"] = "json" + assert _load_candidate(tmp_path, monkeypatch, invalid_export) is None + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("harmonicExplanation", 7), + ("transpositionPlan", {"steps": -2}), + ("transcription", "C4"), + ( + "transcription", + [{"pitch": "C4", "onset": 0.0, "offset": 1.0, "velocity": True}], + ), + ("practiceProgress", 101), + ("practiceProgress", True), + ], +) +def test_cache_rejects_invalid_optional_role_fields( + tmp_path, monkeypatch, field, value +) -> None: + """Optional persisted role fields become authoritative when present and must validate.""" + song = build_demo_rehearsal_song() + song["sections"][0]["roles"][0][field] = value + assert _load_candidate(tmp_path, monkeypatch, song) is None + + +def test_cache_rejects_invalid_optional_song_fields(tmp_path, monkeypatch) -> None: + """Reject malformed collaboration and score metadata before exposing a cache hit.""" + song = build_demo_rehearsal_song() + + invalid_collaboration = copy.deepcopy(song) + invalid_collaboration["collaboration"] = { + "syncMode": "cloud", + "syncNote": "", + "assignments": [], + "comments": [], + "approvals": [], + } + assert _load_candidate(tmp_path, monkeypatch, invalid_collaboration) is None + + invalid_scores = copy.deepcopy(song) + invalid_scores["scoreAttachments"] = [{"id": "score-1", "fileName": 7}] + assert _load_candidate(tmp_path, monkeypatch, invalid_scores) is None + + +def test_cache_accepts_valid_optional_shared_contract_fields(tmp_path, monkeypatch) -> None: + """Preserve legitimate optional rehearsal fields while tightening cache admission.""" + song = build_demo_rehearsal_song() + role = song["sections"][0]["roles"][0] + role["harmonicExplanation"] = "Keep the upper extension out of the bass register." + role["transpositionPlan"] = "Move the shape down a whole step if the singer drops the key." + role["transcription"] = [ + {"pitch": "C4", "onset": 0.0, "offset": 0.5, "velocity": 92.0} + ] + role["practiceProgress"] = 60 + _attach_valid_collaboration(song) + song["scoreAttachments"] = [{"id": "score-1", "fileName": "verse-chart.pdf"}] + + assert _load_candidate(tmp_path, monkeypatch, song) == song + + +def test_cache_rejects_unknown_keys_at_shared_contract_boundaries(tmp_path, monkeypatch) -> None: + """Reject forward-incompatible fields anywhere the shared validator is key-strict.""" + base_song = build_demo_rehearsal_song() + + candidates = [] + + song = copy.deepcopy(base_song) + song["futureSongField"] = True + candidates.append(song) + + song = copy.deepcopy(base_song) + song["sections"][0]["futureSectionField"] = True + candidates.append(song) + + song = copy.deepcopy(base_song) + song["sections"][0]["confidence"]["futureConfidenceField"] = True + candidates.append(song) + + song = copy.deepcopy(base_song) + song["sections"][0]["roles"][0]["futureRoleField"] = True + candidates.append(song) + + song = copy.deepcopy(base_song) + song["sections"][0]["roles"][0]["harmony"]["futureHarmonyField"] = True + candidates.append(song) + + song = copy.deepcopy(base_song) + song["sections"][0]["roles"][0]["cue"]["futureCueField"] = True + candidates.append(song) + + song = copy.deepcopy(base_song) + song["sections"][0]["roles"][0]["range"]["futureRangeField"] = True + candidates.append(song) + + song = copy.deepcopy(base_song) + song["sections"][0]["roles"][0]["transcription"] = [ + { + "pitch": "C4", + "onset": 0.0, + "offset": 0.5, + "velocity": 92.0, + "futureNoteField": True, + } + ] + candidates.append(song) + + song = copy.deepcopy(base_song) + song["sections"][0]["roles"][4]["manualOverrides"][0]["futureOverrideField"] = True + candidates.append(song) + + song = copy.deepcopy(base_song) + song["sections"][0]["partGraph"][0]["futureGraphField"] = True + candidates.append(song) + + song = copy.deepcopy(base_song) + song["sections"][0]["timeRange"]["futureRangeField"] = True + candidates.append(song) + + song = copy.deepcopy(base_song) + song["exportSummary"]["futureExportField"] = True + candidates.append(song) + + song = copy.deepcopy(base_song) + _attach_valid_collaboration(song) + song["collaboration"]["futureCollaborationField"] = True + candidates.append(song) + + song = copy.deepcopy(base_song) + _attach_valid_collaboration(song) + song["collaboration"]["assignments"][0]["futureAssignmentField"] = True + candidates.append(song) + + song = copy.deepcopy(base_song) + _attach_valid_collaboration(song) + song["collaboration"]["comments"][0]["futureCommentField"] = True + candidates.append(song) + + song = copy.deepcopy(base_song) + _attach_valid_collaboration(song) + song["collaboration"]["approvals"][0]["futureApprovalField"] = True + candidates.append(song) + + song = copy.deepcopy(base_song) + song["scoreAttachments"] = [ + {"id": "score-1", "fileName": "verse-chart.pdf", "futureScoreField": True} + ] + candidates.append(song) + + for candidate in candidates: + assert _load_candidate(tmp_path, monkeypatch, candidate) is None diff --git a/services/analysis-engine/tests/test_mir_cache_generation_identity.py b/services/analysis-engine/tests/test_mir_cache_generation_identity.py new file mode 100644 index 000000000..80d1b03db --- /dev/null +++ b/services/analysis-engine/tests/test_mir_cache_generation_identity.py @@ -0,0 +1,64 @@ +"""Cache reuse contracts for MIR implementation and model generation identity.""" + +from __future__ import annotations + +import pytest + +import bandscope_analysis.final_result_cache as final_result_cache + +_EXPECTED_MIR_GENERATION: dict[str, object] = { + "implementation": "bandscope-demucs", + "implementationGeneration": 1, + "modelName": "htdemucs", + "checkpointName": "955717e8-8726e21a.th", + "checkpointSignature": "955717e8", + "checkpointChecksumPrefix": "8726e21a", + "demucsVersion": "4.0.1", + "torchVersion": "2.8.0", + "targetSampleRate": 22_050, + "overlap": 0.25, + "device": "cpu", +} + + +def _install_native_source_evidence(monkeypatch: pytest.MonkeyPatch) -> None: + """Provide deterministic native-owned source evidence for cache identity tests.""" + monkeypatch.setattr( + final_result_cache, + "_admitted_audio_evidence_from_environment", + lambda: (4096, "a" * 64), + ) + + +def test_cache_identity_binds_current_mir_generation(monkeypatch: pytest.MonkeyPatch) -> None: + """A cache key must distinguish the MIR implementation/model generation that created it.""" + _install_native_source_evidence(monkeypatch) + monkeypatch.setattr( + final_result_cache, + "separation_generation_identity", + lambda: _EXPECTED_MIR_GENERATION, + raising=False, + ) + + identity = final_result_cache.admitted_audio_cache_identity() + + assert identity is not None + assert identity["fileSizeBytes"] == 4096 + assert identity["contentSha256"] == "a" * 64 + assert identity["mirGeneration"] == _EXPECTED_MIR_GENERATION + + +def test_cache_identity_fails_closed_when_mir_generation_is_unavailable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Disable cache reuse when model/runtime generation evidence is unavailable.""" + _install_native_source_evidence(monkeypatch) + monkeypatch.setattr( + final_result_cache, + "separation_generation_identity", + lambda: None, + raising=False, + ) + + with pytest.raises(ValueError, match="MIR generation identity is unavailable"): + final_result_cache.admitted_audio_cache_identity() 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..927d1acdc --- /dev/null +++ b/services/analysis-engine/tests/test_project_persistence_workflow_policy.py @@ -0,0 +1,106 @@ +"""Regression coverage for native Project Persistence evidence lanes.""" + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[3] +WINDOWS_WORKFLOW = "project-persistence-windows-native.yml" +LEGACY_WINDOWS_WORKFLOW = "project-persistence-windows.yml" +WARNING_GATE_FEATURE = "persistence_warning_gate" +EXACT_SOURCE_REF = "ref: ${{ github.event.pull_request.head.sha || github.sha }}" +REQUIRED_NATIVE_PERSISTENCE_PATHS = ( + '"apps/desktop/core/Cargo.toml"', + '"apps/desktop/core/src/root.rs"', + '"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_load.rs"', + '"apps/desktop/src-tauri/src/project_persistence.rs"', + '"apps/desktop/src-tauri/src/project_persistence_engine.rs"', + '"apps/desktop/src-tauri/src/project_root.rs"', + '"apps/desktop/src-tauri/tests/project_persistence*.rs"', + '"apps/desktop/src-tauri/tests/project_persistence*.case"', + '"docs/traceability/project-persistence-native-ci.md"', + '"services/analysis-engine/tests/test_project_persistence_workflow_policy.py"', +) + + +def _workflow_text(name: str) -> str: + return (REPO_ROOT / ".github" / "workflows" / name).read_text(encoding="utf-8") + + +def _assert_tracks_native_persistence_inputs(workflow: str, lane: str) -> None: + for required_path in REQUIRED_NATIVE_PERSISTENCE_PATHS: + assert required_path in workflow, f"{lane} persistence workflow misses {required_path}" + + +def _assert_enforces_owned_rust_warnings(workflow: str, lane: str) -> None: + assert ( + f"--features {WARNING_GATE_FEATURE}" in workflow + ), f"{lane} persistence workflow must compile owned Rust with the warning gate feature" + + +def _assert_checks_out_exact_source_identity(workflow: str, lane: str) -> None: + assert ( + EXACT_SOURCE_REF in workflow + ), f"{lane} persistence workflow must test the exact PR source head rather than GitHub's merge ref" + + +def test_windows_project_persistence_gate_tracks_contract_inputs() -> None: + """Run the Windows regression whenever a persistence contract input changes.""" + workflow = _workflow_text(WINDOWS_WORKFLOW) + + _assert_tracks_native_persistence_inputs(workflow, "Windows") + _assert_enforces_owned_rust_warnings(workflow, "Windows") + _assert_checks_out_exact_source_identity(workflow, "Windows") + assert f'".github/workflows/{WINDOWS_WORKFLOW}"' in workflow + assert not (REPO_ROOT / ".github" / "workflows" / LEGACY_WINDOWS_WORKFLOW).exists() + assert "runs-on: windows-2025" in workflow + assert ( + "cargo +1.97.1 test --manifest-path apps/desktop/src-tauri/Cargo.toml " + "--no-default-features --features persistence_warning_gate --tests" + ) in workflow + + +def test_macos_project_persistence_gate_tracks_contract_inputs() -> None: + """Run native macOS regressions for the same persistence owner inputs.""" + workflow = _workflow_text("project-persistence-macos.yml") + + _assert_tracks_native_persistence_inputs(workflow, "macOS") + _assert_enforces_owned_rust_warnings(workflow, "macOS") + _assert_checks_out_exact_source_identity(workflow, "macOS") + assert "runs-on: macos-15" in workflow + assert ( + "cargo +1.97.1 test --manifest-path apps/desktop/src-tauri/Cargo.toml " + "--no-default-features --features persistence_warning_gate --tests" + ) in workflow + + +def test_native_warning_gate_is_owned_by_core_and_the_persistence_harness() -> None: + """Deny owned warnings without global RUSTFLAGS, output filtering, or dependency lint changes.""" + core_manifest = (REPO_ROOT / "apps/desktop/core/Cargo.toml").read_text(encoding="utf-8") + tauri_manifest = (REPO_ROOT / "apps/desktop/src-tauri/Cargo.toml").read_text(encoding="utf-8") + core_root = (REPO_ROOT / "apps/desktop/core/src/root.rs").read_text(encoding="utf-8") + harness = (REPO_ROOT / "apps/desktop/src-tauri/tests/project_persistence.rs").read_text( + encoding="utf-8" + ) + + assert f"{WARNING_GATE_FEATURE} = []" in core_manifest + assert ( + f'{WARNING_GATE_FEATURE} = ["bandscope-desktop-core/{WARNING_GATE_FEATURE}"]' + in tauri_manifest + ) + gate_attribute = f'cfg_attr(feature = "{WARNING_GATE_FEATURE}", deny(warnings))' + assert gate_attribute in core_root + assert gate_attribute in harness + + for workflow_name in (WINDOWS_WORKFLOW, "project-persistence-macos.yml"): + workflow = _workflow_text(workflow_name) + assert "RUSTFLAGS" not in workflow + assert "grep" not in workflow.lower() 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_separation_generation_identity.py b/services/analysis-engine/tests/test_separation_generation_identity.py new file mode 100644 index 000000000..17513226d --- /dev/null +++ b/services/analysis-engine/tests/test_separation_generation_identity.py @@ -0,0 +1,71 @@ +"""Scientific-generation contracts for local Demucs separation.""" + +from __future__ import annotations + +import pytest + +from bandscope_analysis.separation import audio_separator, generation + + +def _version(package_name: str) -> str: + """Return deterministic package versions without importing heavy ML runtimes.""" + return {"demucs": "4.0.1", "torch": "2.8.0"}[package_name] + + +def test_separation_generation_identity_binds_implementation_runtime_and_checkpoint( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Cache generation must identify the BandScope code generation and Demucs artifact family.""" + monkeypatch.setattr(generation.importlib_metadata, "version", _version) + + identity = generation.separation_generation_identity() + config = audio_separator.AudioSeparationConfig() + + assert identity == { + "implementation": "bandscope-demucs", + "implementationGeneration": generation.MIR_IMPLEMENTATION_GENERATION, + "modelName": config.model_name, + "checkpointName": "955717e8-8726e21a.th", + "checkpointSignature": "955717e8", + "checkpointChecksumPrefix": "8726e21a", + "demucsVersion": "4.0.1", + "torchVersion": "2.8.0", + "targetSampleRate": config.target_sample_rate, + "overlap": config.overlap, + "device": config.device, + } + + +def test_separation_generation_identity_rejects_missing_checkpoint_mapping( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unknown model generations cannot authorize reuse of prior separated features.""" + monkeypatch.setattr(audio_separator, "_DEMUCS_LOCAL_CHECKPOINTS", {}) + + assert generation.separation_generation_identity() is None + + +def test_separation_generation_identity_rejects_malformed_checkpoint_identity( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A checkpoint without the canonical signature/checksum tuple cannot identify a generation.""" + monkeypatch.setattr( + audio_separator, + "_DEMUCS_LOCAL_CHECKPOINTS", + {"htdemucs": "checkpoint-without-canonical-digest.th"}, + ) + + assert generation.separation_generation_identity() is None + + +def test_separation_generation_identity_fails_closed_without_runtime_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Missing installed-runtime metadata disables cache equivalence instead of guessing it.""" + + def missing_version(package_name: str) -> str: + raise generation.importlib_metadata.PackageNotFoundError(package_name) + + monkeypatch.setattr(generation.importlib_metadata, "version", missing_version) + + assert generation.separation_generation_identity() is None 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