diff --git a/.github/workflows/project-persistence-macos.yml b/.github/workflows/project-persistence-macos.yml new file mode 100644 index 000000000..76428f431 --- /dev/null +++ b/.github/workflows/project-persistence-macos.yml @@ -0,0 +1,125 @@ +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/content_sha256.rs" + - "apps/desktop/core/src/project_format.rs" + - "apps/desktop/core/src/project_scoped_score_recovery.rs" + - "apps/desktop/core/src/score_attachment_recovery.rs" + - "apps/desktop/core/tests/content_sha256_shared_kernel.rs" + - "apps/desktop/core/tests/project_persistence*.rs" + - "apps/desktop/core/tests/project_format*.rs" + - "apps/desktop/core/tests/project_migration*.rs" + - "apps/desktop/core/testdata/project-*.json" + - "apps/desktop/src/App.tsx" + - "apps/desktop/src/App.project-save-source-authority.test.tsx" + - "apps/desktop/src/App.score-project-identity.test.tsx" + - "apps/desktop/src/features/workspace/SectionRoadmap.tsx" + - "apps/desktop/src/lib/analysis.ts" + - "apps/desktop/src/lib/analysis.workspace-single-flight.test.ts" + - "apps/desktop/src/lib/projectDocumentBridge.test.ts" + - "apps/desktop/src/lib/projectDocumentSaveAuthority.test.ts" + - "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" + - "docs/traceability/project-persistence-score-publication-identity.md" + - "docs/traceability/project-persistence-score-recovery-project-scope.md" + - "docs/traceability/project-persistence-score-recovery-reconciliation.md" + - "docs/traceability/project-persistence-shared-song-contract.md" + - "docs/traceability/project-persistence-workspace-mutation-admission.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/content_sha256.rs" + - "apps/desktop/core/src/project_format.rs" + - "apps/desktop/core/src/project_scoped_score_recovery.rs" + - "apps/desktop/core/src/score_attachment_recovery.rs" + - "apps/desktop/core/tests/content_sha256_shared_kernel.rs" + - "apps/desktop/core/tests/project_persistence*.rs" + - "apps/desktop/core/tests/project_format*.rs" + - "apps/desktop/core/tests/project_migration*.rs" + - "apps/desktop/core/testdata/project-*.json" + - "apps/desktop/src/App.tsx" + - "apps/desktop/src/App.project-save-source-authority.test.tsx" + - "apps/desktop/src/App.score-project-identity.test.tsx" + - "apps/desktop/src/features/workspace/SectionRoadmap.tsx" + - "apps/desktop/src/lib/analysis.ts" + - "apps/desktop/src/lib/analysis.workspace-single-flight.test.ts" + - "apps/desktop/src/lib/projectDocumentBridge.test.ts" + - "apps/desktop/src/lib/projectDocumentSaveAuthority.test.ts" + - "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" + - "docs/traceability/project-persistence-score-publication-identity.md" + - "docs/traceability/project-persistence-score-recovery-project-scope.md" + - "docs/traceability/project-persistence-score-recovery-reconciliation.md" + - "docs/traceability/project-persistence-shared-song-contract.md" + - "docs/traceability/project-persistence-workspace-mutation-admission.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: Run Project Persistence recovery module tests + run: 'cargo +1.97.1 test --manifest-path apps/desktop/core/Cargo.toml --features persistence_warning_gate --lib score_attachment_recovery::tests::' + - name: Run Project Persistence content-identity module tests + run: 'cargo +1.97.1 test --manifest-path apps/desktop/core/Cargo.toml --features persistence_warning_gate --lib content_sha256::tests::' + - name: Run Project Persistence core integration contracts + run: 'cargo +1.97.1 test --manifest-path apps/desktop/core/Cargo.toml --features persistence_warning_gate --test content_sha256_shared_kernel --test project_format_resource_admission_handoff --test project_format_v2_fixture --test project_format_v2_playback_preference --test project_format_v3_renderer_source_authority --test project_format_v3_source_reference --test project_migration_receipt_input_binding --test project_persistence_contract --test project_persistence_score_recovery' + - 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..da6859768 --- /dev/null +++ b/.github/workflows/project-persistence-windows-native.yml @@ -0,0 +1,126 @@ +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/content_sha256.rs" + - "apps/desktop/core/src/project_format.rs" + - "apps/desktop/core/src/project_scoped_score_recovery.rs" + - "apps/desktop/core/src/score_attachment_recovery.rs" + - "apps/desktop/core/tests/content_sha256_shared_kernel.rs" + - "apps/desktop/core/tests/project_persistence*.rs" + - "apps/desktop/core/tests/project_format*.rs" + - "apps/desktop/core/tests/project_migration*.rs" + - "apps/desktop/core/testdata/project-*.json" + - "apps/desktop/src/App.tsx" + - "apps/desktop/src/App.project-save-source-authority.test.tsx" + - "apps/desktop/src/App.score-project-identity.test.tsx" + - "apps/desktop/src/features/workspace/SectionRoadmap.tsx" + - "apps/desktop/src/lib/analysis.ts" + - "apps/desktop/src/lib/analysis.workspace-single-flight.test.ts" + - "apps/desktop/src/lib/projectDocumentBridge.test.ts" + - "apps/desktop/src/lib/projectDocumentSaveAuthority.test.ts" + - "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" + - "docs/traceability/project-persistence-score-publication-identity.md" + - "docs/traceability/project-persistence-score-recovery-project-scope.md" + - "docs/traceability/project-persistence-score-recovery-reconciliation.md" + - "docs/traceability/project-persistence-shared-song-contract.md" + - "docs/traceability/project-persistence-workspace-mutation-admission.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/content_sha256.rs" + - "apps/desktop/core/src/project_format.rs" + - "apps/desktop/core/src/project_scoped_score_recovery.rs" + - "apps/desktop/core/src/score_attachment_recovery.rs" + - "apps/desktop/core/tests/content_sha256_shared_kernel.rs" + - "apps/desktop/core/tests/project_persistence*.rs" + - "apps/desktop/core/tests/project_format*.rs" + - "apps/desktop/core/tests/project_migration*.rs" + - "apps/desktop/core/testdata/project-*.json" + - "apps/desktop/src/App.tsx" + - "apps/desktop/src/App.project-save-source-authority.test.tsx" + - "apps/desktop/src/App.score-project-identity.test.tsx" + - "apps/desktop/src/features/workspace/SectionRoadmap.tsx" + - "apps/desktop/src/lib/analysis.ts" + - "apps/desktop/src/lib/analysis.workspace-single-flight.test.ts" + - "apps/desktop/src/lib/projectDocumentBridge.test.ts" + - "apps/desktop/src/lib/projectDocumentSaveAuthority.test.ts" + - "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" + - "docs/traceability/project-persistence-score-publication-identity.md" + - "docs/traceability/project-persistence-score-recovery-project-scope.md" + - "docs/traceability/project-persistence-score-recovery-reconciliation.md" + - "docs/traceability/project-persistence-shared-song-contract.md" + - "docs/traceability/project-persistence-workspace-mutation-admission.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: Run Project Persistence recovery module tests + run: 'cargo +1.97.1 test --manifest-path apps/desktop/core/Cargo.toml --features persistence_warning_gate --lib score_attachment_recovery::tests::' + - name: Run Project Persistence content-identity module tests + run: 'cargo +1.97.1 test --manifest-path apps/desktop/core/Cargo.toml --features persistence_warning_gate --lib content_sha256::tests::' + - name: Run Project Persistence core integration contracts + run: 'cargo +1.97.1 test --manifest-path apps/desktop/core/Cargo.toml --features persistence_warning_gate --test content_sha256_shared_kernel --test project_format_resource_admission_handoff --test project_format_v2_fixture --test project_format_v2_playback_preference --test project_format_v3_renderer_source_authority --test project_format_v3_source_reference --test project_migration_receipt_input_binding --test project_persistence_contract --test project_persistence_score_recovery' + - 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/project_scoped_score_recovery.rs b/apps/desktop/core/src/project_scoped_score_recovery.rs new file mode 100644 index 000000000..6555cbb72 --- /dev/null +++ b/apps/desktop/core/src/project_scoped_score_recovery.rs @@ -0,0 +1,155 @@ +//! Project identity and revision binding for score recovery intent. +//! +//! Recovery reconciliation is path-free and score-id based, but a buyer decision is made while one +//! durable project aggregate revision is active. The same score candidate can remain visible after the +//! application switches projects or after another writer updates the same project, so score-set +//! revalidation alone is insufficient mutation authority. This module binds the otherwise valid recovery +//! action to both the app-owned project identity and durable content revision that authorized it. + +use crate::{ + runtime_core::is_valid_project_id, + score_attachment_recovery::{ + authorize_unreferenced_score_recovery_action, + recovery_attachment_metadata_for_action, + revalidate_unreferenced_score_recovery_action, + AuthorizedUnreferencedScoreRecoveryAction, + RecoveredScoreAttachmentMetadata, + ScoreAttachmentRecoveryReconciliation, + UnreferencedScoreRecoveryDecision, + }, +}; + +const SCORE_RECOVERY_ACTION_ERROR: &str = "Could not authorize score attachment recovery action."; +const SCORE_RECOVERED_METADATA_ERROR: &str = + "Could not prepare recovered score attachment metadata."; + +fn is_valid_project_revision(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +/// Session-local score recovery intent bound to one durable app-owned project revision. +/// +/// This type is deliberately opaque. A score recovery decision that was valid for Project A must never +/// become mutation authority for Project B, and a decision from revision R1 must never survive an R2 +/// update of the same project merely because both states classify the same score id as unreferenced and +/// published. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectScopedScoreRecoveryAction { + project_id: String, + project_revision: String, + action: AuthorizedUnreferencedScoreRecoveryAction, +} + +impl ProjectScopedScoreRecoveryAction { + /// Return the validated app-owned project identity that authorized this decision. + pub fn project_id(&self) -> &str { + &self.project_id + } + + /// Return the canonical lowercase SHA-256 project revision that authorized this decision. + pub fn project_revision(&self) -> &str { + &self.project_revision + } + + /// Return the validated score identity covered by this decision. + pub fn score_id(&self) -> &str { + self.action.score_id() + } + + /// Return the explicit buyer-selected disposition. + pub fn decision(&self) -> UnreferencedScoreRecoveryDecision { + self.action.decision() + } +} + +/// Authorize one explicit score-recovery disposition for the currently active durable project revision. +/// +/// The project identity is validated with the same canonical `project--` contract used +/// by Project Persistence. The project revision must be the canonical lowercase SHA-256 token returned by +/// the durable workspace publication/load boundary. Reconciliation and score-id checks remain owned by +/// the existing recovery domain; this function adds aggregate identity and revision freshness without +/// acquiring Score Storage filesystem authority. +/// +/// # Errors +/// +/// Returns a bounded generic error when the project id or revision is malformed or the underlying score +/// recovery reconciliation cannot authorize the requested candidate and decision. +pub fn authorize_project_scoped_score_recovery_action( + project_id: &str, + project_revision: &str, + reconciliation: &ScoreAttachmentRecoveryReconciliation, + score_id: &str, + decision: UnreferencedScoreRecoveryDecision, +) -> Result { + if !is_valid_project_id(project_id) || !is_valid_project_revision(project_revision) { + return Err(SCORE_RECOVERY_ACTION_ERROR.to_string()); + } + + let action = authorize_unreferenced_score_recovery_action(reconciliation, score_id, decision) + .map_err(|_| SCORE_RECOVERY_ACTION_ERROR.to_string())?; + Ok(ProjectScopedScoreRecoveryAction { + project_id: project_id.to_string(), + project_revision: project_revision.to_string(), + action, + }) +} + +/// Revalidate a project-scoped decision against fresh aggregate identity, revision, and recovery evidence. +/// +/// The caller must supply the project identity and durable revision reread at the mutation boundary. +/// Switching projects or changing the durable revision invalidates the previous decision even when the +/// current score candidate sets are byte-for-byte equal. The score lifecycle evidence is then +/// revalidated by the existing recovery domain. +/// +/// # Errors +/// +/// Returns a bounded generic error when the active project or revision changed, either current token is +/// malformed, or the underlying score candidate no longer has unreferenced-published status. +pub fn revalidate_project_scoped_score_recovery_action( + current_project_id: &str, + current_project_revision: &str, + current_reconciliation: &ScoreAttachmentRecoveryReconciliation, + action: &ProjectScopedScoreRecoveryAction, +) -> Result<(), String> { + if !is_valid_project_id(current_project_id) + || !is_valid_project_revision(current_project_revision) + || action.project_id != current_project_id + || action.project_revision != current_project_revision + { + return Err(SCORE_RECOVERY_ACTION_ERROR.to_string()); + } + + revalidate_unreferenced_score_recovery_action(current_reconciliation, &action.action) + .map_err(|_| SCORE_RECOVERY_ACTION_ERROR.to_string()) +} + +/// Build recovered attachment metadata only for a still-current revision-bound Recover decision. +/// +/// Project identity, durable revision, and fresh recovery evidence are checked before metadata can be +/// emitted. The underlying recovery domain still owns the truthful generated filename and rejects +/// Preserve/Discard decisions. This function performs no project or filesystem mutation. +/// +/// # Errors +/// +/// Returns a bounded generic error when the project or revision changed, recovery evidence changed, or +/// the action is not an explicit Recover decision. +pub fn recovery_attachment_metadata_for_project_action( + current_project_id: &str, + current_project_revision: &str, + current_reconciliation: &ScoreAttachmentRecoveryReconciliation, + action: &ProjectScopedScoreRecoveryAction, +) -> Result { + revalidate_project_scoped_score_recovery_action( + current_project_id, + current_project_revision, + current_reconciliation, + action, + ) + .map_err(|_| SCORE_RECOVERED_METADATA_ERROR.to_string())?; + + recovery_attachment_metadata_for_action(current_reconciliation, &action.action) + .map_err(|_| SCORE_RECOVERED_METADATA_ERROR.to_string()) +} 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..3f313348b --- /dev/null +++ b/apps/desktop/core/src/root.rs @@ -0,0 +1,57 @@ +#![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 project_scoped_score_recovery; +mod publication_identity; +mod score_attachment_recovery; +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 project_scoped_score_recovery::{ + authorize_project_scoped_score_recovery_action, + recovery_attachment_metadata_for_project_action, + revalidate_project_scoped_score_recovery_action, + ProjectScopedScoreRecoveryAction, +}; +pub use publication_identity::{ + build_local_audio_publication_identity, LocalAudioPublicationIdentity, +}; +pub use runtime_core::*; +pub use score_attachment_recovery::{ + derive_score_attachment_recovery_candidates, RecoveredScoreAttachmentMetadata, + ScoreAttachmentRecoveryReconciliation, UnreferencedScoreRecoveryDecision, +}; +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_attachment_recovery.rs b/apps/desktop/core/src/score_attachment_recovery.rs new file mode 100644 index 000000000..2afc2f794 --- /dev/null +++ b/apps/desktop/core/src/score_attachment_recovery.rs @@ -0,0 +1,377 @@ +//! Project-side reconciliation for durable score attachment metadata and Score Storage inventory. +//! +//! Score Storage owns whether a score object exists. Project Persistence owns whether a score id is +//! durably referenced by the project. This module compares those two path-free identity sets without +//! inferring attach/delete intent, touching the filesystem, or copying Score Storage validation. + +use std::collections::BTreeSet; + +use crate::is_valid_score_id; + +const SCORE_RECONCILIATION_ERROR: &str = "Could not reconcile score attachments."; +const SCORE_RECOVERY_ACTION_ERROR: &str = "Could not authorize score attachment recovery action."; +const SCORE_RECOVERED_METADATA_ERROR: &str = + "Could not prepare recovered score attachment metadata."; + +/// Path-free reconciliation result between durable project references and published score objects. +/// +/// The three sets are mutually exclusive and deterministically sorted. In particular, +/// `unreferenced_published_score_ids` is only a recovery *candidate* set: an id can represent either +/// an interrupted attach whose bytes should be recoverable or a completed metadata detach whose byte +/// cleanup failed. The caller must not auto-attach or auto-delete from this result alone. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ScoreAttachmentRecoveryReconciliation { + /// Score ids present in both durable project metadata and Score Storage inventory. + pub referenced_and_published_score_ids: Vec, + /// Score ids present in Score Storage but absent from durable project metadata. + pub unreferenced_published_score_ids: Vec, + /// Score ids referenced by the durable project but absent from Score Storage inventory. + pub missing_referenced_score_ids: Vec, +} + +/// Buyer-selected disposition for a published score object that has no durable project reference. +/// +/// `Preserve` deliberately performs no storage mutation. `Recover` expresses explicit intent to +/// reattach the current published object using truthful generated presentation metadata. `Discard` +/// expresses explicit cleanup intent. This domain type performs none of those mutations itself. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UnreferencedScoreRecoveryDecision { + /// Leave the published object intact for later inspection or recovery. + Preserve, + /// Permit a later Project Persistence transaction to reattach this current published object. + Recover, + /// Permit cleanup of this one unreferenced published object. + Discard, +} + +/// Opaque authorization proving that one explicit buyer decision targeted an unreferenced object. +/// +/// Fields are private so callers cannot manufacture destructive cleanup or reattachment authority without +/// passing the reconciliation checks in [`authorize_unreferenced_score_recovery_action`]. Authorization +/// is intentionally not a durable capability: every mutation boundary must revalidate the action against +/// fresh reconciliation evidence with [`revalidate_unreferenced_score_recovery_action`]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AuthorizedUnreferencedScoreRecoveryAction { + score_id: String, + decision: UnreferencedScoreRecoveryDecision, +} + +impl AuthorizedUnreferencedScoreRecoveryAction { + /// Return the validated score identity covered by this authorization. + pub fn score_id(&self) -> &str { + &self.score_id + } + + /// Return the explicit buyer-selected disposition. + pub fn decision(&self) -> UnreferencedScoreRecoveryDecision { + self.decision + } +} + +/// Path-free attachment metadata for a buyer-authorized recovery. +/// +/// The generated file name is intentionally not the original selected filename. That filename is absent +/// from the restart inventory contract after `PDF durable -> project metadata not durable`. Persisting a +/// generated recovery label avoids inventing provenance while giving Project Persistence a stable display +/// value that can survive the next project save. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RecoveredScoreAttachmentMetadata { + score_id: String, + file_name: String, +} + +impl RecoveredScoreAttachmentMetadata { + /// Return the validated score identity to reattach. + pub fn score_id(&self) -> &str { + &self.score_id + } + + /// Return the deterministic generated display filename. + pub fn file_name(&self) -> &str { + &self.file_name + } +} + +fn validated_identity_set(score_ids: &[String]) -> Result, String> { + let mut identities = BTreeSet::new(); + for score_id in score_ids { + if !is_valid_score_id(score_id) || !identities.insert(score_id.clone()) { + return Err(SCORE_RECONCILIATION_ERROR.to_string()); + } + } + Ok(identities) +} + +fn validated_reconciliation_sets( + reconciliation: &ScoreAttachmentRecoveryReconciliation, +) -> Result<(BTreeSet, BTreeSet, BTreeSet), String> { + let referenced_and_published = + validated_identity_set(&reconciliation.referenced_and_published_score_ids) + .map_err(|_| SCORE_RECOVERY_ACTION_ERROR.to_string())?; + let unreferenced_published = + validated_identity_set(&reconciliation.unreferenced_published_score_ids) + .map_err(|_| SCORE_RECOVERY_ACTION_ERROR.to_string())?; + let missing_referenced = validated_identity_set(&reconciliation.missing_referenced_score_ids) + .map_err(|_| SCORE_RECOVERY_ACTION_ERROR.to_string())?; + + if referenced_and_published + .intersection(&unreferenced_published) + .next() + .is_some() + || referenced_and_published + .intersection(&missing_referenced) + .next() + .is_some() + || unreferenced_published + .intersection(&missing_referenced) + .next() + .is_some() + { + return Err(SCORE_RECOVERY_ACTION_ERROR.to_string()); + } + + Ok(( + referenced_and_published, + unreferenced_published, + missing_referenced, + )) +} + +/// Compare durable Project Persistence attachment ids with Score Storage's validated object inventory. +/// +/// Both inputs must contain unique canonical BandScope score ids. Invalid or duplicate identities fail +/// closed because either condition makes lifecycle evidence ambiguous. No filesystem path or PDF content +/// crosses this boundary, and the function deliberately returns classification only: it never chooses +/// recover, keep, discard, attach, or delete on the buyer's behalf. +/// +/// # Errors +/// +/// Returns a bounded generic error when either input contains a malformed or duplicate score id. +pub fn derive_score_attachment_recovery_candidates( + durable_project_score_ids: &[String], + published_score_ids: &[String], +) -> Result { + let durable = validated_identity_set(durable_project_score_ids)?; + let published = validated_identity_set(published_score_ids)?; + + Ok(ScoreAttachmentRecoveryReconciliation { + referenced_and_published_score_ids: durable + .intersection(&published) + .cloned() + .collect(), + unreferenced_published_score_ids: published + .difference(&durable) + .cloned() + .collect(), + missing_referenced_score_ids: durable + .difference(&published) + .cloned() + .collect(), + }) +} + +/// Authorize one explicit disposition for an unreferenced published score object. +/// +/// The supplied reconciliation may originate outside this module because its classification fields are +/// public for IPC/application consumption. This function therefore revalidates every identity and the +/// mutual-exclusion invariant before issuing authorization. Referenced-and-published, missing-reference, +/// malformed, unknown, duplicated, or cross-set-overlapping identities can never become cleanup or +/// reattachment authority. +/// +/// `Preserve`, `Recover`, and `Discard` are all explicit buyer decisions. No action mutates storage here. +/// The returned action is session-local intent, not a durable capability; the current owner evidence must +/// be checked again immediately before metadata or byte mutation. +/// +/// # Errors +/// +/// Returns a bounded generic error if the reconciliation is inconsistent or `score_id` is not exactly one +/// current `unreferenced_published_score_ids` candidate. +pub fn authorize_unreferenced_score_recovery_action( + reconciliation: &ScoreAttachmentRecoveryReconciliation, + score_id: &str, + decision: UnreferencedScoreRecoveryDecision, +) -> Result { + if !is_valid_score_id(score_id) { + return Err(SCORE_RECOVERY_ACTION_ERROR.to_string()); + } + + let (_, unreferenced_published, _) = validated_reconciliation_sets(reconciliation)?; + if !unreferenced_published.contains(score_id) { + return Err(SCORE_RECOVERY_ACTION_ERROR.to_string()); + } + + Ok(AuthorizedUnreferencedScoreRecoveryAction { + score_id: score_id.to_string(), + decision, + }) +} + +/// Revalidate an authorized decision against fresh Project Persistence and Score Storage evidence. +/// +/// Recovery dialogs can remain open while another save, detach, recovery, or cleanup changes lifecycle +/// state. An authorization from an older reconciliation must therefore never cross a later mutation +/// boundary by itself. The score id must still be exactly one current unreferenced-published candidate; +/// if it became referenced, disappeared, moved into the missing-reference set, or the reconciliation is +/// otherwise inconsistent, the action fails closed and the application must refresh recovery state. +/// +/// # Errors +/// +/// Returns a bounded generic error when the current reconciliation no longer authorizes this score id. +pub fn revalidate_unreferenced_score_recovery_action( + current_reconciliation: &ScoreAttachmentRecoveryReconciliation, + action: &AuthorizedUnreferencedScoreRecoveryAction, +) -> Result<(), String> { + if !is_valid_score_id(&action.score_id) { + return Err(SCORE_RECOVERY_ACTION_ERROR.to_string()); + } + + let (_, unreferenced_published, _) = validated_reconciliation_sets(current_reconciliation)?; + if !unreferenced_published.contains(&action.score_id) { + return Err(SCORE_RECOVERY_ACTION_ERROR.to_string()); + } + + Ok(()) +} + +/// Build truthful durable presentation metadata for one currently valid authorized recovery action. +/// +/// The restart inventory carries no original selected filename. A `Recover` action therefore uses a +/// deterministic generated label, `recovered-score-.pdf`, rather than claiming the original +/// filename was restored. `Preserve` and `Discard` actions cannot be converted into attachment metadata. +/// The action is revalidated against fresh reconciliation evidence before metadata is emitted so a dialog +/// decision cannot outlive a concurrent attach/detach/recovery state change. This function performs no +/// filesystem or project mutation; the application must still persist the returned metadata through +/// Project Persistence before presenting the attachment as accepted. +/// +/// # Errors +/// +/// Returns a bounded generic error when the authorized action is not an explicit `Recover` decision or is +/// no longer valid under the supplied current reconciliation. +pub fn recovery_attachment_metadata_for_action( + current_reconciliation: &ScoreAttachmentRecoveryReconciliation, + action: &AuthorizedUnreferencedScoreRecoveryAction, +) -> Result { + if action.decision != UnreferencedScoreRecoveryDecision::Recover { + return Err(SCORE_RECOVERED_METADATA_ERROR.to_string()); + } + revalidate_unreferenced_score_recovery_action(current_reconciliation, action) + .map_err(|_| SCORE_RECOVERED_METADATA_ERROR.to_string())?; + + Ok(RecoveredScoreAttachmentMetadata { + score_id: action.score_id.clone(), + file_name: format!("recovered-score-{}.pdf", action.score_id), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SCORE_ID: &str = "6fa459ea-ee8a-4ca4-894e-db77e160355e"; + + #[test] + fn empty_sets_reconcile_without_candidates() { + let result = derive_score_attachment_recovery_candidates(&[], &[]) + .expect("empty owner sets should be a valid steady state"); + assert!(result.referenced_and_published_score_ids.is_empty()); + assert!(result.unreferenced_published_score_ids.is_empty()); + assert!(result.missing_referenced_score_ids.is_empty()); + } + + #[test] + fn duplicate_published_identity_fails_closed() { + let result = derive_score_attachment_recovery_candidates( + &[SCORE_ID.to_string()], + &[SCORE_ID.to_string(), SCORE_ID.to_string()], + ); + assert_eq!(result.err().as_deref(), Some(SCORE_RECONCILIATION_ERROR)); + } + + #[test] + fn authorization_revalidates_public_reconciliation_state() { + let forged = ScoreAttachmentRecoveryReconciliation { + referenced_and_published_score_ids: vec![SCORE_ID.to_string()], + unreferenced_published_score_ids: vec![SCORE_ID.to_string()], + missing_referenced_score_ids: Vec::new(), + }; + + assert_eq!( + authorize_unreferenced_score_recovery_action( + &forged, + SCORE_ID, + UnreferencedScoreRecoveryDecision::Discard, + ) + .err() + .as_deref(), + Some(SCORE_RECOVERY_ACTION_ERROR) + ); + } + + #[test] + fn generated_recovery_metadata_requires_recover_authorization() { + let reconciliation = ScoreAttachmentRecoveryReconciliation { + referenced_and_published_score_ids: Vec::new(), + unreferenced_published_score_ids: vec![SCORE_ID.to_string()], + missing_referenced_score_ids: Vec::new(), + }; + let recover = authorize_unreferenced_score_recovery_action( + &reconciliation, + SCORE_ID, + UnreferencedScoreRecoveryDecision::Recover, + ) + .expect("recovery candidate should authorize explicit recovery"); + let metadata = recovery_attachment_metadata_for_action(&reconciliation, &recover) + .expect("authorized recovery should produce generated metadata"); + assert_eq!(metadata.score_id(), SCORE_ID); + assert_eq!( + metadata.file_name(), + "recovered-score-6fa459ea-ee8a-4ca4-894e-db77e160355e.pdf" + ); + + let preserve = authorize_unreferenced_score_recovery_action( + &reconciliation, + SCORE_ID, + UnreferencedScoreRecoveryDecision::Preserve, + ) + .expect("candidate should authorize preserve"); + assert_eq!( + recovery_attachment_metadata_for_action(&reconciliation, &preserve) + .err() + .as_deref(), + Some(SCORE_RECOVERED_METADATA_ERROR) + ); + } + + #[test] + fn stale_authorization_fails_current_revalidation() { + let initial = ScoreAttachmentRecoveryReconciliation { + referenced_and_published_score_ids: Vec::new(), + unreferenced_published_score_ids: vec![SCORE_ID.to_string()], + missing_referenced_score_ids: Vec::new(), + }; + let action = authorize_unreferenced_score_recovery_action( + &initial, + SCORE_ID, + UnreferencedScoreRecoveryDecision::Recover, + ) + .expect("initial candidate should authorize recovery"); + let current = ScoreAttachmentRecoveryReconciliation { + referenced_and_published_score_ids: vec![SCORE_ID.to_string()], + unreferenced_published_score_ids: Vec::new(), + missing_referenced_score_ids: Vec::new(), + }; + + assert_eq!( + revalidate_unreferenced_score_recovery_action(¤t, &action) + .err() + .as_deref(), + Some(SCORE_RECOVERY_ACTION_ERROR) + ); + assert_eq!( + recovery_attachment_metadata_for_action(¤t, &action) + .err() + .as_deref(), + Some(SCORE_RECOVERED_METADATA_ERROR) + ); + } +} 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..8c1f92ae9 --- /dev/null +++ b/apps/desktop/core/tests/content_sha256_shared_kernel.rs @@ -0,0 +1,12 @@ +use bandscope_desktop_core::sha256_hex_reader; +use std::io::Cursor; + +#[test] +fn shared_sha256_reader_matches_the_fips_180_4_abc_vector() { + let digest = sha256_hex_reader(Cursor::new(b"abc")) + .expect("in-memory FIPS 180-4 fixture should be readable"); + assert_eq!( + digest, + "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/project_persistence_score_recovery.rs b/apps/desktop/core/tests/project_persistence_score_recovery.rs new file mode 100644 index 000000000..c335c3488 --- /dev/null +++ b/apps/desktop/core/tests/project_persistence_score_recovery.rs @@ -0,0 +1,433 @@ +use bandscope_desktop_core::{ + authorize_project_scoped_score_recovery_action, + derive_score_attachment_recovery_candidates, + recovery_attachment_metadata_for_project_action, + revalidate_project_scoped_score_recovery_action, + ScoreAttachmentRecoveryReconciliation, + UnreferencedScoreRecoveryDecision, +}; + +const PROJECT_ID: &str = "project-1700000000000000000-1"; +const OTHER_PROJECT_ID: &str = "project-1700000000000000001-2"; +const PROJECT_REVISION: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const OTHER_PROJECT_REVISION: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const REFERENCED_ID: &str = "6fa459ea-ee8a-4ca4-894e-db77e160355e"; +const PUBLISHED_ONLY_ID: &str = "3f2c8f0e-1a2b-4c3d-8e9f-001122334455"; +const MISSING_ID: &str = "7a0f4b3d-f83d-4b5c-8b6e-2e2f1cf2a901"; + +#[test] +fn reconciliation_distinguishes_unreferenced_bytes_from_missing_references() { + let reconciliation = derive_score_attachment_recovery_candidates( + &[REFERENCED_ID.to_string(), MISSING_ID.to_string()], + &[PUBLISHED_ONLY_ID.to_string(), REFERENCED_ID.to_string()], + ) + .expect("valid owner identities should reconcile"); + + assert_eq!( + reconciliation.referenced_and_published_score_ids, + vec![REFERENCED_ID.to_string()] + ); + assert_eq!( + reconciliation.unreferenced_published_score_ids, + vec![PUBLISHED_ONLY_ID.to_string()] + ); + assert_eq!( + reconciliation.missing_referenced_score_ids, + vec![MISSING_ID.to_string()] + ); +} + +#[test] +fn reconciliation_is_deterministic_and_does_not_infer_recovery_intent() { + let second = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + let first = "11111111-1111-4111-8111-111111111111"; + + let reconciliation = derive_score_attachment_recovery_candidates( + &[], + &[second.to_string(), first.to_string()], + ) + .expect("valid inventory should reconcile"); + + assert!(reconciliation.referenced_and_published_score_ids.is_empty()); + assert!(reconciliation.missing_referenced_score_ids.is_empty()); + assert_eq!( + reconciliation.unreferenced_published_score_ids, + vec![first.to_string(), second.to_string()] + ); +} + +#[test] +fn reconciliation_rejects_duplicate_or_malformed_owner_identities() { + let duplicate = derive_score_attachment_recovery_candidates( + &[REFERENCED_ID.to_string(), REFERENCED_ID.to_string()], + &[REFERENCED_ID.to_string()], + ); + assert_eq!( + duplicate.err().as_deref(), + Some("Could not reconcile score attachments.") + ); + + let malformed = derive_score_attachment_recovery_candidates( + &["../escape".to_string()], + &[REFERENCED_ID.to_string()], + ); + assert_eq!( + malformed.err().as_deref(), + Some("Could not reconcile score attachments.") + ); +} + +#[test] +fn unreferenced_published_score_requires_an_explicit_preserve_recover_or_discard_decision() { + let reconciliation = derive_score_attachment_recovery_candidates( + &[REFERENCED_ID.to_string(), MISSING_ID.to_string()], + &[PUBLISHED_ONLY_ID.to_string(), REFERENCED_ID.to_string()], + ) + .expect("valid owner identities should reconcile"); + + let preserve = authorize_project_scoped_score_recovery_action( + PROJECT_ID, + PROJECT_REVISION, + &reconciliation, + PUBLISHED_ONLY_ID, + UnreferencedScoreRecoveryDecision::Preserve, + ) + .expect("buyer may explicitly preserve an unreferenced published object"); + assert_eq!(preserve.project_id(), PROJECT_ID); + assert_eq!(preserve.project_revision(), PROJECT_REVISION); + assert_eq!(preserve.score_id(), PUBLISHED_ONLY_ID); + assert_eq!( + preserve.decision(), + UnreferencedScoreRecoveryDecision::Preserve + ); + + let recover = authorize_project_scoped_score_recovery_action( + PROJECT_ID, + PROJECT_REVISION, + &reconciliation, + PUBLISHED_ONLY_ID, + UnreferencedScoreRecoveryDecision::Recover, + ) + .expect("buyer may explicitly recover an unreferenced published object"); + assert_eq!(recover.project_id(), PROJECT_ID); + assert_eq!(recover.project_revision(), PROJECT_REVISION); + assert_eq!(recover.score_id(), PUBLISHED_ONLY_ID); + assert_eq!( + recover.decision(), + UnreferencedScoreRecoveryDecision::Recover + ); + + let discard = authorize_project_scoped_score_recovery_action( + PROJECT_ID, + PROJECT_REVISION, + &reconciliation, + PUBLISHED_ONLY_ID, + UnreferencedScoreRecoveryDecision::Discard, + ) + .expect("buyer may explicitly discard an unreferenced published object"); + assert_eq!(discard.project_id(), PROJECT_ID); + assert_eq!(discard.project_revision(), PROJECT_REVISION); + assert_eq!(discard.score_id(), PUBLISHED_ONLY_ID); + assert_eq!( + discard.decision(), + UnreferencedScoreRecoveryDecision::Discard + ); +} + +#[test] +fn recover_action_produces_truthful_generated_metadata_without_claiming_original_filename() { + let reconciliation = derive_score_attachment_recovery_candidates( + &[], + &[PUBLISHED_ONLY_ID.to_string()], + ) + .expect("published-only score should be a recovery candidate"); + let recover = authorize_project_scoped_score_recovery_action( + PROJECT_ID, + PROJECT_REVISION, + &reconciliation, + PUBLISHED_ONLY_ID, + UnreferencedScoreRecoveryDecision::Recover, + ) + .expect("buyer recovery decision should be authorized"); + + let metadata = recovery_attachment_metadata_for_project_action( + PROJECT_ID, + PROJECT_REVISION, + &reconciliation, + &recover, + ) + .expect("authorized recovery should produce durable presentation metadata"); + + assert_eq!(metadata.score_id(), PUBLISHED_ONLY_ID); + assert_eq!( + metadata.file_name(), + "recovered-score-3f2c8f0e-1a2b-4c3d-8e9f-001122334455.pdf" + ); + assert!(!metadata.file_name().contains("opener")); +} + +#[test] +fn preserve_or_discard_actions_cannot_become_recovered_attachment_metadata() { + let reconciliation = derive_score_attachment_recovery_candidates( + &[], + &[PUBLISHED_ONLY_ID.to_string()], + ) + .expect("published-only score should be a recovery candidate"); + + for decision in [ + UnreferencedScoreRecoveryDecision::Preserve, + UnreferencedScoreRecoveryDecision::Discard, + ] { + let action = authorize_project_scoped_score_recovery_action( + PROJECT_ID, + PROJECT_REVISION, + &reconciliation, + PUBLISHED_ONLY_ID, + decision, + ) + .expect("non-recovery disposition should still be authorizable"); + assert_eq!( + recovery_attachment_metadata_for_project_action( + PROJECT_ID, + PROJECT_REVISION, + &reconciliation, + &action, + ) + .err() + .as_deref(), + Some("Could not prepare recovered score attachment metadata.") + ); + } +} + +#[test] +fn recovery_action_never_authorizes_referenced_missing_or_unknown_score_ids() { + let reconciliation = derive_score_attachment_recovery_candidates( + &[REFERENCED_ID.to_string(), MISSING_ID.to_string()], + &[PUBLISHED_ONLY_ID.to_string(), REFERENCED_ID.to_string()], + ) + .expect("valid owner identities should reconcile"); + + for score_id in [ + REFERENCED_ID, + MISSING_ID, + "11111111-1111-4111-8111-111111111111", + "../escape", + ] { + let result = authorize_project_scoped_score_recovery_action( + PROJECT_ID, + PROJECT_REVISION, + &reconciliation, + score_id, + UnreferencedScoreRecoveryDecision::Discard, + ); + assert_eq!( + result.err().as_deref(), + Some("Could not authorize score attachment recovery action."), + "{score_id} must not become cleanup authority" + ); + } +} + +#[test] +fn malformed_project_identity_or_revision_cannot_authorize_recovery() { + let reconciliation = derive_score_attachment_recovery_candidates( + &[], + &[PUBLISHED_ONLY_ID.to_string()], + ) + .expect("published-only score should be a recovery candidate"); + + let malformed_project = authorize_project_scoped_score_recovery_action( + "../project-escape", + PROJECT_REVISION, + &reconciliation, + PUBLISHED_ONLY_ID, + UnreferencedScoreRecoveryDecision::Recover, + ); + assert_eq!( + malformed_project.err().as_deref(), + Some("Could not authorize score attachment recovery action.") + ); + + for malformed_revision in [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg", + ] { + let result = authorize_project_scoped_score_recovery_action( + PROJECT_ID, + malformed_revision, + &reconciliation, + PUBLISHED_ONLY_ID, + UnreferencedScoreRecoveryDecision::Recover, + ); + assert_eq!( + result.err().as_deref(), + Some("Could not authorize score attachment recovery action."), + "malformed durable project revision must not become recovery authority" + ); + } +} + +#[test] +fn forged_overlapping_reconciliation_cannot_authorize_destructive_cleanup() { + let forged = ScoreAttachmentRecoveryReconciliation { + referenced_and_published_score_ids: vec![PUBLISHED_ONLY_ID.to_string()], + unreferenced_published_score_ids: vec![PUBLISHED_ONLY_ID.to_string()], + missing_referenced_score_ids: Vec::new(), + }; + + let result = authorize_project_scoped_score_recovery_action( + PROJECT_ID, + PROJECT_REVISION, + &forged, + PUBLISHED_ONLY_ID, + UnreferencedScoreRecoveryDecision::Discard, + ); + assert_eq!( + result.err().as_deref(), + Some("Could not authorize score attachment recovery action.") + ); +} + +#[test] +fn stale_recovery_authorization_cannot_cross_a_changed_reconciliation() { + let initial = derive_score_attachment_recovery_candidates( + &[], + &[PUBLISHED_ONLY_ID.to_string()], + ) + .expect("published-only score should initially be recoverable"); + let recover = authorize_project_scoped_score_recovery_action( + PROJECT_ID, + PROJECT_REVISION, + &initial, + PUBLISHED_ONLY_ID, + UnreferencedScoreRecoveryDecision::Recover, + ) + .expect("initial recovery decision should be authorized"); + let discard = authorize_project_scoped_score_recovery_action( + PROJECT_ID, + PROJECT_REVISION, + &initial, + PUBLISHED_ONLY_ID, + UnreferencedScoreRecoveryDecision::Discard, + ) + .expect("initial discard decision should be authorized"); + + let current = derive_score_attachment_recovery_candidates( + &[PUBLISHED_ONLY_ID.to_string()], + &[PUBLISHED_ONLY_ID.to_string()], + ) + .expect("current owner evidence should show the score as referenced"); + + for action in [&recover, &discard] { + assert_eq!( + revalidate_project_scoped_score_recovery_action( + PROJECT_ID, + PROJECT_REVISION, + ¤t, + action, + ) + .err() + .as_deref(), + Some("Could not authorize score attachment recovery action."), + "a decision from an older reconciliation must not survive current owner evidence" + ); + } + assert_eq!( + recovery_attachment_metadata_for_project_action( + PROJECT_ID, + PROJECT_REVISION, + ¤t, + &recover, + ) + .err() + .as_deref(), + Some("Could not prepare recovered score attachment metadata."), + "stale recovery authority must not become durable metadata" + ); +} + +#[test] +fn recovery_authorization_cannot_cross_project_identity_even_when_candidate_sets_match() { + let reconciliation = derive_score_attachment_recovery_candidates( + &[], + &[PUBLISHED_ONLY_ID.to_string()], + ) + .expect("published-only score should be a recovery candidate"); + let recover = authorize_project_scoped_score_recovery_action( + PROJECT_ID, + PROJECT_REVISION, + &reconciliation, + PUBLISHED_ONLY_ID, + UnreferencedScoreRecoveryDecision::Recover, + ) + .expect("initial project should authorize recovery"); + + assert_eq!( + revalidate_project_scoped_score_recovery_action( + OTHER_PROJECT_ID, + PROJECT_REVISION, + &reconciliation, + &recover, + ) + .err() + .as_deref(), + Some("Could not authorize score attachment recovery action."), + "a recovery decision must not survive a project switch merely because score sets match" + ); + assert_eq!( + recovery_attachment_metadata_for_project_action( + OTHER_PROJECT_ID, + PROJECT_REVISION, + &reconciliation, + &recover, + ) + .err() + .as_deref(), + Some("Could not prepare recovered score attachment metadata."), + "cross-project recovery authority must not become durable metadata" + ); +} + +#[test] +fn recovery_authorization_cannot_cross_project_revision_even_when_project_and_candidate_sets_match() { + let reconciliation = derive_score_attachment_recovery_candidates( + &[], + &[PUBLISHED_ONLY_ID.to_string()], + ) + .expect("published-only score should be a recovery candidate"); + let recover = authorize_project_scoped_score_recovery_action( + PROJECT_ID, + PROJECT_REVISION, + &reconciliation, + PUBLISHED_ONLY_ID, + UnreferencedScoreRecoveryDecision::Recover, + ) + .expect("current durable revision should authorize recovery intent"); + + assert_eq!( + revalidate_project_scoped_score_recovery_action( + PROJECT_ID, + OTHER_PROJECT_REVISION, + &reconciliation, + &recover, + ) + .err() + .as_deref(), + Some("Could not authorize score attachment recovery action."), + "recovery intent from an older project revision must not survive same-project mutation" + ); + assert_eq!( + recovery_attachment_metadata_for_project_action( + PROJECT_ID, + OTHER_PROJECT_REVISION, + &reconciliation, + &recover, + ) + .err() + .as_deref(), + Some("Could not prepare recovered score attachment metadata."), + "stale-revision recovery authority must not become durable metadata" + ); +} 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..d2b26a9ba 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,10 +1,18 @@ #![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}; use std::{ - io::{BufRead, BufReader, Read, Write}, + io::{BufRead, BufReader, Cursor, Read, Write}, path::{Path, PathBuf}, process::{Command, Stdio}, sync::{atomic::Ordering, mpsc}, @@ -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 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) } -fn normalize_local_audio_source(path: &Path) -> Result { +/// Admit one OS-selected local audio file into a project-owned immutable source artifact. +/// +/// Security Notes: the external path is used only to canonicalize and open the +/// user-authorized source. Size is checked from that opened descriptor, bytes +/// are copied through the bounded Resource Admission helper into a private +/// same-project staging file. After the stage is synchronized, 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, @@ -738,37 +1033,79 @@ async fn import_youtube_url( Err("YouTube import failed with an unknown error.".to_string()) } +/// Persist either a user-selected export or the app-owned local project snapshot. +/// +/// Security Notes: workspace persistence never accepts a renderer path. It +/// requires a BandScope-minted project id, resolves the existing project root +/// through Project Persistence authority, and publishes the fixed +/// `project.bscope` child with recovery, expected-revision validation, and +/// replacement under one process-external write admission lease. The renderer +/// may submit only the path-free revision receipt returned by the prior accepted +/// workspace save. Manual Save keeps the OS-owned file picker and does not use +/// workspace revision authority. #[tauri::command] -fn save_project(payload: Value) -> Result<(), String> { - let parsed = serde_json::from_value::(payload) +fn save_project( + payload: Value, + project_id: Option, + workspace: Option, + expected_content_sha256: Option, + app: tauri::AppHandle, + publication_state: tauri::State<'_, LocalAudioPublicationIdentityState>, +) -> Result { + 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 content = project_content_for_document(&parsed)?; + let workspace = workspace.unwrap_or(false); + + let path = if workspace { + let project_id = project_id + .as_deref() + .ok_or_else(|| "Invalid project payload".to_string())?; + let project_root = app_owned_root(&app, "projects", project_id)?; + project_root.join("project.bscope") + } else { + if expected_content_sha256.is_some() { + return Err("Invalid project revision.".to_string()); + } + FileDialog::new() + .add_filter("BandScope Project", &["bscope", "json"]) + .save_file() + .ok_or_else(|| "User cancelled".to_string())? + }; - 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())?; + if workspace { + return project_persistence::publish_workspace_project_file_with_expected_content( + &path, + content.as_bytes(), + expected_content_sha256.as_deref(), + ); + } - Ok(()) + project_persistence::recover_project_publication(&path)?; + project_persistence::publish_new_project_file(&path, content.as_bytes())?; + bandscope_desktop_core::sha256_hex_reader(Cursor::new(content.as_bytes())) + .map_err(|_| "Could not publish the project safely.".to_string()) } #[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 +1116,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 +1162,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 +1176,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 +1206,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..fee815338 --- /dev/null +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -0,0 +1,612 @@ +#[path = "../src/project_persistence_engine.rs"] +mod engine; + +pub(crate) use engine::*; + +use std::{ + fs::{self, File}, + io::{Cursor, 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."; +const PROJECT_WRITE_BUSY_ERROR: &str = "Project update is already being saved."; +const PROJECT_REVISION_CONFLICT_ERROR: &str = "Project changed since it was opened."; +const PROJECT_REVISION_INVALID_ERROR: &str = "Invalid project revision."; + +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)] +struct ProjectWriteAdmission { + _directory: File, +} + +#[cfg(unix)] +fn acquire_project_write_admission(target: &Path) -> Result { + use std::os::fd::AsRawFd; + + const LOCK_EX: i32 = 2; + const LOCK_NB: i32 = 4; + + extern "C" { + fn flock(fd: i32, operation: i32) -> i32; + } + + 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 directory = File::open(parent).map_err(|_| FIRST_SAVE_PUBLISH_ERROR.to_string())?; + let result = unsafe { flock(directory.as_raw_fd(), LOCK_EX | LOCK_NB) }; + if result == 0 { + return Ok(ProjectWriteAdmission { + _directory: directory, + }); + } + + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::WouldBlock { + Err(PROJECT_WRITE_BUSY_ERROR.to_string()) + } else { + Err(FIRST_SAVE_PUBLISH_ERROR.to_string()) + } +} + +#[cfg(windows)] +#[link(name = "kernel32")] +extern "system" { + #[link_name = "CreateMutexW"] + fn create_mutex_w( + mutex_attributes: *mut std::ffi::c_void, + initial_owner: i32, + name: *const u16, + ) -> *mut std::ffi::c_void; + #[link_name = "WaitForSingleObject"] + fn wait_for_single_object(handle: *mut std::ffi::c_void, milliseconds: u32) -> u32; + #[link_name = "ReleaseMutex"] + fn release_mutex(handle: *mut std::ffi::c_void) -> i32; + #[link_name = "CloseHandle"] + fn close_handle(handle: *mut std::ffi::c_void) -> i32; +} + +#[cfg(windows)] +struct ProjectWriteAdmission { + handle: *mut std::ffi::c_void, +} + +#[cfg(windows)] +impl Drop for ProjectWriteAdmission { + fn drop(&mut self) { + unsafe { + let _ = release_mutex(self.handle); + let _ = close_handle(self.handle); + } + } +} + +#[cfg(windows)] +fn windows_project_write_admission_name(target: &Path) -> Result, String> { + use std::os::windows::ffi::OsStrExt; + + let parent = first_save_parent(target); + let Some(file_name) = target.file_name() else { + return Err(FIRST_SAVE_STAGE_ERROR.to_string()); + }; + if !first_save_parent_chain_is_safe(parent) { + return Err(FIRST_SAVE_STAGE_ERROR.to_string()); + } + let canonical_parent = + fs::canonicalize(parent).map_err(|_| FIRST_SAVE_PUBLISH_ERROR.to_string())?; + let canonical_target = canonical_parent.join(file_name); + + let mut hash = 0xcbf2_9ce4_8422_2325_u64; + for unit in canonical_target.as_os_str().encode_wide() { + for byte in unit.to_le_bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + } + + Ok(format!("Local\\BandScopeProjectWrite-{hash:016x}") + .encode_utf16() + .chain(std::iter::once(0)) + .collect()) +} + +#[cfg(windows)] +fn acquire_project_write_admission(target: &Path) -> Result { + const WAIT_OBJECT_0: u32 = 0x0000_0000; + const WAIT_ABANDONED: u32 = 0x0000_0080; + const WAIT_TIMEOUT: u32 = 0x0000_0102; + + let name = windows_project_write_admission_name(target)?; + let handle = unsafe { create_mutex_w(std::ptr::null_mut(), 0, name.as_ptr()) }; + if handle.is_null() { + return Err(FIRST_SAVE_PUBLISH_ERROR.to_string()); + } + + match unsafe { wait_for_single_object(handle, 0) } { + WAIT_OBJECT_0 | WAIT_ABANDONED => Ok(ProjectWriteAdmission { handle }), + WAIT_TIMEOUT => { + unsafe { + let _ = close_handle(handle); + } + Err(PROJECT_WRITE_BUSY_ERROR.to_string()) + } + _ => { + unsafe { + let _ = close_handle(handle); + } + Err(FIRST_SAVE_PUBLISH_ERROR.to_string()) + } + } +} + +#[cfg(not(any(unix, windows)))] +struct ProjectWriteAdmission; + +#[cfg(not(any(unix, windows)))] +fn acquire_project_write_admission(_target: &Path) -> Result { + Err(FIRST_SAVE_PUBLISH_ERROR.to_string()) +} + +#[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(()) +} + +fn project_revision_is_valid(revision: &str) -> bool { + revision.len() == 64 + && revision + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn current_project_content_sha256(target: &Path) -> Result, String> { + match fs::symlink_metadata(target) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err(FIRST_SAVE_PUBLISH_ERROR.to_string()), + Ok(_) => {} + } + + let file = engine::open_project_file(target) + .map_err(|_| FIRST_SAVE_PUBLISH_ERROR.to_string())?; + let metadata = file + .metadata() + .map_err(|_| FIRST_SAVE_PUBLISH_ERROR.to_string())?; + if !metadata.is_file() || metadata.len() > FIRST_SAVE_MAX_PROJECT_FILE_BYTES as u64 { + return Err(FIRST_SAVE_PUBLISH_ERROR.to_string()); + } + let identity = first_save_identity_from_file(&file) + .map_err(|_| FIRST_SAVE_PUBLISH_ERROR.to_string())?; + if !first_save_path_matches_identity(target, &identity) { + return Err(FIRST_SAVE_PUBLISH_ERROR.to_string()); + } + let digest = bandscope_desktop_core::sha256_hex_reader(file) + .map_err(|_| FIRST_SAVE_PUBLISH_ERROR.to_string())?; + if !first_save_path_matches_identity(target, &identity) { + return Err(FIRST_SAVE_PUBLISH_ERROR.to_string()); + } + Ok(Some(digest)) +} + +fn verify_expected_project_revision( + target: &Path, + expected_content_sha256: Option<&str>, + next_content_sha256: &str, +) -> Result { + if expected_content_sha256.is_some_and(|revision| !project_revision_is_valid(revision)) { + return Err(PROJECT_REVISION_INVALID_ERROR.to_string()); + } + + let current = current_project_content_sha256(target)?; + match (current.as_deref(), expected_content_sha256) { + (None, None) => Ok(false), + (Some(current), None) if current == next_content_sha256 => Ok(true), + (Some(current), Some(expected)) if current == expected => Ok(false), + _ => Err(PROJECT_REVISION_CONFLICT_ERROR.to_string()), + } +} + +fn publish_project_file_after_admission( + 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) + } + }, + } +} + +/// 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, durability, and native +/// cross-process admission implementation. +pub(crate) fn publish_new_project_file_with_linker_and_directory_sync( + target: &Path, + content: &[u8], + link: F, + sync_parent: S, +) -> Result<(), String> +where + F: FnOnce(&Path, &Path) -> std::io::Result<()>, + S: FnMut(&Path) -> std::io::Result<()>, +{ + let _write_admission = acquire_project_write_admission(target)?; + publish_project_file_after_admission(target, content, link, sync_parent) +} + +/// Persist one app-owned workspace snapshot only when its base revision is still durable. +/// +/// Security Notes: an expected revision is a path-free SHA-256 receipt from the prior accepted +/// workspace publication. After restart, an absent receipt may bind only when the canonical +/// candidate bytes exactly match the existing app-owned workspace bytes under the same native +/// admission lease; that equality path returns without staging or replacing the target. Recovery, +/// revision validation, and replacement otherwise execute under one process-external Project +/// Persistence admission lease. Malformed or stale receipts and non-identical restart candidates +/// fail closed, and stale full snapshots are never replayed automatically. +pub(crate) fn publish_workspace_project_file_with_expected_content( + target: &Path, + content: &[u8], + expected_content_sha256: Option<&str>, +) -> 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 next_revision = bandscope_desktop_core::sha256_hex_reader(Cursor::new(content)) + .map_err(|_| FIRST_SAVE_STAGE_ERROR.to_string())?; + let _write_admission = acquire_project_write_admission(target)?; + engine::recover_project_publication(target)?; + let already_current = verify_expected_project_revision( + target, + expected_content_sha256, + &next_revision, + )?; + if already_current { + return Ok(next_revision); + } + publish_project_file_after_admission( + target, + content, + |source, destination| fs::hard_link(source, destination), + first_save_sync_parent, + )?; + Ok(next_revision) +} + +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..a1a68c378 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence.rs @@ -0,0 +1,56 @@ +#![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_native_write_admission.case"] +mod native_write_admission; +#[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; +#[path = "project_persistence_workspace_revision.case"] +mod workspace_revision; +#[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_native_write_admission.case b/apps/desktop/src-tauri/tests/project_persistence_native_write_admission.case new file mode 100644 index 000000000..ce1d9f27c --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_native_write_admission.case @@ -0,0 +1,129 @@ +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_NATIVE_WRITE_CHILD"; +const ROOT_ENV: &str = "BANDSCOPE_PROJECT_PERSISTENCE_NATIVE_WRITE_ROOT"; +const CHILD_TEST_NAME: &str = + "native_write_admission::child_waits_inside_first_save_durability_boundary"; +const ORIGINAL: &[u8] = br#"{\"id\":\"native-write-original\"}"#; +const FOLLOW_UP: &[u8] = br#"{\"id\":\"native-write-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-native-write-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).expect("test directory should be created"); + path +} + +fn wait_for_marker(child: &mut std::process::Child, marker: &Path) { + let reached = (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, "child must reach the deterministic durability boundary"); + assert!(marker.exists(), "child must publish its boundary marker"); + assert!( + child + .try_wait() + .expect("child status should be readable") + .is_none(), + "child must still hold the first-save publication boundary" + ); +} + +#[test] +fn child_waits_inside_first_save_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("project.bscope"); + let marker = root.join("child-holds-native-write-admission.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-but-not-acknowledged")?; + loop { + thread::sleep(Duration::from_secs(60)); + } + }, + ); + panic!("native-write child unexpectedly returned from the durability boundary"); +} + +#[test] +fn overlapping_process_write_fails_before_overwriting_published_project() { + let root = test_dir("cross-process"); + let target = root.join("project.bscope"); + let marker = root.join("child-holds-native-write-admission.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("native-write child should start"); + + wait_for_marker(&mut child, &marker); + assert_eq!( + fs::read(&target).expect("child publication should already be visible"), + ORIGINAL + ); + + let overlap_result = project_persistence::publish_new_project_file(&target, FOLLOW_UP); + let bytes_during_overlap = + fs::read(&target).expect("overlap attempt must leave a readable project target"); + + child + .kill() + .expect("parent must be able to terminate the admission holder"); + let status = child.wait().expect("terminated child should be reapable"); + assert!(!status.success(), "the interruption must be a real process termination"); + + assert_eq!( + overlap_result.expect_err("a second process must fail closed while native write admission is held"), + "Project update is already being saved." + ); + assert_eq!( + bytes_during_overlap, ORIGINAL, + "rejected overlap must not replace buyer project bytes" + ); + + project_persistence::publish_new_project_file(&target, FOLLOW_UP) + .expect("OS-owned admission must be released when the writer process terminates"); + assert_eq!( + fs::read(&target).expect("follow-up publication should remain readable"), + FOLLOW_UP + ); + + fs::remove_dir_all(root).expect("test 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-tauri/tests/project_persistence_workspace_revision.case b/apps/desktop/src-tauri/tests/project_persistence_workspace_revision.case new file mode 100644 index 000000000..9ae834538 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_workspace_revision.case @@ -0,0 +1,130 @@ +use crate::project_persistence; +use std::{fs, io::Cursor, path::PathBuf}; + +fn test_dir(name: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "bandscope-project-revision-{name}-{}", + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&root).expect("revision fixture directory should be created"); + root +} + +fn digest(content: &[u8]) -> String { + bandscope_desktop_core::sha256_hex_reader(Cursor::new(content)) + .expect("fixture digest should be computed") +} + +#[test] +fn first_workspace_publication_returns_revision_and_stale_revision_fails_closed() { + let root = test_dir("cas"); + let target = root.join("project.bscope"); + let first = br#"{"version":1,"song":{"id":"first"}}"#; + let second = br#"{"version":1,"song":{"id":"second"}}"#; + let stale = br#"{"version":1,"song":{"id":"stale"}}"#; + + let first_revision = project_persistence::publish_workspace_project_file_with_expected_content( + &target, + first, + None, + ) + .expect("first workspace publication should accept an absent predecessor"); + assert_eq!(first_revision, digest(first)); + + let second_revision = project_persistence::publish_workspace_project_file_with_expected_content( + &target, + second, + Some(&first_revision), + ) + .expect("current durable revision should authorize replacement"); + assert_eq!(second_revision, digest(second)); + + let error = project_persistence::publish_workspace_project_file_with_expected_content( + &target, + stale, + Some(&first_revision), + ) + .expect_err("stale full-song snapshot must not overwrite the current project"); + assert_eq!(error, "Project changed since it was opened."); + assert_eq!(fs::read(&target).expect("current project should remain readable"), second); + + fs::remove_dir_all(root).expect("revision fixture should be removed"); +} + +#[test] +fn existing_workspace_can_bind_an_absent_renderer_revision_only_when_content_is_identical() { + let root = test_dir("restart-bind"); + let target = root.join("project.bscope"); + let content = br#"{"version":1,"song":{"id":"restart"}}"#; + fs::write(&target, content).expect("existing workspace fixture should be written"); + + let revision = project_persistence::publish_workspace_project_file_with_expected_content( + &target, + content, + None, + ) + .expect("byte-identical reopen should bind the durable workspace revision"); + + assert_eq!(revision, digest(content)); + assert_eq!(fs::read(&target).expect("bound project should remain readable"), content); + let stage_count = fs::read_dir(&root) + .expect("workspace directory should remain readable") + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(".bandscope-stage-") + }) + .count(); + assert_eq!(stage_count, 0, "revision binding must not stage replacement bytes"); + + fs::remove_dir_all(root).expect("revision fixture should be removed"); +} + +#[test] +fn restart_binding_does_not_bypass_workspace_content_admission() { + let root = test_dir("restart-bind-empty"); + let target = root.join("project.bscope"); + fs::write(&target, b"").expect("empty existing fixture should be written"); + + let error = project_persistence::publish_workspace_project_file_with_expected_content( + &target, + b"", + None, + ) + .expect_err("restart equality must not turn an empty snapshot into accepted project state"); + + assert_eq!(error, "Could not stage the project safely."); + assert_eq!(fs::read(&target).expect("existing empty fixture should remain readable"), b""); + + fs::remove_dir_all(root).expect("revision fixture should be removed"); +} + +#[test] +fn workspace_revision_presence_must_match_target_presence() { + let root = test_dir("presence"); + let target = root.join("project.bscope"); + let content = br#"{"version":1,"song":{"id":"presence"}}"#; + + let unexpected_revision = digest(content); + let missing_error = project_persistence::publish_workspace_project_file_with_expected_content( + &target, + content, + Some(&unexpected_revision), + ) + .expect_err("a revision cannot authorize a target that does not exist"); + assert_eq!(missing_error, "Project changed since it was opened."); + + fs::write(&target, content).expect("existing project fixture should be written"); + let existing_error = project_persistence::publish_workspace_project_file_with_expected_content( + &target, + br#"{"version":1,"song":{"id":"replacement"}}"#, + None, + ) + .expect_err("different existing workspace bytes require an expected durable revision"); + assert_eq!(existing_error, "Project changed since it was opened."); + assert_eq!(fs::read(&target).expect("existing project should remain readable"), content); + + fs::remove_dir_all(root).expect("revision fixture should be removed"); +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_workspace_snapshot.rs b/apps/desktop/src-tauri/tests/project_persistence_workspace_snapshot.rs new file mode 100644 index 000000000..1a79029cf --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_workspace_snapshot.rs @@ -0,0 +1,12 @@ +//! Wiring guard for the app-owned Project Persistence snapshot. + +#[test] +fn workspace_save_resolves_a_fixed_native_snapshot_without_renderer_path_input() { + let source = include_str!("../src/main.rs"); + + assert!(source.contains("workspace: Option")); + assert!(source.contains("app_owned_root(&app, \"projects\", project_id)?")); + assert!(source.contains("project_root.join(\"project.bscope\")")); + assert!(source.contains("project_persistence::recover_project_publication(&path)?")); + assert!(source.contains("project_persistence::publish_new_project_file(&path, content.as_bytes())?")); +} diff --git a/apps/desktop/src/App.project-revision-conflict.test.tsx b/apps/desktop/src/App.project-revision-conflict.test.tsx new file mode 100644 index 000000000..70e9fd6cf --- /dev/null +++ b/apps/desktop/src/App.project-revision-conflict.test.tsx @@ -0,0 +1,144 @@ +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 PROJECT_REVISION_CONFLICT = "Project changed since it was opened."; +const CONTENT_SHA256 = "a".repeat(64); +const { mockLoadProjectDocument, mockSaveProject } = vi.hoisted(() => ({ + 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-revision-conflict", + state: "succeeded" as const, + requestedAt: "2026-09-20T03:00:00Z", + updatedAt: "2026-09-20T03:00:01Z", + progressLabel: "Analysis ready", + progressStage: "ready" as const, + progressPercent: 100, + result: createDemoRehearsalSong() + }), + subscribeToAnalysisJobUpdates: async () => () => undefined, + loadProjectDocument: (...args: unknown[]) => mockLoadProjectDocument(...args), + saveProject: (...args: unknown[]) => mockSaveProject(...args) + }; +}); + +function reopenedDocument() { + return { + song: createDemoRehearsalSong(), + preferences: { selectedPlaybackSource: "full_mix" as const }, + sourceReference: { + projectId: "project-500-5", + artifactName: "source.wav", + extension: "wav" as const, + fileSizeBytes: 8192, + contentSha256: CONTENT_SHA256 + } + }; +} + +describe("App project revision conflict", () => { + beforeEach(() => { + mockLoadProjectDocument.mockReset(); + mockSaveProject.mockReset(); + mockSaveProject.mockResolvedValue(undefined); + }); + + it("keeps the accepted rehearsal visible and offers bounded recovery actions when reopen conflicts", async () => { + mockLoadProjectDocument + .mockResolvedValueOnce(reopenedDocument()) + .mockRejectedValueOnce(new Error(PROJECT_REVISION_CONFLICT)); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + await waitFor(() => + expect(screen.getAllByText("C#m7", { selector: "button" }).length).toBeGreaterThan(0) + ); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toMatch(/project changed elsewhere/i); + expect(screen.getAllByText("C#m7", { selector: "button" }).length).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: /keep current rehearsal/i })).toBeTruthy(); + expect(screen.getByRole("button", { name: /choose another project/i })).toBeTruthy(); + }); + + it("keeps the last accepted song visible when a rehearsal mutation loses the revision race", async () => { + mockSaveProject.mockRejectedValueOnce(new Error(PROJECT_REVISION_CONFLICT)); + const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("Dbmaj7"); + + 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.getAllByText("C#m7", { selector: "button" }).length).toBeGreaterThan(0) + ); + + fireEvent.click(screen.getAllByText("C#m7", { selector: "button" })[0]!); + + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toMatch(/project changed elsewhere/i); + expect(screen.getAllByText("C#m7", { selector: "button" }).length).toBeGreaterThan(0); + expect(screen.queryAllByText("Dbmaj7").length).toBe(0); + promptSpy.mockRestore(); + }); + + it("keeps the conflict notice visible while the score view is active", async () => { + mockLoadProjectDocument.mockRejectedValueOnce(new Error(PROJECT_REVISION_CONFLICT)); + + 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.getAllByRole("button", { name: /^score$/i }).length).toBeGreaterThan(0) + ); + + fireEvent.click(screen.getAllByRole("button", { name: /^score$/i })[0]!); + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toMatch(/project changed elsewhere/i); + }); +}); 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..c4d9aa0df --- /dev/null +++ b/apps/desktop/src/App.project-save-source-authority.test.tsx @@ -0,0 +1,212 @@ +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.mockReset(); + mockSaveProject.mockResolvedValue(undefined); + }); + + 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("persists a local-project mutation before exposing it as accepted renderer state", async () => { + let releasePersist: (() => void) | undefined; + mockSaveProject.mockImplementationOnce( + () => new Promise((resolve) => { + releasePersist = resolve; + }) + ); + const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("Dbmaj7"); + + 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.getAllByText("C#m7", { selector: "button" }).length).toBeGreaterThan(0)); + + fireEvent.click(screen.getAllByText("C#m7", { selector: "button" })[0]!); + + await waitFor(() => { + expect(mockSaveProject).toHaveBeenCalledWith( + expect.objectContaining({ + sections: expect.arrayContaining([ + expect.objectContaining({ chords: expect.arrayContaining(["Dbmaj7"]) }) + ]) + }), + "full_mix", + "project-400-4", + true + ); + }); + expect(screen.queryAllByText("Dbmaj7").length).toBe(0); + + releasePersist?.(); + await waitFor(() => expect(screen.getAllByText("Dbmaj7").length).toBeGreaterThan(0)); + promptSpy.mockRestore(); + }); + + it("does not let overlapping renderer mutations persist concurrently or overwrite a newer accepted revision", async () => { + let releaseFirstPersist: (() => void) | undefined; + mockSaveProject.mockImplementationOnce( + () => new Promise((resolve) => { + releaseFirstPersist = resolve; + }) + ); + const promptSpy = vi + .spyOn(window, "prompt") + .mockReturnValueOnce("Dbmaj7") + .mockReturnValueOnce("Emaj7"); + + 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.getAllByText("C#m7", { selector: "button" }).length).toBeGreaterThan(0)); + + const originalChord = screen.getAllByText("C#m7", { selector: "button" })[0]!; + fireEvent.click(originalChord); + fireEvent.click(originalChord); + + await waitFor(() => expect(mockSaveProject).toHaveBeenCalledTimes(1)); + expect(screen.queryAllByText("Dbmaj7").length).toBe(0); + expect(screen.queryAllByText("Emaj7").length).toBe(0); + + releaseFirstPersist?.(); + await waitFor(() => expect(screen.getAllByText("Dbmaj7").length).toBeGreaterThan(0)); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(mockSaveProject).toHaveBeenCalledTimes(1); + expect(screen.queryAllByText("Emaj7").length).toBe(0); + promptSpy.mockRestore(); + }); + + it("keeps the previously accepted song when workspace persistence fails", async () => { + mockSaveProject.mockRejectedValueOnce(new Error("Could not prepare the local project workspace.")); + const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("Dbmaj7"); + + 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.getAllByText("C#m7", { selector: "button" }).length).toBeGreaterThan(0)); + + fireEvent.click(screen.getAllByText("C#m7", { selector: "button" })[0]!); + + await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy()); + expect(screen.queryAllByText("Dbmaj7").length).toBe(0); + promptSpy.mockRestore(); + }); + + 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.score-project-identity.test.tsx b/apps/desktop/src/App.score-project-identity.test.tsx new file mode 100644 index 000000000..7bb7ca638 --- /dev/null +++ b/apps/desktop/src/App.score-project-identity.test.tsx @@ -0,0 +1,80 @@ +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { App } from "./App"; +import { loadProjectDocument } from "./lib/analysis"; + +vi.mock("./lib/analysis", () => ({ + createDefaultAnalysisRequest: () => ({ + sourceKind: "demo", + sourceLabel: "Project identity regression", + roleFocus: [] + }), + getAnalysisJobStatus: vi.fn(), + importYoutubeUrl: vi.fn(), + isSupportedYoutubeUrl: vi.fn(() => true), + loadProjectDocument: vi.fn(), + MAX_YOUTUBE_URL_LENGTH: 2000, + saveProject: vi.fn(), + subscribeToAnalysisJobUpdates: vi.fn(async () => () => undefined), + selectLocalAudioSource: vi.fn(async () => ({ + ok: false, + error: { code: "invalid_request", message: "No local source selected." } + })), + startAnalysisJob: vi.fn() +})); + +vi.mock("./features/score/ScoreView", () => ({ + ScoreView: ({ projectId }: { projectId: string | null }) => ( +
{projectId ?? "missing-project-id"}
+ ) +})); + +vi.mock("./features/workspace/Workspace", () => ({ + Workspace: () =>
workspace
+})); + +const mockLoadProjectDocument = vi.mocked(loadProjectDocument); + +describe("App score project identity", () => { + beforeEach(() => { + mockLoadProjectDocument.mockReset(); + }); + + it("passes the reopened app-owned publication project id to ScoreView", async () => { + mockLoadProjectDocument.mockResolvedValueOnce({ + song: { + id: "song-reopened-1", + title: "Reopened rehearsal", + sections: [], + exportSummary: { + format: "cue-sheet", + headline: "Reopened app-owned rehearsal", + focusSections: [] + } + }, + preferences: { + selectedPlaybackSource: "full_mix" + }, + sourceReference: { + projectId: "project-42-7", + artifactName: "source.wav", + extension: "wav", + fileSizeBytes: 4096, + contentSha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + + await waitFor(() => { + expect(screen.getByTestId("workspace")).toBeTruthy(); + }); + + const primaryNav = screen.getByRole("navigation", { name: /primary rehearsal views/i }); + fireEvent.click(within(primaryNav).getByRole("button", { name: /^Score$/i })); + + expect(screen.getByTestId("score-project-id")).toHaveTextContent("project-42-7"); + }); +}); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f3d678454..35203b7be 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react"; import { + AlertTriangle, AudioWaveform, CircleHelp, Clock3, @@ -36,13 +37,15 @@ import { getAnalysisJobStatus, importYoutubeUrl, isSupportedYoutubeUrl, - loadProject, + loadProjectDocument, MAX_YOUTUBE_URL_LENGTH, saveProject, subscribeToAnalysisJobUpdates, selectLocalAudioSource, - startAnalysisJob + startAnalysisJob, + type SelectedPlaybackSource } from "./lib/analysis"; +import { isProjectRevisionConflict } from "./lib/projectPersistenceErrors"; import { createTranslator, detectPreferredLocale, type TranslationKey } from "./i18n"; import { ScoreView } from "./features/score/ScoreView"; import { Workspace } from "./features/workspace/Workspace"; @@ -59,6 +62,7 @@ const URL_PATTERN = /\bhttps?:\/\/[^\s"'<>]+/gi; const SECRET_ASSIGNMENT_PATTERN = /\b(token|secret|password|api[_-]?key|access[_-]?token)\s*[:=]\s*[^\s,;]+/gi; type RehearsalView = "workspace" | "score"; +type ProjectConflictKind = "open" | "save"; const NAV_ITEMS = [ { labelKey: "navWorkspace", icon: Home, view: "workspace" }, @@ -247,6 +251,59 @@ function priorityLabel(song: RehearsalSong | null, t: ReturnType void; + onChooseProject: () => void; + t: ReturnType; +}) { + return ( +
+
+
+
+ ); +} + /** Documented. */ export function App() { const t = useMemo(() => createTranslator(detectPreferredLocale()), []); @@ -254,11 +311,16 @@ 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 [projectRevisionConflict, setProjectRevisionConflict] = 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 +349,20 @@ 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); + setProjectRevisionConflict(null); } if (nextStatus.state === "failed") { setActiveAnalysisBootstrap(null); + setActiveAnalysisPublicationProjectId(null); + setProjectRevisionConflict(null); setJobError(safeErrorDetail(nextStatus.error?.message, t("analysisCouldNotStart"))); } - }, [activeAnalysisBootstrap, t]); + }, [activeAnalysisBootstrap, activeAnalysisPublicationProjectId, t]); useEffect(() => { const targetPercent = jobStatus?.progressPercent; @@ -362,6 +430,7 @@ export function App() { } const fallbackMessage = t("analysisCouldNotStart"); setJobError(fallbackMessage); + setProjectRevisionConflict(null); setJobStatus({ ...jobStatus, state: "failed", @@ -388,11 +457,15 @@ export function App() { /** Documented. */ const handleStartAnalysis = async () => { const submittedBootstrap = selectedBootstrap; + const submittedPublicationProjectId = selectedPublicationProjectId; setJobError(null); + setProjectRevisionConflict(null); setJobResult(null); setJobResultBootstrap(null); + setJobResultPublicationProjectId(null); setJobStatus(null); setActiveAnalysisBootstrap(submittedBootstrap); + setActiveAnalysisPublicationProjectId(submittedPublicationProjectId); setIsStarting(true); try { const nextStatus = await startAnalysisJob(selectedRequest); @@ -400,13 +473,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 +497,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 +530,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,16 +553,27 @@ 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); + setProjectRevisionConflict(null); setSelectedBootstrap(null); + setSelectedPublicationProjectId(null); setActiveAnalysisBootstrap(null); + setActiveAnalysisPublicationProjectId(null); setJobStatus(null); } catch (e) { if (!isUserCancellation(e)) { - setJobError(`${t("loadProjectFailedPrefix")}: ${safeErrorDetail(e, t("loadProjectFailedFallback"))}`); + if (isProjectRevisionConflict(e)) { + setJobError(null); + setProjectRevisionConflict("open"); + } else { + setProjectRevisionConflict(null); + setJobError(`${t("loadProjectFailedPrefix")}: ${safeErrorDetail(e, t("loadProjectFailedFallback"))}`); + } } } }; @@ -490,20 +581,64 @@ 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"))}`); + if (isProjectRevisionConflict(e)) { + setJobError(null); + setProjectRevisionConflict("save"); + } else { + setProjectRevisionConflict(null); + setJobError(`${t("saveProjectFailedPrefix")}: ${safeErrorDetail(e, t("saveProjectFailedFallback"))}`); + } } } }; - /** Documented. */ - const handleSongUpdate = (updatedSong: RehearsalSong) => { - setJobResult(updatedSong); + /** + * Accept one rehearsal mutation only after the app-owned project snapshot is durable. + * + * A local project id represents native Project Persistence authority. Renderer + * state is therefore acknowledgement, not the source of truth: when that id + * exists, the updated song is serialized and crash-safely published to the + * fixed workspace snapshot before React exposes the mutation. Persistence + * failure leaves the prior accepted song visible and surfaces a bounded error. + */ + const handleSongUpdate = async (updatedSong: RehearsalSong): Promise => { + const projectId = jobResultPublicationProjectId; + if (!projectId) { + setJobResult(updatedSong); + return true; + } + + try { + await saveProject( + updatedSong, + jobResultSelectedPlaybackSource, + projectId, + true + ); + setJobResult(updatedSong); + setJobError(null); + setProjectRevisionConflict(null); + return true; + } catch (error) { + if (isProjectRevisionConflict(error)) { + setJobError(null); + setProjectRevisionConflict("save"); + } else { + setProjectRevisionConflict(null); + setJobError(`${t("saveProjectFailedPrefix")}: ${safeErrorDetail(error, t("saveProjectFailedFallback"))}`); + } + return false; + } }; - /** Documented. */ + /** Render the accepted workspace state; revision conflicts are rendered above every rehearsal view. */ const renderWorkspaceState = () => { if (jobError) { return ; @@ -841,11 +976,21 @@ export function App() {