diff --git a/.github/workflows/project-persistence-windows.yml b/.github/workflows/project-persistence-windows.yml new file mode 100644 index 000000000..4d451d3a6 --- /dev/null +++ b/.github/workflows/project-persistence-windows.yml @@ -0,0 +1,67 @@ +name: project-persistence-windows + +on: + pull_request: + branches: + - develop + - main + paths: + - "apps/desktop/core/Cargo.toml" + - "apps/desktop/core/src/lib.rs" + - "apps/desktop/core/src/crate_root.rs" + - "apps/desktop/core/src/project_format.rs" + - "apps/desktop/core/tests/project_persistence*.rs" + - "apps/desktop/core/tests/project_format*.rs" + - "apps/desktop/core/testdata/project-*.json" + - "apps/desktop/src-tauri/Cargo.toml" + - "apps/desktop/src-tauri/Cargo.lock" + - "apps/desktop/src-tauri/src/main.rs" + - "apps/desktop/src-tauri/src/project_persistence.rs" + - "apps/desktop/src-tauri/tests/project_persistence*.rs" + - ".github/workflows/project-persistence-windows.yml" + push: + branches: + - develop + - main + paths: + - "apps/desktop/core/Cargo.toml" + - "apps/desktop/core/src/lib.rs" + - "apps/desktop/core/src/crate_root.rs" + - "apps/desktop/core/src/project_format.rs" + - "apps/desktop/core/tests/project_persistence*.rs" + - "apps/desktop/core/tests/project_format*.rs" + - "apps/desktop/core/testdata/project-*.json" + - "apps/desktop/src-tauri/Cargo.toml" + - "apps/desktop/src-tauri/Cargo.lock" + - "apps/desktop/src-tauri/src/main.rs" + - "apps/desktop/src-tauri/src/project_persistence.rs" + - "apps/desktop/src-tauri/tests/project_persistence*.rs" + - ".github/workflows/project-persistence-windows.yml" + +permissions: + contents: read + +env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: develop + +jobs: + windows-recovery-cleanup: + name: test / project-persistence / windows + runs-on: windows-2025 + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Install Rust 1.97.1 + run: rustup toolchain install 1.97.1 --profile minimal + - name: Prepare compile-only frontendDist fixture + shell: pwsh + run: | + New-Item -ItemType Directory -Force apps/desktop/dist | Out-Null + Set-Content -Path apps/desktop/dist/index.html -Value 'BandScope test fixture' -NoNewline + - name: Run Windows recovery-cleanup regression + run: cargo +1.97.1 test --manifest-path apps/desktop/src-tauri/Cargo.toml --no-default-features --tests diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..f2312a8a0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -111,6 +111,7 @@ 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. +- Project writes currently use an independent v1 JSON envelope around the validated rehearsal song; legacy raw song files remain readable, unknown envelope fields fail closed, and unsupported versions return an explicit error. Typed source, derived, decision, handoff, preference, and volatile runtime sections remain follow-up work under #962. - 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. - Those bootstrap roots should resolve from app-owned Tauri data/cache paths instead of the shared system temp namespace. diff --git a/CHANGELOG.md b/CHANGELOG.md index 34331fb86..3044738ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,15 +7,23 @@ - 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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. +- Write project files through the versioned `projectFormatVersion: 1` envelope and retain validated tempo values across save/load, with explicit legacy and unsupported-version handling. ### Changed -- Consolidated Bandit, dependency audits, supplemental secret checks, and Trivy into one trusted-branch security backstop, delegated CodeQL to GitHub default setup, and removed duplicate local PR security and release-preflight runs. - Pinned npm `10.9.9` as the approved lockfile generator, activated it through Node-bundled Corepack before dependency consumption, and fail closed unless its bundled `tar` is at least `7.5.19`; primary CI still consumes the committed lock only through frozen `npm ci` validation, rejects mutable npm resolution in the lock gate, requires integrity evidence for public-registry lock entries, and preserves generator-sensitive root `@esbuild/*` peer metadata. ### Fixed - 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. ## [0.1.3] - 2026-04-29 diff --git a/apps/desktop/core/Cargo.toml b/apps/desktop/core/Cargo.toml index b01a537dc..4fa841f5d 100644 --- a/apps/desktop/core/Cargo.toml +++ b/apps/desktop/core/Cargo.toml @@ -7,7 +7,7 @@ publish = false [lib] name = "bandscope_desktop_core" -path = "src/lib.rs" +path = "src/crate_root.rs" [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage)'] } @@ -19,4 +19,4 @@ time = { version = "0.3", features = ["formatting", "macros"] } url = "2.5.8" [dev-dependencies] -uuid = { version = "1", features = ["v4"] } +uuid = { version = "1", features = ["v4"] } \ No newline at end of file diff --git a/apps/desktop/core/src/crate_root.rs b/apps/desktop/core/src/crate_root.rs new file mode 100644 index 000000000..fd067ee29 --- /dev/null +++ b/apps/desktop/core/src/crate_root.rs @@ -0,0 +1,17 @@ +//! Public crate root for GUI-independent BandScope desktop logic. +//! +//! The historical payload/process source remains at `src/lib.rs` and is +//! included as the `core` module. Project-format evolution is isolated in +//! `project_format` so durable migration rules do not become another renderer +//! or Tauri storage authority. + +#[path = "lib.rs"] +mod core; +mod project_format; + +pub use core::*; +pub use project_format::{ + project_content_for_document, project_content_for_payload, project_document_from_content, + project_document_from_value, project_payload_from_content, ProjectDocumentPayload, + ProjectPreferencesPayload, SelectedPlaybackSourcePayload, CURRENT_PROJECT_FORMAT_VERSION, +}; diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index 200726570..aaf2fc812 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -122,12 +122,149 @@ pub enum AnalysisCacheStatus { pub struct RehearsalSongPayload { id: String, title: String, + #[serde( + default, + deserialize_with = "deserialize_project_tempo", + skip_serializing_if = "Option::is_none" + )] + tempo: Option, sections: Vec, export_summary: ExportSummaryPayload, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_present_optional", + skip_serializing_if = "Option::is_none" + )] + collaboration: Option, + #[serde( + default, + deserialize_with = "deserialize_present_optional", + skip_serializing_if = "Option::is_none" + )] score_attachments: Option>, } +fn deserialize_project_tempo<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Value::deserialize(deserializer)?; + match value { + Value::Number(number) => match number.as_f64() { + Some(tempo) if tempo.is_finite() && tempo > 0.0 => Ok(Some(tempo)), + _ => Err(serde::de::Error::custom( + "project tempo must be a finite positive number", + )), + }, + _ => Err(serde::de::Error::custom( + "project tempo must be a finite positive number", + )), + } +} + +fn deserialize_present_optional<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + T::deserialize(deserializer).map(Some) +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RehearsalCollaborationSyncModePayload { + LocalOnly, + PlannedCloud, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RehearsalAssignmentStatusPayload { + Todo, + InProgress, + Ready, + Blocked, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RehearsalCommentStatusPayload { + Open, + Resolved, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RehearsalApprovalStatusPayload { + Pending, + Approved, + ChangesRequested, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RehearsalAssignmentPayload { + id: String, + assignee: String, + summary: String, + section_id: String, + #[serde( + default, + deserialize_with = "deserialize_present_optional", + skip_serializing_if = "Option::is_none" + )] + role_id: Option, + status: RehearsalAssignmentStatusPayload, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RehearsalCommentPayload { + id: String, + author: String, + body: String, + section_id: String, + #[serde( + default, + deserialize_with = "deserialize_present_optional", + skip_serializing_if = "Option::is_none" + )] + role_id: Option, + status: RehearsalCommentStatusPayload, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RehearsalApprovalPayload { + id: String, + scope: String, + owner: String, + status: RehearsalApprovalStatusPayload, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RehearsalCollaborationPayload { + sync_mode: RehearsalCollaborationSyncModePayload, + sync_note: String, + assignments: Vec, + comments: Vec, + approvals: Vec, +} + +/// Current on-disk project format version, independent of the app version. +pub const CURRENT_PROJECT_FORMAT_VERSION: u16 = 1; + +/// Versioned project envelope. The song remains the compatibility view until +/// source, derived, decision, handoff, preference, and runtime fields are +/// promoted into typed sections in a later format version. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProjectFilePayload { + project_format_version: u16, + song: RehearsalSongPayload, +} + /// Score attachment metadata persisted inside the song payload. Only the /// locally minted score id and the display file name cross the IPC boundary; /// the PDF bytes stay in the app-owned scores directory keyed by that id. @@ -138,18 +275,41 @@ pub struct ScoreAttachmentMetadataPayload { file_name: String, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ConfidenceLevelPayload { + Low, + Medium, + High, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ProvenanceSourcePayload { + Model, + User, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ConfidencePayload { - level: String, - source: String, + level: ConfidenceLevelPayload, + source: ProvenanceSourcePayload, notes: String, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CueKindPayload { + Lyric, + Count, + Transition, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct CuePayload { - kind: String, + kind: CueKindPayload, value: String, } @@ -165,15 +325,78 @@ pub struct RangePayload { pub struct HarmonyPayload { chord: String, function_label: String, - source: String, + source: ProvenanceSourcePayload, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ManualOverrideFieldPayload { + Harmony, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ManualOverrideSourcePayload { + User, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ManualOverrideHarmonyPayload { + chord: String, + function_label: String, + source: ManualOverrideSourcePayload, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ManualOverridePayload { - field: String, - value: HarmonyPayload, - source: String, + field: ManualOverrideFieldPayload, + value: ManualOverrideHarmonyPayload, + source: ManualOverrideSourcePayload, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TranscriptionNotePayload { + pitch: String, + onset: f64, + offset: f64, + velocity: f64, +} + +fn deserialize_practice_progress<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Value::deserialize(deserializer)?; + match value { + Value::Number(number) => match number.as_u64() { + Some(progress) if progress <= 100 => Ok(Some(progress as u8)), + _ => Err(serde::de::Error::custom( + "practiceProgress must be an integer from 0 through 100", + )), + }, + _ => Err(serde::de::Error::custom( + "practiceProgress must be an integer from 0 through 100", + )), + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RehearsalRoleTypePayload { + Instrument, + Vocal, + Hand, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RehearsalPriorityPayload { + Low, + Medium, + High, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -181,16 +404,40 @@ pub struct ManualOverridePayload { pub struct RehearsalRolePayload { id: String, name: String, - role_type: String, + role_type: RehearsalRoleTypePayload, harmony: HarmonyPayload, + #[serde( + default, + deserialize_with = "deserialize_present_optional", + skip_serializing_if = "Option::is_none" + )] + harmonic_explanation: Option, cue: CuePayload, range: RangePayload, confidence: ConfidencePayload, - rehearsal_priority: String, + rehearsal_priority: RehearsalPriorityPayload, simplification: String, setup_note: String, + #[serde( + default, + deserialize_with = "deserialize_present_optional", + skip_serializing_if = "Option::is_none" + )] + transposition_plan: Option, manual_overrides: Vec, overlap_warnings: Vec, + #[serde( + default, + deserialize_with = "deserialize_present_optional", + skip_serializing_if = "Option::is_none" + )] + transcription: Option>, + #[serde( + default, + deserialize_with = "deserialize_practice_progress", + skip_serializing_if = "Option::is_none" + )] + practice_progress: Option, } #[derive(Clone, Debug, Serialize)] @@ -235,11 +482,26 @@ pub struct PartGraphNodePayload { handoff_from: Vec, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SectionFormLabelPayload { + Intro, + Verse, + PreChorus, + Chorus, + Bridge, + Outro, + Tag, + Pickup, + Stop, + Handoff, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RehearsalSectionPayload { id: String, - label: String, + label: SectionFormLabelPayload, groove: String, time_range: SectionTimeRangePayload, confidence: ConfidencePayload, @@ -247,10 +509,17 @@ pub struct RehearsalSectionPayload { part_graph: Vec, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ExportFormatPayload { + CueSheet, + ChartSummary, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExportSummaryPayload { - format: String, + format: ExportFormatPayload, headline: String, focus_sections: Vec, } @@ -528,12 +797,25 @@ pub fn is_youtube_video_id(value: &str) -> bool { } pub fn project_payload_from_content(content: &str) -> Result { - if let Ok(parsed) = serde_json::from_str::(content) { + let payload = serde_json::from_str::(content) + .map_err(|_| "Invalid project file format".to_string())?; + + if let Some(version_value) = payload.get("projectFormatVersion") { + let version = version_value + .as_u64() + .ok_or_else(|| "Invalid project file format".to_string())?; + if version != u64::from(CURRENT_PROJECT_FORMAT_VERSION) { + return Err(format!("Unsupported project format version: {version}")); + } + let envelope = serde_json::from_value::(payload) + .map_err(|_| "Invalid project file format".to_string())?; + return Ok(envelope.song); + } + + if let Ok(parsed) = serde_json::from_value::(payload.clone()) { return Ok(parsed); } - let payload = serde_json::from_str::(content) - .map_err(|_| "Invalid project file format".to_string())?; if let Some(sections) = payload.get("sections").and_then(Value::as_array) { for (section_index, section) in sections.iter().enumerate() { if section @@ -550,6 +832,15 @@ pub fn project_payload_from_content(content: &str) -> Result Result { + serde_json::to_string_pretty(&ProjectFilePayload { + project_format_version: CURRENT_PROJECT_FORMAT_VERSION, + song: payload.clone(), + }) + .map_err(|_| "Failed to serialize project file format".to_string()) +} + #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ScoreAttachmentPayload { @@ -869,6 +1160,95 @@ mod tests { assert_eq!(parsed.title, "Late Night Set"); } + #[test] + fn project_format_v1_round_trips_the_song_and_tempo() { + let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + payload["tempo"] = json!(120.0); + let song = serde_json::from_value::(payload) + .expect("song payload should deserialize"); + + let content = project_content_for_payload(&song).expect("v1 project should serialize"); + let encoded: Value = serde_json::from_str(&content).expect("v1 project should be JSON"); + assert_eq!( + encoded["projectFormatVersion"], + json!(CURRENT_PROJECT_FORMAT_VERSION) + ); + assert_eq!(encoded["song"]["tempo"], json!(120.0)); + + let parsed = project_payload_from_content(&content).expect("v1 project should load"); + assert_eq!(parsed.title, "Late Night Set"); + assert_eq!(parsed.tempo, Some(120.0)); + } + + #[test] + fn project_format_v1_fixture_is_loadable() { + let parsed = project_payload_from_content(include_str!("../testdata/project-v1.json")) + .expect("the checked-in v1 fixture should load"); + + assert_eq!(parsed.id, "fixture-song"); + assert_eq!(parsed.tempo, Some(96.0)); + } + + #[test] + fn project_format_rejects_unknown_fields_and_unsupported_versions() { + let payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + let mut envelope = json!({ + "projectFormatVersion": CURRENT_PROJECT_FORMAT_VERSION, + "song": payload + }); + envelope["unexpected"] = json!(true); + assert_eq!( + project_payload_from_content(&envelope.to_string()) + .expect_err("unknown fields fail closed"), + "Invalid project file format" + ); + + let supported_payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + let supported_envelope = json!({ + "projectFormatVersion": CURRENT_PROJECT_FORMAT_VERSION + 1, + "song": supported_payload + }); + assert_eq!( + project_payload_from_content(&supported_envelope.to_string()) + .expect_err("unsupported version should be explicit"), + "Unsupported project format version: 2" + ); + + let future_envelope = json!({ + "projectFormatVersion": CURRENT_PROJECT_FORMAT_VERSION + 1, + "futureEnvelopeField": true, + "song": { "futureSongField": "new schema" } + }); + assert_eq!( + project_payload_from_content(&future_envelope.to_string()) + .expect_err("future schema should report its unsupported version"), + "Unsupported project format version: 2" + ); + } + + #[test] + fn project_format_rejects_invalid_tempo_values() { + for invalid_tempo in [json!(null), json!(0), json!(-10), json!("120")] { + let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + payload["tempo"] = invalid_tempo; + assert!( + serde_json::from_value::(payload).is_err(), + "invalid tempo should fail closed" + ); + } + + assert!( + project_payload_from_content( + &format!( + r#"{{"projectFormatVersion":{},"song":{{"id":"song","title":"Song","tempo":1e999,"sections":[],"exportSummary":{{}}}}}}"#, + CURRENT_PROJECT_FORMAT_VERSION + ) + ) + .is_err(), + "non-finite JSON numbers should fail closed" + ); + } + #[test] fn project_payload_from_content_rejects_malformed_or_incomplete_payloads() { assert_eq!( diff --git a/apps/desktop/core/src/project_format.rs b/apps/desktop/core/src/project_format.rs new file mode 100644 index 000000000..628c8e23c --- /dev/null +++ b/apps/desktop/core/src/project_format.rs @@ -0,0 +1,164 @@ +//! Versioned local project document and migration boundary. +//! +//! Version 2 introduces durable project preferences without serializing a +//! revocable runtime playback URL. The existing v1/legacy song parser remains +//! the migration authority for historical inputs; this module owns the current +//! envelope presented to external crate consumers. + +use crate::core::{ + project_payload_from_content as project_v1_payload_from_content, RehearsalSongPayload, +}; +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 = 2; + +/// 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, + } + } +} + +/// 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, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProjectFileV2Payload { + project_format_version: u16, + song: RehearsalSongPayload, + preferences: ProjectPreferencesPayload, +} + +fn unsupported_version(version: u64) -> String { + format!("Unsupported project format version: {version}") +} + +/// 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 all use +/// typed allowlists/`deny_unknown_fields`; revocable playback URLs and unknown +/// runtime state therefore fail closed before any filesystem mutation. +pub fn project_document_from_value(value: Value) -> Result { + serde_json::from_value::(value) + .map_err(|_| "Invalid project document payload".to_string()) +} + +/// Parse a current, v1, or legacy project into the current typed document. +/// +/// Security Notes: `.bscope` bytes are untrusted input. Version 2 uses a +/// `deny_unknown_fields` envelope and a closed playback-source enum. Version 1 +/// and legacy raw-song inputs are delegated to the existing strict parser and +/// migrated in memory with the explicit `full_mix` default. Unsupported +/// versions fail before their body is interpreted as current truth. +pub fn project_document_from_content(content: &str) -> Result { + let root = serde_json::from_str::(content) + .map_err(|_| "Invalid project file format".to_string())?; + + let Some(version_value) = root.get("projectFormatVersion") else { + let song = project_v1_payload_from_content(content)?; + return Ok(ProjectDocumentPayload { + song, + preferences: ProjectPreferencesPayload::default(), + }); + }; + + 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(), + }) + } + 2 => { + 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, + ))); + } + Ok(ProjectDocumentPayload { + song: envelope.song, + preferences: envelope.preferences, + }) + } + _ => Err(unsupported_version(version)), + } +} + +/// Compatibility view for callers that currently consume only the song. +/// +/// The current reader still accepts v1 and legacy projects through the ordered +/// migration above, while v2 preferences 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-2 project envelope. +pub fn project_content_for_document(payload: &ProjectDocumentPayload) -> Result { + serde_json::to_string_pretty(&ProjectFileV2Payload { + project_format_version: CURRENT_PROJECT_FORMAT_VERSION, + song: payload.song.clone(), + preferences: payload.preferences.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 v2 without inventing a +/// source choice: their deterministic migration default is `full_mix` until +/// the Active Player bridge supplies an explicit stable preference. +pub fn project_content_for_payload(payload: &RehearsalSongPayload) -> Result { + project_content_for_document(&ProjectDocumentPayload { + song: payload.clone(), + preferences: ProjectPreferencesPayload::default(), + }) +} 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/project_format_v2_fixture.rs b/apps/desktop/core/tests/project_format_v2_fixture.rs new file mode 100644 index 000000000..ed74710e1 --- /dev/null +++ b/apps/desktop/core/tests/project_format_v2_fixture.rs @@ -0,0 +1,29 @@ +use bandscope_desktop_core::{ + project_content_for_document, project_document_from_content, SelectedPlaybackSourcePayload, + CURRENT_PROJECT_FORMAT_VERSION, +}; +use serde_json::{json, Value}; + +#[test] +fn golden_v2_fixture_preserves_the_selected_playback_source() { + let document = project_document_from_content(include_str!("../testdata/project-v2.json")) + .expect("the checked-in v2 fixture should load"); + + assert_eq!( + document.preferences.selected_playback_source, + SelectedPlaybackSourcePayload::Vocals + ); + + let serialized = project_content_for_document(&document) + .expect("the checked-in v2 fixture should serialize"); + let value: Value = serde_json::from_str(&serialized) + .expect("the serialized v2 fixture should remain valid JSON"); + assert_eq!( + value["projectFormatVersion"], + json!(CURRENT_PROJECT_FORMAT_VERSION) + ); + assert_eq!( + value["preferences"]["selectedPlaybackSource"], + json!("vocals") + ); +} diff --git a/apps/desktop/core/tests/project_format_v2_playback_preference.rs b/apps/desktop/core/tests/project_format_v2_playback_preference.rs new file mode 100644 index 000000000..8f6b48940 --- /dev/null +++ b/apps/desktop/core/tests/project_format_v2_playback_preference.rs @@ -0,0 +1,172 @@ +use bandscope_desktop_core::{ + project_content_for_document, project_document_from_content, project_document_from_value, + project_payload_from_content, ProjectDocumentPayload, ProjectPreferencesPayload, + SelectedPlaybackSourcePayload, +}; +use serde_json::{json, Value}; + +fn v1_fixture() -> &'static str { + include_str!("../testdata/project-v1.json") +} + +#[test] +fn v1_migrates_to_v2_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!(2)); + assert_eq!( + value["preferences"]["selectedPlaybackSource"], + json!("full_mix") + ); +} + +#[test] +fn v2_preserves_each_stable_playback_source_semantic() { + 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"); + let round_trip_value: Value = serde_json::from_str(&round_trip) + .expect("the serialized v2 document should remain valid JSON"); + assert_eq!( + round_trip_value["preferences"]["selectedPlaybackSource"], + json!(selected_source) + ); + } +} + +#[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!(2)); + assert_eq!( + value["preferences"]["selectedPlaybackSource"], + json!("full_mix") + ); + + // 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, + }, + }; + + 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("v2 JSON should parse"); + assert_eq!( + value["preferences"]["selectedPlaybackSource"], + json!("drums") + ); + 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 durable v2 envelope"); + let value: Value = serde_json::from_str(&serialized).expect("v2 JSON should parse"); + 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_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/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index ed4f967bd..4b811ab9c 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,5 +1,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod project_persistence; + use bandscope_desktop_core::*; use rfd::FileDialog; use serde_json::{json, Value}; @@ -740,7 +742,7 @@ async fn import_youtube_url( #[tauri::command] fn save_project(payload: Value) -> Result<(), String> { - let parsed = serde_json::from_value::(payload) + let parsed = project_document_from_value(payload) .map_err(|_| "Invalid project payload".to_string())?; let path = FileDialog::new() @@ -748,27 +750,23 @@ fn save_project(payload: Value) -> Result<(), String> { .save_file() .ok_or_else(|| "User cancelled".to_string())?; - let content = serde_json::to_string_pretty(&parsed) - .map_err(|_| "Failed to serialize project".to_string())?; - std::fs::write(path, content).map_err(|_| "Failed to write file".to_string())?; + let content = project_content_for_document(&parsed)?; + project_persistence::recover_project_publication(&path)?; + project_persistence::publish_new_project_file(&path, content.as_bytes())?; Ok(()) } #[tauri::command] -fn load_project() -> Result { +fn load_project() -> Result { let path = FileDialog::new() .add_filter("BandScope Project", &["bscope", "json"]) .pick_file() .ok_or_else(|| "User cancelled".to_string())?; - let metadata = std::fs::metadata(&path).map_err(|_| "Failed to read file".to_string())?; - if metadata.len() > 5 * 1024 * 1024 { - return Err("Project file is too large (exceeds 5MB limit)".to_string()); - } - - let content = std::fs::read_to_string(path).map_err(|_| "Failed to read file".to_string())?; - project_payload_from_content(&content) + project_persistence::recover_project_publication(&path)?; + let content = project_persistence::read_project_file(&path)?; + project_document_from_content(&content) } fn scores_root_for_project( 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..ec56f3e75 --- /dev/null +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -0,0 +1,1760 @@ +use std::{ + fs::{self, File}, + io::{Read, Write}, + path::{Path, PathBuf}, +}; +use serde::{Deserialize, Serialize}; + +const MAX_PROJECT_FILE_BYTES: usize = 5 * 1024 * 1024; +const MAX_RECOVERY_JOURNAL_BYTES: usize = 64 * 1024; +const PROJECT_EXISTS_ERROR: &str = "Project file already exists. Choose a new file name."; +const PROJECT_STAGE_ERROR: &str = "Could not stage the project safely."; +const PROJECT_PUBLISH_ERROR: &str = "Could not publish the project safely."; +const PROJECT_READ_ERROR: &str = "Failed to read file"; +const PROJECT_TOO_LARGE_ERROR: &str = "Project file is too large (exceeds 5 MiB limit)"; +const PROJECT_RECOVERY_ERROR: &str = "Could not recover the project publication safely."; + +#[cfg(windows)] +const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; +#[cfg(windows)] +const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; +#[cfg(target_os = "linux")] +const UNIX_PROJECT_OPEN_FLAGS: i32 = 0x0002_0800; // O_NOFOLLOW | O_NONBLOCK +#[cfg(target_os = "macos")] +const UNIX_PROJECT_OPEN_FLAGS: i32 = 0x0000_0104; // O_NOFOLLOW | O_NONBLOCK + +fn project_parent(target: &Path) -> &Path { + match target.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent, + _ => Path::new("."), + } +} + +fn staging_path(target: &Path) -> Result { + let parent = project_parent(target); + if target.file_name().is_none() { + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + let stage_name = format!(".bandscope-stage-{}.stage", uuid::Uuid::new_v4()); + Ok(parent.join(stage_name)) +} + +fn remove_stage(path: &Path) { + let _ = fs::remove_file(path); +} + +#[cfg(target_os = "linux")] +fn rename_noreplace(source: &Path, destination: &Path) -> std::io::Result<()> { + use std::{ffi::CString, os::unix::ffi::OsStrExt}; + + const AT_FDCWD: i32 = -100; + const RENAME_NOREPLACE: u32 = 1; + + extern "C" { + fn renameat2( + olddirfd: i32, + oldpath: *const std::os::raw::c_char, + newdirfd: i32, + newpath: *const std::os::raw::c_char, + flags: u32, + ) -> i32; + } + + let source = CString::new(source.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "project staging path contains NUL", + ) + })?; + let destination = CString::new(destination.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "project destination path contains NUL", + ) + })?; + + let result = unsafe { + renameat2( + AT_FDCWD, + source.as_ptr(), + AT_FDCWD, + destination.as_ptr(), + RENAME_NOREPLACE, + ) + }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(target_os = "macos")] +fn rename_noreplace(source: &Path, destination: &Path) -> std::io::Result<()> { + use std::{ffi::CString, os::unix::ffi::OsStrExt}; + + const RENAME_EXCL: u32 = 0x0000_0004; + + extern "C" { + fn renamex_np( + from: *const std::os::raw::c_char, + to: *const std::os::raw::c_char, + flags: u32, + ) -> i32; + } + + let source = CString::new(source.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "project staging path contains NUL", + ) + })?; + let destination = CString::new(destination.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "project destination path contains NUL", + ) + })?; + + let result = unsafe { renamex_np(source.as_ptr(), destination.as_ptr(), RENAME_EXCL) }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(windows)] +fn rename_noreplace(source: &Path, destination: &Path) -> std::io::Result<()> { + use std::os::windows::ffi::OsStrExt; + + const MOVEFILE_WRITE_THROUGH: u32 = 0x0000_0008; + + #[link(name = "kernel32")] + extern "system" { + #[link_name = "MoveFileExW"] + fn move_file_ex_w(existing: *const u16, new: *const u16, flags: u32) -> i32; + } + + let source = source + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let destination = destination + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + + let result = unsafe { + move_file_ex_w( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_WRITE_THROUGH, + ) + }; + if result != 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] +fn rename_noreplace(_source: &Path, _destination: &Path) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "atomic no-replace project publication is unsupported on this platform", + )) +} + +#[cfg(target_os = "linux")] +fn rename_exchange(left: &Path, right: &Path) -> std::io::Result<()> { + use std::{ffi::CString, os::unix::ffi::OsStrExt}; + + const AT_FDCWD: i32 = -100; + const RENAME_EXCHANGE: u32 = 2; + + extern "C" { + fn renameat2( + olddirfd: i32, + oldpath: *const std::os::raw::c_char, + newdirfd: i32, + newpath: *const std::os::raw::c_char, + flags: u32, + ) -> i32; + } + + let left = CString::new(left.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "project exchange path contains NUL", + ) + })?; + let right = CString::new(right.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "project exchange path contains NUL", + ) + })?; + + let result = unsafe { + renameat2( + AT_FDCWD, + left.as_ptr(), + AT_FDCWD, + right.as_ptr(), + RENAME_EXCHANGE, + ) + }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(target_os = "macos")] +fn rename_exchange(left: &Path, right: &Path) -> std::io::Result<()> { + use std::{ffi::CString, os::unix::ffi::OsStrExt}; + + const RENAME_SWAP: u32 = 0x0000_0002; + + extern "C" { + fn renamex_np( + from: *const std::os::raw::c_char, + to: *const std::os::raw::c_char, + flags: u32, + ) -> i32; + } + + let left = CString::new(left.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "project exchange path contains NUL", + ) + })?; + let right = CString::new(right.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "project exchange path contains NUL", + ) + })?; + + let result = unsafe { renamex_np(left.as_ptr(), right.as_ptr(), RENAME_SWAP) }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(windows)] +fn replace_file_with_backup( + replaced: &Path, + replacement: &Path, + backup: &Path, +) -> std::io::Result<()> { + use std::{os::windows::ffi::OsStrExt, ptr}; + + #[link(name = "kernel32")] + extern "system" { + #[link_name = "ReplaceFileW"] + fn replace_file_w( + replaced_file_name: *const u16, + replacement_file_name: *const u16, + backup_file_name: *const u16, + replace_flags: u32, + exclude: *mut std::ffi::c_void, + reserved: *mut std::ffi::c_void, + ) -> i32; + } + + let wide = |path: &Path| { + path.as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>() + }; + let replaced = wide(replaced); + let replacement = wide(replacement); + let backup = wide(backup); + + let result = unsafe { + replace_file_w( + replaced.as_ptr(), + replacement.as_ptr(), + backup.as_ptr(), + 0, + ptr::null_mut(), + ptr::null_mut(), + ) + }; + if result != 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(windows)] +pub(crate) fn open_project_file(target: &Path) -> std::io::Result { + use std::os::windows::fs::OpenOptionsExt; + + let mut options = fs::OpenOptions::new(); + options + .read(true) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + options.open(target) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn open_project_file(target: &Path) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + + let mut options = fs::OpenOptions::new(); + options.read(true).custom_flags(UNIX_PROJECT_OPEN_FLAGS); + options.open(target) +} + +#[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))] +pub(crate) fn open_project_file(_target: &Path) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "project loading requires no-follow handle acquisition on this platform", + )) +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn open_project_file(_target: &Path) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "project loading is unsupported on this platform", + )) +} + +#[cfg(unix)] +fn same_file_identity(left: &fs::Metadata, right: &fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + + left.dev() == right.dev() && left.ino() == right.ino() +} + +#[cfg(windows)] +#[repr(C)] +struct WindowsFileTime { + low_date_time: u32, + high_date_time: u32, +} + +#[cfg(windows)] +#[repr(C)] +struct WindowsByHandleFileInformation { + file_attributes: u32, + creation_time: WindowsFileTime, + last_access_time: WindowsFileTime, + last_write_time: WindowsFileTime, + volume_serial_number: u32, + file_size_high: u32, + file_size_low: u32, + number_of_links: u32, + file_index_high: u32, + file_index_low: u32, +} + +#[cfg(windows)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct WindowsFileIdentity { + volume_serial_number: u32, + file_index: u64, +} + +#[cfg(windows)] +pub(crate) fn windows_file_identity(file: &File) -> std::io::Result { + use std::{mem::MaybeUninit, os::windows::io::AsRawHandle}; + + #[link(name = "kernel32")] + extern "system" { + #[link_name = "GetFileInformationByHandle"] + fn get_file_information_by_handle( + file: std::os::windows::io::RawHandle, + information: *mut WindowsByHandleFileInformation, + ) -> i32; + } + + let mut information = MaybeUninit::::uninit(); + let result = unsafe { + get_file_information_by_handle(file.as_raw_handle(), information.as_mut_ptr()) + }; + if result == 0 { + return Err(std::io::Error::last_os_error()); + } + let information = unsafe { information.assume_init() }; + Ok(WindowsFileIdentity { + volume_serial_number: information.volume_serial_number, + file_index: ((information.file_index_high as u64) << 32) + | information.file_index_low as u64, + }) +} + +#[cfg(windows)] +fn metadata_is_regular_project_file(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + + metadata.is_file() && metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 +} + +#[cfg(not(windows))] +fn metadata_is_regular_project_file(metadata: &fs::Metadata) -> bool { + metadata.is_file() && !metadata.file_type().is_symlink() +} + +#[cfg(windows)] +fn metadata_is_safe_project_directory(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + + metadata.is_dir() && metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 +} + +#[cfg(not(windows))] +fn metadata_is_safe_project_directory(metadata: &fs::Metadata) -> bool { + metadata.is_dir() && !metadata.file_type().is_symlink() +} + +#[cfg(unix)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct ProjectFileIdentity { + device: u64, + inode: u64, +} + +#[cfg(unix)] +fn project_file_identity_from_metadata(metadata: &fs::Metadata) -> ProjectFileIdentity { + use std::os::unix::fs::MetadataExt; + + ProjectFileIdentity { + device: metadata.dev(), + inode: metadata.ino(), + } +} + +#[cfg(unix)] +pub(crate) fn project_file_identity(target: &Path) -> Result { + let metadata = fs::symlink_metadata(target).map_err(|_| PROJECT_PUBLISH_ERROR.to_string())?; + if !metadata_is_regular_project_file(&metadata) { + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + Ok(project_file_identity_from_metadata(&metadata)) +} + +#[cfg(windows)] +pub(crate) type ProjectFileIdentity = WindowsFileIdentity; + +#[cfg(windows)] +pub(crate) fn project_file_identity(target: &Path) -> Result { + let file = open_project_file(target).map_err(|_| PROJECT_PUBLISH_ERROR.to_string())?; + let metadata = file + .metadata() + .map_err(|_| PROJECT_PUBLISH_ERROR.to_string())?; + if !metadata_is_regular_project_file(&metadata) { + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + windows_file_identity(&file).map_err(|_| PROJECT_PUBLISH_ERROR.to_string()) +} + +#[cfg(not(any(unix, windows)))] +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct ProjectFileIdentity; + +#[cfg(not(any(unix, windows)))] +pub(crate) fn project_file_identity(_target: &Path) -> Result { + Err(PROJECT_PUBLISH_ERROR.to_string()) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[cfg(unix)] +type JournalPathName = Vec; + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[cfg(windows)] +type JournalPathName = Vec; + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[derive(Debug, Deserialize, Serialize)] +struct PublicationJournal { + version: u8, + target_name: JournalPathName, + candidate_name: JournalPathName, + displaced_name: JournalPathName, + expected: ProjectFileIdentity, + candidate: ProjectFileIdentity, +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn journal_path_name(path: &Path) -> Result { + let name = path + .file_name() + .ok_or_else(|| PROJECT_RECOVERY_ERROR.to_string())?; + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + Ok(name.as_bytes().to_vec()) + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + Ok(name.encode_wide().collect()) + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn path_from_journal_name(parent: &Path, name: &JournalPathName) -> Option { + #[cfg(unix)] + { + use std::{ffi::OsStr, os::unix::ffi::OsStrExt}; + Some(parent.join(OsStr::from_bytes(name))) + } + #[cfg(windows)] + { + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + Some(parent.join(OsString::from_wide(name))) + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn generated_stage_name(name: &JournalPathName) -> bool { + let Some(path) = path_from_journal_name(Path::new("."), name) else { + return false; + }; + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + return false; + }; + let Some(uuid) = name + .strip_prefix(".bandscope-stage-") + .and_then(|value| value.strip_suffix(".stage")) + else { + return false; + }; + uuid::Uuid::parse_str(uuid).is_ok() +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +pub(crate) fn journal_target_key(target: &Path) -> Result { + let canonical_target = fs::canonicalize(target).unwrap_or_else(|_| target.to_path_buf()); + // ponytail: bounded dual-hash names avoid oversized filenames; journal target/path identity + // validation prevents redirects, with a journal index as the upgrade path for hostile collisions. + let mut primary = 0xcbf29ce484222325u64; + let mut secondary = 0x84222325cbf29ce4u64; + let mut update = |byte: u8| { + primary ^= u64::from(byte); + primary = primary.wrapping_mul(0x100000001b3); + secondary ^= u64::from(byte); + secondary = secondary.wrapping_mul(0x100000001b3); + }; + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + + for byte in canonical_target.as_os_str().as_bytes() { + update(*byte); + } + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + + for unit in canonical_target.as_os_str().encode_wide() { + for byte in unit.to_le_bytes() { + update(byte); + } + } + } + Ok(format!("{primary:016x}{secondary:016x}")) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn publication_journal_path(target: &Path, published: bool) -> Result { + let phase = if published { "published" } else { "prepared" }; + Ok(project_parent(target).join(format!( + ".bandscope-recovery-{}.{}.journal", + journal_target_key(target)?, + phase + ))) +} + +#[cfg(unix)] +fn sync_parent_directory(parent: &Path) -> std::io::Result<()> { + File::open(parent)?.sync_all() +} + +#[cfg(windows)] +fn sync_parent_directory(_parent: &Path) -> std::io::Result<()> { + // Windows ReplaceFileW/MoveFileExW provide the native write-through step; directory + // handles are not opened here because ordinary directory opens are not portable on Windows. + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn create_publication_journal( + target: &Path, + candidate_stage: &Path, + displaced: &Path, + expected: &ProjectFileIdentity, + candidate: &ProjectFileIdentity, +) -> Result { + let journal_path = publication_journal_path(target, false)?; + let journal = PublicationJournal { + version: 1, + target_name: journal_path_name(target)?, + candidate_name: journal_path_name(candidate_stage)?, + displaced_name: journal_path_name(displaced)?, + expected: expected.clone(), + candidate: candidate.clone(), + }; + let bytes = serde_json::to_vec(&journal).map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + let mut file = match File::create_new(&journal_path) { + Ok(file) => file, + Err(_) => return Err(PROJECT_RECOVERY_ERROR.to_string()), + }; + if file.write_all(&bytes).is_err() + || file.sync_all().is_err() + || sync_parent_directory(project_parent(target)).is_err() + { + drop(file); + remove_stage(&journal_path); + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + Ok(journal_path) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn project_file_identity_if_present( + path: &Path, +) -> Result, String> { + match fs::symlink_metadata(path) { + Ok(metadata) => { + if !metadata_is_regular_project_file(&metadata) { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + project_file_identity(path).map(Some) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(_) => Err(PROJECT_RECOVERY_ERROR.to_string()), + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn remove_recovery_artifact(path: &Path) -> Result<(), String> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err(PROJECT_RECOVERY_ERROR.to_string()), + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn recovery_artifact_exists(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(_) => Err(PROJECT_RECOVERY_ERROR.to_string()), + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn promote_publication_journal(prepared: &Path, target: &Path) -> Result { + let published = publication_journal_path(target, true)?; + rename_noreplace(prepared, &published).map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + Ok(published) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn finish_successful_publication( + prepared: &Path, + stage: &Path, + target: &Path, +) -> Result<(), String> { + let published = promote_publication_journal(prepared, target)?; + remove_stage(stage); + if matches!( + fs::symlink_metadata(stage), + Err(error) if error.kind() == std::io::ErrorKind::NotFound + ) && sync_parent_directory(project_parent(target)).is_ok() + { + remove_stage(&published); + let _ = sync_parent_directory(project_parent(target)); + } + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn finish_rolled_back_publication( + stage: &Path, + journal: &Path, + target: &Path, +) -> Result<(), String> { + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + remove_recovery_artifact(stage)?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + remove_recovery_artifact(journal)?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn recover_publication_state( + target: &Path, + journal_path: &Path, + journal: &PublicationJournal, + candidate_stage: &Path, + displaced: &Path, + published: bool, +) -> Result<(), String> { + let target_identity = project_file_identity_if_present(target)?; + let candidate_identity = project_file_identity_if_present(candidate_stage)?; + let displaced_identity = if displaced == candidate_stage { + candidate_identity.clone() + } else { + project_file_identity_if_present(displaced)? + }; + + if published { + if target_identity.as_ref() != Some(&journal.candidate) + || (displaced_identity.is_some() + && displaced_identity.as_ref() != Some(&journal.expected)) + || (displaced != candidate_stage && candidate_identity.is_some()) + { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + if displaced_identity.is_some() { + remove_recovery_artifact(displaced)?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + } + remove_recovery_artifact(journal_path)?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + return Ok(()); + } + + if target_identity.as_ref() == Some(&journal.candidate) + && displaced_identity + .as_ref() + .is_some_and(|identity| identity != &journal.candidate) + { + #[cfg(any(target_os = "linux", target_os = "macos"))] + if rename_exchange(displaced, target).is_err() { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + + #[cfg(windows)] + { + if replace_file_with_backup(target, displaced, candidate_stage).is_err() { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + } + remove_recovery_artifact(candidate_stage)?; + if displaced != candidate_stage { + remove_recovery_artifact(displaced)?; + } + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + remove_recovery_artifact(journal_path)?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + return Ok(()); + } + + if target_identity.as_ref() == Some(&journal.expected) + && candidate_identity.as_ref() == Some(&journal.candidate) + && (displaced_identity.is_none() || displaced == candidate_stage) + { + remove_recovery_artifact(candidate_stage)?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + remove_recovery_artifact(journal_path)?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + return Ok(()); + } + + let rollback_artifact_consumed = displaced == candidate_stage || displaced_identity.is_none(); + if target_identity + .as_ref() + .is_some_and(|identity| identity != &journal.expected && identity != &journal.candidate) + && candidate_identity.as_ref() == Some(&journal.candidate) + && rollback_artifact_consumed + { + return finish_rolled_back_publication(candidate_stage, journal_path, target); + } + + if candidate_identity.is_none() && displaced_identity.is_none() { + remove_recovery_artifact(journal_path)?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + return Ok(()); + } + + Err(PROJECT_RECOVERY_ERROR.to_string()) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn journal_target_matches( + target: &Path, + parent: &Path, + journal_target_name: &JournalPathName, +) -> bool { + let Ok(target_name) = journal_path_name(target) else { + return false; + }; + if target_name == *journal_target_name { + return true; + } + let Some(journal_target) = path_from_journal_name(parent, journal_target_name) else { + return false; + }; + let Ok(Some(target_identity)) = project_file_identity_if_present(target) else { + return false; + }; + let Ok(Some(journal_identity)) = project_file_identity_if_present(&journal_target) else { + return false; + }; + target_identity == journal_identity + && fs::canonicalize(target).ok() == fs::canonicalize(journal_target).ok() +} + +/// Repairs one durable, adjacent publication journal when its target is selected again. +/// +/// Security Notes: journal names are derived from the selected target and stage names are generated +/// UUID-based same-directory names; target, journal, and stage paths must stay regular non-link files; +/// journal reads use the bounded no-follow project reader; unrecognized identity pairs fail closed. +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +pub(crate) fn recover_project_publication(target: &Path) -> Result<(), String> { + let parent = project_parent(target); + let prepared_path = publication_journal_path(target, false)?; + let published_path = publication_journal_path(target, true)?; + let prepared_exists = recovery_artifact_exists(&prepared_path)?; + let published_exists = recovery_artifact_exists(&published_path)?; + if prepared_exists && published_exists { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + let Some((journal_path, published)) = (if prepared_exists { + Some((prepared_path, false)) + } else if published_exists { + Some((published_path, true)) + } else { + None + }) else { + return Ok(()); + }; + if !project_parent_chain_is_safe(parent) { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + let metadata = fs::symlink_metadata(&journal_path) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + if !metadata_is_regular_project_file(&metadata) { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + let content = read_project_file_with_opener( + &journal_path, + open_project_file, + MAX_RECOVERY_JOURNAL_BYTES, + PROJECT_RECOVERY_ERROR, + ) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + let journal: PublicationJournal = + serde_json::from_str(&content).map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + if !journal_target_matches(target, parent, &journal.target_name) { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + if journal.version != 1 + || !generated_stage_name(&journal.candidate_name) + || !generated_stage_name(&journal.displaced_name) + { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + let candidate_stage = path_from_journal_name(parent, &journal.candidate_name) + .ok_or_else(|| PROJECT_RECOVERY_ERROR.to_string())?; + let displaced = path_from_journal_name(parent, &journal.displaced_name) + .ok_or_else(|| PROJECT_RECOVERY_ERROR.to_string())?; + recover_publication_state( + target, + &journal_path, + &journal, + &candidate_stage, + &displaced, + published, + )?; + Ok(()) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] +pub(crate) fn recover_project_publication(_target: &Path) -> Result<(), String> { + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn replace_existing_project_file( + stage: &Path, + target: &Path, + expected: &ProjectFileIdentity, +) -> Result<(), String> { + let candidate = match project_file_identity(stage) { + Ok(candidate) => candidate, + Err(error) => { + remove_stage(stage); + return Err(error); + } + }; + let journal = match create_publication_journal(target, stage, stage, expected, &candidate) { + Ok(journal) => journal, + Err(error) => { + remove_stage(stage); + return Err(error); + } + }; + if rename_exchange(stage, target).is_err() { + remove_stage(stage); + remove_stage(&journal); + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + + let displaced = project_file_identity(stage); + if displaced.as_ref().is_ok_and(|identity| identity == expected) { + return finish_successful_publication(&journal, stage, target); + } + + let target_is_candidate = + project_file_identity(target).is_ok_and(|identity| identity == candidate); + if target_is_candidate && rename_exchange(stage, target).is_ok() { + let _ = finish_rolled_back_publication(stage, &journal, target); + } + Err(PROJECT_PUBLISH_ERROR.to_string()) +} + +#[cfg(windows)] +pub(crate) fn replace_existing_project_file( + stage: &Path, + target: &Path, + expected: &ProjectFileIdentity, +) -> Result<(), String> { + let candidate = match project_file_identity(stage) { + Ok(candidate) => candidate, + Err(error) => { + remove_stage(stage); + return Err(error); + } + }; + let backup = staging_path(target)?; + let journal = match create_publication_journal(target, stage, &backup, expected, &candidate) { + Ok(journal) => journal, + Err(error) => { + remove_stage(stage); + return Err(error); + } + }; + if replace_file_with_backup(target, stage, &backup).is_err() { + remove_stage(stage); + remove_stage(&journal); + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + + let displaced = project_file_identity(&backup); + if displaced.as_ref().is_ok_and(|identity| identity == expected) { + return finish_successful_publication(&journal, &backup, target); + } + + let target_is_candidate = + project_file_identity(target).is_ok_and(|identity| identity == candidate); + if target_is_candidate && replace_file_with_backup(target, &backup, stage).is_ok() { + let _ = finish_rolled_back_publication(stage, &journal, target); + } + Err(PROJECT_PUBLISH_ERROR.to_string()) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] +pub(crate) fn replace_existing_project_file( + stage: &Path, + _target: &Path, + _expected: &ProjectFileIdentity, +) -> Result<(), String> { + remove_stage(stage); + Err(PROJECT_PUBLISH_ERROR.to_string()) +} + +#[cfg(any(target_os = "macos", test))] +pub(crate) fn trusted_macos_root_alias_target(path: &Path) -> Option<&'static Path> { + match path.to_str()? { + "/etc" => Some(Path::new("/private/etc")), + "/tmp" => Some(Path::new("/private/tmp")), + "/var" => Some(Path::new("/private/var")), + _ => None, + } +} + +#[cfg(target_os = "macos")] +fn metadata_is_trusted_macos_root_directory_alias(path: &Path, metadata: &fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + + let Some(expected_target) = trusted_macos_root_alias_target(path) else { + return false; + }; + + metadata.file_type().is_symlink() + && metadata.uid() == 0 + && path.parent() == Some(Path::new("/")) + && fs::canonicalize(path).is_ok_and(|resolved| resolved == expected_target) + && fs::symlink_metadata(expected_target) + .is_ok_and(|target_metadata| metadata_is_safe_project_directory(&target_metadata)) +} + +#[cfg(not(target_os = "macos"))] +fn metadata_is_trusted_macos_root_directory_alias(_path: &Path, _metadata: &fs::Metadata) -> bool { + false +} + +fn project_parent_chain_is_safe(parent: &Path) -> bool { + parent + .ancestors() + .filter(|ancestor| !ancestor.as_os_str().is_empty()) + .all(|ancestor| { + fs::symlink_metadata(ancestor).is_ok_and(|metadata| { + metadata_is_safe_project_directory(&metadata) + || metadata_is_trusted_macos_root_directory_alias(ancestor, &metadata) + }) + }) +} + +fn read_project_file_with_opener( + target: &Path, + open_file: F, + max_bytes: usize, + too_large_error: &str, +) -> Result +where + F: FnOnce(&Path) -> std::io::Result, +{ + let before = fs::symlink_metadata(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; + if !metadata_is_regular_project_file(&before) { + return Err(PROJECT_READ_ERROR.to_string()); + } + + #[cfg(windows)] + let before_file = { + let file = open_project_file(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; + let metadata = file + .metadata() + .map_err(|_| PROJECT_READ_ERROR.to_string())?; + if !metadata_is_regular_project_file(&metadata) { + return Err(PROJECT_READ_ERROR.to_string()); + } + file + }; + + let file = open_file(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; + let opened = file + .metadata() + .map_err(|_| PROJECT_READ_ERROR.to_string())?; + let after = fs::symlink_metadata(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; + if !metadata_is_regular_project_file(&opened) || !metadata_is_regular_project_file(&after) { + return Err(PROJECT_READ_ERROR.to_string()); + } + + #[cfg(unix)] + if !same_file_identity(&before, &opened) || !same_file_identity(&opened, &after) { + return Err(PROJECT_READ_ERROR.to_string()); + } + + #[cfg(windows)] + { + let after_file = open_project_file(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; + let after_opened = after_file + .metadata() + .map_err(|_| PROJECT_READ_ERROR.to_string())?; + if !metadata_is_regular_project_file(&after_opened) { + return Err(PROJECT_READ_ERROR.to_string()); + } + + let before_identity = + windows_file_identity(&before_file).map_err(|_| PROJECT_READ_ERROR.to_string())?; + let opened_identity = + windows_file_identity(&file).map_err(|_| PROJECT_READ_ERROR.to_string())?; + let after_identity = + windows_file_identity(&after_file).map_err(|_| PROJECT_READ_ERROR.to_string())?; + if before_identity != opened_identity || opened_identity != after_identity { + return Err(PROJECT_READ_ERROR.to_string()); + } + } + + #[cfg(not(any(unix, windows)))] + return Err(PROJECT_READ_ERROR.to_string()); + + let mut reader = file.take((max_bytes + 1) as u64); + let mut bytes = Vec::new(); + reader + .read_to_end(&mut bytes) + .map_err(|_| PROJECT_READ_ERROR.to_string())?; + if bytes.len() > max_bytes { + return Err(too_large_error.to_string()); + } + String::from_utf8(bytes).map_err(|_| PROJECT_READ_ERROR.to_string()) +} + +/// Reads one project through a bounded, path-stable native file handle. +/// +/// The selected path must name the same regular file before the open, on the opened handle, and +/// immediately after the open. Linux and macOS acquire the handle with no-follow plus non-blocking +/// flags before comparing device/inode identity, so a last-component symlink swap cannot redirect +/// handle acquisition and a special-file swap cannot block the UI thread. Windows opens reparse +/// points without following them, rejects reparse handles, and compares the volume serial number plus +/// file index returned for native handles before, during, and after acquisition. Other Unix targets +/// fail closed until their no-follow open contract is explicitly modeled. The reader remains capped +/// at `MAX_PROJECT_FILE_BYTES + 1`; backup rotation and migration semantics remain later #962 work. +pub(crate) fn read_project_file(target: &Path) -> Result { + read_project_file_with_opener( + target, + open_project_file, + MAX_PROJECT_FILE_BYTES, + PROJECT_TOO_LARGE_ERROR, + ) +} + +/// Publishes a selected project only after its complete bounded bytes are staged and synced. +/// +/// The selected parent and each lexical ancestor must be a real directory rather than a +/// symlink/reparse point before any staging artifact is created. On macOS, only the canonical +/// root-owned `/etc`, `/tmp`, and `/var` aliases are admitted, and each must resolve to its exact +/// `/private` system directory; arbitrary root-level aliases remain fail-closed. This rejects +/// user-writable static ancestor-link redirection without breaking normal paths below macOS system +/// aliases. `File::create_new` makes staging non-clobbering. If the selected target exists, its native +/// identity and permissions are captured from the same pre-staging metadata snapshot on Unix; the +/// staged inode receives the existing read/write permission bits after its bytes are written and +/// before it is synced; executable and special bits are never copied to project data. +/// Linux and macOS then atomically exchange the synced staging inode with the target and accept the +/// publication only when the displaced inode still matches that captured identity; a mismatch is +/// exchanged back before returning an error. Windows uses `ReplaceFileW` with a unique same-directory +/// backup, validates the displaced file's native identity, and restores it when the snapshot no longer +/// matches. For a destination that was absent at the snapshot, a hard link is attempted first; Linux +/// then uses `renameat2(RENAME_NOREPLACE)`, macOS uses `renamex_np(RENAME_EXCL)`, and Windows uses +/// `MoveFileExW` without `MOVEFILE_REPLACE_EXISTING` so a concurrently appearing destination is not +/// clobbered. A newly created final directory entry is part of the success contract: Unix fsyncs its +/// parent before first-save success is acknowledged, while Windows keeps the existing native +/// write-through publication semantics. If that durability step fails after the complete target is +/// visible, the target is not deleted or truncated and the caller receives the safe publication error. +/// Filesystems without the required native primitive fail closed. These checks do not claim +/// descriptor-bound protection for a parent-chain swap or authority before the first post-dialog +/// identity snapshot. A durable adjacent journal repairs an interrupted mismatch rollback the next +/// time the same target is selected; global startup scanning and backup rotation remain #962 work. +pub(crate) fn publish_new_project_file(target: &Path, content: &[u8]) -> Result<(), String> { + publish_new_project_file_with_linker(target, content, |source, destination| { + fs::hard_link(source, destination) + }) +} + +pub(crate) fn publish_new_project_file_with_linker( + target: &Path, + content: &[u8], + link: F, +) -> Result<(), String> +where + F: FnOnce(&Path, &Path) -> std::io::Result<()>, +{ + publish_new_project_file_with_linker_and_directory_sync( + target, + content, + link, + sync_parent_directory, + ) +} + +pub(crate) fn publish_new_project_file_with_linker_and_directory_sync( + target: &Path, + content: &[u8], + link: F, + mut sync_parent: S, +) -> Result<(), String> +where + F: FnOnce(&Path, &Path) -> std::io::Result<()>, + S: FnMut(&Path) -> std::io::Result<()>, +{ + if content.is_empty() { + return Err(PROJECT_STAGE_ERROR.to_string()); + } + if content.len() > MAX_PROJECT_FILE_BYTES { + return Err(PROJECT_TOO_LARGE_ERROR.to_string()); + } + + let parent = project_parent(target); + if !project_parent_chain_is_safe(parent) { + return Err(PROJECT_STAGE_ERROR.to_string()); + } + + let expected_target = match fs::symlink_metadata(target) { + Ok(metadata) => { + if !metadata_is_regular_project_file(&metadata) { + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + #[cfg(unix)] + let identity = project_file_identity_from_metadata(&metadata); + #[cfg(not(unix))] + let identity = project_file_identity(target)?; + Some((identity, metadata.permissions())) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(_) => return Err(PROJECT_PUBLISH_ERROR.to_string()), + }; + + let stage = staging_path(target)?; + let mut staged = File::create_new(&stage).map_err(|_| PROJECT_STAGE_ERROR.to_string())?; + if staged.write_all(content).is_err() { + drop(staged); + remove_stage(&stage); + return Err(PROJECT_STAGE_ERROR.to_string()); + } + #[cfg(unix)] + if let Some((_, permissions)) = expected_target.as_ref() { + use std::os::unix::fs::PermissionsExt; + + let data_permissions = fs::Permissions::from_mode(permissions.mode() & 0o666); + if staged.set_permissions(data_permissions).is_err() { + drop(staged); + remove_stage(&stage); + return Err(PROJECT_STAGE_ERROR.to_string()); + } + } + if staged.sync_all().is_err() { + drop(staged); + remove_stage(&stage); + return Err(PROJECT_STAGE_ERROR.to_string()); + } + drop(staged); + + if let Some((expected, _)) = expected_target { + return replace_existing_project_file(&stage, target, &expected); + } + + if let Err(error) = link(&stage, target) { + if error.kind() == std::io::ErrorKind::AlreadyExists { + remove_stage(&stage); + return Err(PROJECT_EXISTS_ERROR.to_string()); + } + + match rename_noreplace(&stage, target) { + Ok(()) => { + sync_parent(parent).map_err(|_| PROJECT_PUBLISH_ERROR.to_string())?; + return Ok(()); + } + Err(publish_error) if publish_error.kind() == std::io::ErrorKind::AlreadyExists => { + remove_stage(&stage); + return Err(PROJECT_EXISTS_ERROR.to_string()); + } + Err(_) => { + remove_stage(&stage); + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + } + } + + // The final hard-link directory entry must be durable before staging cleanup can be acknowledged. + // A failed sync leaves both complete names intact and reports a publication failure; it never + // deletes the buyer-visible target or pretends that crash-safe first-save durability was achieved. + sync_parent(parent).map_err(|_| PROJECT_PUBLISH_ERROR.to_string())?; + remove_stage(&stage); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + publish_new_project_file, read_project_file, read_project_file_with_opener, + MAX_PROJECT_FILE_BYTES, PROJECT_TOO_LARGE_ERROR, + }; + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn test_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "bandscope-project-persistence-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).expect("test directory should be created"); + path + } + + #[cfg(any(target_os = "linux", target_os = "macos", windows))] + #[test] + fn native_no_replace_rename_preserves_a_competing_destination() { + let root = test_dir("rename-noreplace-conflict"); + let stage = root.join("candidate.stage"); + let target = root.join("setlist.bscope"); + let candidate = br#"{\"id\":\"candidate\"}"#; + let competing = br#"{\"id\":\"competing\"}"#; + fs::write(&stage, candidate).expect("candidate stage should be written"); + fs::write(&target, competing).expect("competing target should be written"); + + let error = super::rename_noreplace(&stage, &target) + .expect_err("native no-replace rename must refuse an existing target"); + + assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!( + fs::read(&target).expect("competing target should remain readable"), + competing + ); + assert_eq!( + fs::read(&stage).expect("candidate stage should remain after conflict"), + candidate + ); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[cfg(any(target_os = "linux", target_os = "macos", windows))] + #[test] + fn native_no_replace_rename_publishes_when_destination_is_absent() { + let root = test_dir("rename-noreplace-new"); + let stage = root.join("candidate.stage"); + let target = root.join("setlist.bscope"); + let candidate = br#"{\"id\":\"candidate\"}"#; + fs::write(&stage, candidate).expect("candidate stage should be written"); + + super::rename_noreplace(&stage, &target) + .expect("native no-replace rename should publish an absent target"); + + assert_eq!( + fs::read(&target).expect("published target should be readable"), + candidate + ); + assert!(!stage.exists()); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[test] + fn publishes_complete_new_project_without_stage_artifacts() { + let root = test_dir("new"); + let target = root.join("setlist.bscope"); + let content = br#"{\"id\":\"song-1\"}"#; + + publish_new_project_file(&target, content).expect("new project should publish safely"); + + assert_eq!( + fs::read(&target).expect("published project should be readable"), + content + ); + let names = fs::read_dir(&root) + .expect("test directory should be readable") + .map(|entry| { + entry + .expect("directory entry should be readable") + .file_name() + }) + .collect::>(); + assert_eq!(names, vec![target.file_name().unwrap().to_os_string()]); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[test] + fn stages_a_project_with_a_max_length_file_name() { + let root = test_dir("max-name"); + let target = root.join("a".repeat(255)); + + publish_new_project_file(&target, br#"{"id":"song-1"}"#) + .expect("a max-length target name should still be stageable"); + + assert!(target.is_file()); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[cfg(any(target_os = "macos", windows))] + #[test] + fn recovers_a_case_alias_of_the_selected_target() { + let root = test_dir("case-alias"); + let target = root.join("Setlist.bscope"); + let alias = root.join("setlist.bscope"); + let stage = super::staging_path(&target).expect("candidate stage path should be derivable"); + let displaced = + super::staging_path(&target).expect("displaced stage path should be derivable"); + let original = br#"{"id":"original"}"#; + let candidate = br#"{"id":"candidate"}"#; + fs::write(&target, original).expect("original fixture should be written"); + if fs::symlink_metadata(&alias).is_err() { + fs::remove_dir_all(root).expect("case-sensitive fixture directory should be removable"); + return; + } + fs::write(&stage, candidate).expect("candidate fixture should be written"); + + let expected = super::project_file_identity(&target) + .expect("original target identity should be capturable"); + let candidate_identity = + super::project_file_identity(&stage).expect("candidate identity should be capturable"); + let journal = super::create_publication_journal( + &target, + &stage, + &displaced, + &expected, + &candidate_identity, + ) + .expect("the recovery journal should be durable before publication"); + fs::rename(&target, &displaced).expect("original target should be displaced"); + fs::rename(&stage, &target).expect("candidate should become the target"); + + super::recover_project_publication(&alias) + .expect("recovery should resolve the case-insensitive target alias"); + + assert_eq!(fs::read(&target).expect("recovered target should be readable"), original); + assert!(!journal.exists(), "the recovered journal should be removed"); + assert!(!displaced.exists(), "the displaced artifact should be removed"); + fs::remove_dir_all(root).expect("fixture directory should be removable"); + } + + #[cfg(any(target_os = "linux", target_os = "macos", windows))] + #[test] + fn cleans_a_completed_rollback_after_process_interruption() { + let root = test_dir("completed-rollback"); + let target = root.join("setlist.bscope"); + let stage = super::staging_path(&target).expect("candidate stage path should be derivable"); + let displaced = if cfg!(windows) { + super::staging_path(&target).expect("backup path should be derivable") + } else { + stage.clone() + }; + let original = br#"{"id":"original"}"#; + let candidate = br#"{"id":"candidate"}"#; + let competing = br#"{"id":"competing"}"#; + fs::write(&target, original).expect("original fixture should be written"); + fs::write(&stage, candidate).expect("candidate fixture should be written"); + let expected = super::project_file_identity(&target) + .expect("original target identity should be capturable"); + let candidate_identity = + super::project_file_identity(&stage).expect("candidate identity should be capturable"); + let journal = super::create_publication_journal( + &target, + &stage, + &displaced, + &expected, + &candidate_identity, + ) + .expect("the recovery journal should be durable before publication"); + + fs::remove_file(&target).expect("the original target should be replaced by the racer"); + fs::write(&target, competing).expect("the competing target should be written"); + super::recover_project_publication(&target) + .expect("completed rollback state should be safely cleaned"); + + assert_eq!(fs::read(&target).expect("competing target should remain readable"), competing); + assert!(!stage.exists(), "the owned candidate should be removed"); + assert!(!displaced.exists(), "the consumed rollback artifact should be absent"); + assert!(!journal.exists(), "the completed rollback journal should be removed"); + fs::remove_dir_all(root).expect("fixture directory should be removable"); + } + + #[test] + fn invalid_replacement_does_not_clobber_an_existing_known_good_project() { + let root = test_dir("existing-invalid"); + let target = root.join("setlist.bscope"); + let known_good = br#"{\"id\":\"known-good\"}"#; + fs::write(&target, known_good).expect("fixture should be written"); + + let error = publish_new_project_file(&target, b"") + .expect_err("invalid replacement must fail before publication"); + + assert_eq!(error, "Could not stage the project safely."); + assert_eq!( + fs::read(&target).expect("known-good project should remain"), + known_good + ); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[cfg(unix)] + #[test] + fn refuses_to_replace_a_symlink_target() { + use std::os::unix::fs::symlink; + + let root = test_dir("save-symlink"); + let external = root.join("external.bscope"); + let selected = root.join("selected.bscope"); + let known_good = br#"{\"id\":\"external-known-good\"}"#; + fs::write(&external, known_good).expect("external fixture should be written"); + symlink(&external, &selected).expect("fixture symlink should be created"); + + let error = publish_new_project_file(&selected, br#"{\"id\":\"replacement\"}"#) + .expect_err("a selected symlink must not be replaced as project authority"); + + assert_eq!(error, "Could not publish the project safely."); + assert_eq!( + fs::read(&external).expect("external project should remain readable"), + known_good + ); + assert!(fs::symlink_metadata(&selected) + .expect("selected symlink should remain") + .file_type() + .is_symlink()); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[test] + fn rejects_project_bytes_beyond_the_existing_load_limit_before_staging() { + let root = test_dir("oversize"); + let target = root.join("setlist.bscope"); + let content = vec![b'x'; MAX_PROJECT_FILE_BYTES + 1]; + + let error = publish_new_project_file(&target, &content) + .expect_err("oversized project should fail before publication"); + + assert_eq!(error, "Project file is too large (exceeds 5 MiB limit)"); + assert!(!target.exists()); + assert_eq!( + fs::read_dir(&root) + .expect("directory should be readable") + .count(), + 0 + ); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[test] + fn reads_project_content_within_the_existing_load_limit() { + let root = test_dir("read-valid"); + let target = root.join("setlist.bscope"); + let content = r#"{"id":"song-1"}"#; + fs::write(&target, content).expect("fixture should be written"); + + assert_eq!( + read_project_file(&target).expect("bounded project should be readable"), + content + ); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[cfg(unix)] + #[test] + fn rejects_project_symlink_before_reading_external_content() { + use std::os::unix::fs::symlink; + + let root = test_dir("read-symlink"); + let external = root.join("external.json"); + let selected = root.join("selected.bscope"); + fs::write(&external, r#"{"id":"external"}"#).expect("external fixture should be written"); + symlink(&external, &selected).expect("fixture symlink should be created"); + + let error = read_project_file(&selected) + .expect_err("a selected symlink must not redirect the project reader"); + + assert_eq!(error, "Failed to read file"); + fs::remove_dir_all(root).expect("test directory 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 directory should be removable"); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn recovers_an_interrupted_existing_project_publication() { + let root = test_dir("recovery"); + let target = root.join("setlist.bscope"); + let stage = root.join(format!(".bandscope-stage-{}.stage", uuid::Uuid::new_v4())); + let known_good = br#"{"id":"known-good"}"#; + let candidate = br#"{"id":"candidate"}"#; + fs::write(&target, known_good).expect("known-good fixture should be written"); + fs::write(&stage, candidate).expect("candidate fixture should be written"); + + let expected = super::project_file_identity(&target).expect("target identity should exist"); + let candidate_identity = + super::project_file_identity(&stage).expect("candidate identity should exist"); + let journal = super::create_publication_journal( + &target, + &stage, + &stage, + &expected, + &candidate_identity, + ) + .expect("the recovery journal should be durable before publication"); + super::rename_exchange(&stage, &target).expect("fixture should model interrupted exchange"); + + super::recover_project_publication(&target) + .expect("the next selection should recover the known-good target"); + + assert_eq!(fs::read(&target).expect("target should remain readable"), known_good); + assert!(!stage.exists(), "the interrupted candidate should be cleaned"); + assert!(!journal.exists(), "the recovery journal should be cleaned"); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn recovers_a_competing_file_preserved_by_an_interrupted_exchange() { + let root = test_dir("raced-recovery"); + let target = root.join("setlist.bscope"); + let parked = root.join("parked-authorized.bscope"); + let stage = root.join(format!(".bandscope-stage-{}.stage", uuid::Uuid::new_v4())); + let authorized = br#"{"id":"authorized"}"#; + let racer = br#"{"id":"racer"}"#; + let candidate = br#"{"id":"candidate"}"#; + fs::write(&target, authorized).expect("authorized fixture should be written"); + fs::write(&stage, candidate).expect("candidate fixture should be written"); + + let expected = super::project_file_identity(&target).expect("target identity should exist"); + let candidate_identity = + super::project_file_identity(&stage).expect("candidate identity should exist"); + let journal = super::create_publication_journal( + &target, + &stage, + &stage, + &expected, + &candidate_identity, + ) + .expect("the recovery journal should be durable before publication"); + fs::rename(&target, &parked).expect("authorized target should be parked by the racer"); + fs::write(&target, racer).expect("racer should win the target pathname"); + super::rename_exchange(&stage, &target).expect("fixture should model interrupted exchange"); + + super::recover_project_publication(&target) + .expect("the preserved competing file should be restored"); + + assert_eq!(fs::read(&target).expect("target should remain readable"), racer); + assert_eq!( + fs::read(&parked).expect("the authorized file should remain readable"), + authorized + ); + assert!(!stage.exists(), "the candidate should be cleaned"); + assert!(!journal.exists(), "the recovery journal should be cleaned"); + fs::remove_dir_all(root).expect("fixture directory should be removable"); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn cleans_a_durable_published_journal_after_target_exchange() { + let root = test_dir("published-recovery"); + let target = root.join("setlist.bscope"); + let stage = root.join(format!(".bandscope-stage-{}.stage", uuid::Uuid::new_v4())); + let known_good = br#"{"id":"known-good"}"#; + let candidate = br#"{"id":"candidate"}"#; + fs::write(&target, known_good).expect("known-good fixture should be written"); + fs::write(&stage, candidate).expect("candidate fixture should be written"); + + let expected = super::project_file_identity(&target).expect("target identity should exist"); + let candidate_identity = + super::project_file_identity(&stage).expect("candidate identity should exist"); + let prepared = super::create_publication_journal( + &target, + &stage, + &stage, + &expected, + &candidate_identity, + ) + .expect("the recovery journal should be durable before publication"); + super::rename_exchange(&stage, &target).expect("fixture should model target exchange"); + let published = super::publication_journal_path(&target, true) + .expect("published journal path should be derivable"); + super::rename_noreplace(&prepared, &published) + .expect("fixture should model the durable published marker"); + + super::recover_project_publication(&target) + .expect("the next selection should clean the completed publication"); + + assert_eq!(fs::read(&target).expect("target should remain readable"), candidate); + assert!(!stage.exists(), "the displaced known-good stage should be cleaned"); + assert!(!published.exists(), "the published journal should be cleaned"); + fs::remove_dir_all(root).expect("fixture directory should be removable"); + } + + #[cfg(any(target_os = "linux", target_os = "macos", windows))] + #[test] + fn unrelated_incomplete_journals_do_not_block_project_recovery() { + let root = test_dir("unrelated-recovery"); + let target = root.join("selected.bscope"); + let unrelated = root.join("other.bscope"); + fs::write(&target, br#"{"id":"selected"}"#).expect("target fixture should be written"); + fs::write( + super::publication_journal_path(&unrelated, false) + .expect("unrelated journal path should be derivable"), + b"{", + ) + .expect("the incomplete unrelated journal should be written"); + + super::recover_project_publication(&target) + .expect("an unrelated incomplete journal must not block recovery"); + assert_eq!( + fs::read(&target).expect("target should remain readable"), + br#"{"id":"selected"}"# + ); + fs::remove_dir_all(root).expect("fixture directory should be removable"); + } + + #[test] + fn save_project_command_routes_through_safe_publisher() { + let main_source = include_str!("main.rs"); + + assert!( + main_source.contains("project_persistence::publish_new_project_file"), + "the Tauri save command must use the staged project publisher" + ); + assert!( + !main_source.contains("std::fs::write(path, content)"), + "the Tauri save command must not truncate the selected destination directly" + ); + } + + #[test] + fn load_project_command_routes_through_bounded_reader() { + let main_source = include_str!("main.rs"); + + assert!( + main_source.contains("project_persistence::read_project_file(&path)"), + "the Tauri load command must enforce the byte ceiling while reading" + ); + assert!( + !main_source.contains("std::fs::read_to_string(path)"), + "the Tauri load command must not allocate through an unbounded second read" + ); + } +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_atomic_publication.rs b/apps/desktop/src-tauri/tests/project_persistence_atomic_publication.rs new file mode 100644 index 000000000..67c71fe38 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_atomic_publication.rs @@ -0,0 +1,157 @@ +#[path = "../src/project_persistence.rs"] +mod project_persistence; + +use std::{ + cell::Cell, + fs, + io, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +fn test_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "bandscope-project-persistence-atomic-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).expect("test directory should be created"); + path +} + +fn stage_paths(root: &Path) -> Vec { + fs::read_dir(root) + .expect("test directory should be readable") + .filter_map(|entry| { + let path = entry.ok()?.path(); + let name = path.file_name()?.to_str()?; + name.starts_with(".bandscope-stage-").then_some(path) + }) + .collect() +} + +#[test] +fn hard_link_fallback_never_reserves_the_final_path_with_an_empty_file() { + let source = include_str!("../src/project_persistence.rs"); + + assert!( + !source.contains("File::create_new(target)") + && !source.contains("File::create_new(&target)"), + "hard-link fallback must not materialize an empty final-path placeholder before the staged project is atomically published" + ); +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[test] +fn hard_link_first_save_does_not_acknowledge_a_failed_parent_directory_sync() { + let root = test_dir("hard-link-dir-sync-failure"); + let target = root.join("setlist.bscope"); + let content = br#"{"id":"durable-candidate"}"#; + let sync_observed_published_target = Cell::new(false); + + let error = project_persistence::publish_new_project_file_with_linker_and_directory_sync( + &target, + content, + |source, destination| fs::hard_link(source, destination), + |parent| { + assert_eq!(parent, root.as_path()); + sync_observed_published_target.set( + fs::read(&target).is_ok_and(|published| published == content), + ); + Err(io::Error::new( + io::ErrorKind::Other, + "injected parent-directory sync failure", + )) + }, + ) + .expect_err("first-save success must wait for parent-directory durability"); + + assert_eq!(error, "Could not publish the project safely."); + assert!(sync_observed_published_target.get()); + assert_eq!( + fs::read(&target).expect("the fully published target must not be deleted on sync failure"), + content + ); + assert_eq!( + stage_paths(&root).len(), + 1, + "hard-link publication must not acknowledge staging cleanup before directory durability" + ); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[test] +fn no_replace_rename_first_save_does_not_acknowledge_a_failed_parent_directory_sync() { + let root = test_dir("rename-dir-sync-failure"); + let target = root.join("setlist.bscope"); + let content = br#"{"id":"rename-candidate"}"#; + let sync_observed_published_target = Cell::new(false); + + let error = project_persistence::publish_new_project_file_with_linker_and_directory_sync( + &target, + content, + |_source, _destination| { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "force native no-replace rename fallback", + )) + }, + |parent| { + assert_eq!(parent, root.as_path()); + sync_observed_published_target.set( + fs::read(&target).is_ok_and(|published| published == content), + ); + Err(io::Error::new( + io::ErrorKind::Other, + "injected parent-directory sync failure", + )) + }, + ) + .expect_err("rename publication must not report success before directory durability"); + + assert_eq!(error, "Could not publish the project safely."); + assert!(sync_observed_published_target.get()); + assert_eq!( + fs::read(&target).expect("the complete renamed target must survive a sync failure"), + content + ); + assert!( + stage_paths(&root).is_empty(), + "native rename consumes the staged path before the durability failure is reported" + ); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[test] +fn successful_first_save_syncs_the_parent_before_hard_link_stage_cleanup() { + let root = test_dir("dir-sync-success"); + let target = root.join("setlist.bscope"); + let content = br#"{"id":"durable-success"}"#; + let sync_calls = Cell::new(0usize); + + project_persistence::publish_new_project_file_with_linker_and_directory_sync( + &target, + content, + |source, destination| fs::hard_link(source, destination), + |parent| { + assert_eq!(parent, root.as_path()); + assert_eq!( + fs::read(&target).expect("target must exist before its directory is synced"), + content + ); + sync_calls.set(sync_calls.get() + 1); + Ok(()) + }, + ) + .expect("first save should succeed after the parent directory is durable"); + + assert_eq!(sync_calls.get(), 1); + assert_eq!(fs::read(&target).expect("published target should be readable"), content); + assert!(stage_paths(&root).is_empty()); + fs::remove_dir_all(root).expect("test directory should be removable"); +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_linked_ancestor.rs b/apps/desktop/src-tauri/tests/project_persistence_linked_ancestor.rs new file mode 100644 index 000000000..d673f0f54 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_linked_ancestor.rs @@ -0,0 +1,34 @@ +#[cfg(unix)] +#[path = "../src/project_persistence.rs"] +mod project_persistence; + +#[cfg(unix)] +#[test] +fn regular_project_under_symlinked_ancestor_reads_without_recovery_state() { + use std::{fs, os::unix::fs::symlink}; + + let root = std::env::temp_dir().join(format!( + "bandscope-linked-ancestor-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let real_parent = root.join("real"); + let linked_parent = root.join("linked"); + fs::create_dir_all(&real_parent).expect("real project directory should be created"); + symlink(&real_parent, &linked_parent).expect("linked project directory should be created"); + + let real_target = real_parent.join("setlist.bscope"); + let selected_target = linked_parent.join("setlist.bscope"); + let content = r#"{"id":"linked-folder-project"}"#; + fs::write(&real_target, content).expect("project fixture should be written"); + + project_persistence::recover_project_publication(&selected_target) + .expect("no recovery journal should not reject an otherwise readable linked-folder project"); + assert_eq!( + project_persistence::read_project_file(&selected_target) + .expect("the final regular project file should remain readable"), + content + ); + + fs::remove_dir_all(&root).expect("test directory should be removable"); +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_macos_root_alias.rs b/apps/desktop/src-tauri/tests/project_persistence_macos_root_alias.rs new file mode 100644 index 000000000..371ecc43d --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_macos_root_alias.rs @@ -0,0 +1,36 @@ +#[path = "../src/project_persistence.rs"] +mod project_persistence; + +use std::path::Path; + +#[test] +fn macos_root_alias_policy_only_allows_known_system_aliases() { + assert_eq!( + project_persistence::trusted_macos_root_alias_target(Path::new("/var")), + Some(Path::new("/private/var")) + ); + assert_eq!( + project_persistence::trusted_macos_root_alias_target(Path::new("/tmp")), + Some(Path::new("/private/tmp")) + ); + assert_eq!( + project_persistence::trusted_macos_root_alias_target(Path::new("/etc")), + Some(Path::new("/private/etc")) + ); + + assert_eq!( + project_persistence::trusted_macos_root_alias_target(Path::new("/opt")), + None, + "an arbitrary root-level alias must not gain project-save authority" + ); + assert_eq!( + project_persistence::trusted_macos_root_alias_target(Path::new("/Users")), + None, + "ordinary root directories are not trusted aliases" + ); + assert_eq!( + project_persistence::trusted_macos_root_alias_target(Path::new("/var/tmp")), + None, + "only the exact top-level system aliases are admitted" + ); +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs b/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs new file mode 100644 index 000000000..d2cabe3ae --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs @@ -0,0 +1,34 @@ +#[path = "../src/project_persistence.rs"] +mod project_persistence; + +#[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"); +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs b/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs new file mode 100644 index 000000000..7f26a4be6 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs @@ -0,0 +1,251 @@ +#[path = "../src/project_persistence.rs"] +mod project_persistence; + +use std::{ + fs, io, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +fn test_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "bandscope-project-overwrite-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).expect("test directory should be created"); + path +} + +#[test] +fn confirmed_existing_project_is_replaced_after_new_bytes_are_staged() { + let root = test_dir("confirmed"); + let target = root.join("setlist.bscope"); + let known_good = br#"{\"id\":\"known-good\"}"#; + let replacement = br#"{\"id\":\"replacement\"}"#; + fs::write(&target, known_good).expect("known-good fixture should be written"); + + project_persistence::publish_new_project_file(&target, replacement) + .expect("a save-dialog-confirmed regular project should be replaceable"); + + assert_eq!( + fs::read(&target).expect("replacement project should be readable"), + replacement + ); + let names = fs::read_dir(&root) + .expect("test directory should be readable") + .map(|entry| { + entry + .expect("directory entry should be readable") + .file_name() + }) + .collect::>(); + assert_eq!(names, vec![target.file_name().unwrap().to_os_string()]); + + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[test] +fn new_project_uses_reserved_rename_when_hard_links_are_unsupported() { + let root = test_dir("no-hard-link"); + let target = root.join("setlist.bscope"); + let content = br#"{\"id\":\"portable-new-save\"}"#; + + project_persistence::publish_new_project_file_with_linker( + &target, + content, + |_stage, _target| { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "fixture filesystem has no hard links", + )) + }, + ) + .expect("a filesystem without hard links should publish through the reserved rename fallback"); + + assert_eq!( + fs::read(&target).expect("fallback-published project should be readable"), + content + ); + let names = fs::read_dir(&root) + .expect("test directory should be readable") + .map(|entry| { + entry + .expect("directory entry should be readable") + .file_name() + }) + .collect::>(); + assert_eq!(names, vec![target.file_name().unwrap().to_os_string()]); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[test] +fn fallback_never_clobbers_a_target_created_after_hard_link_failure() { + let root = test_dir("fallback-race"); + let target = root.join("setlist.bscope"); + let content = br#"{\"id\":\"candidate\"}"#; + let racer = br#"{\"id\":\"racer\"}"#; + + let error = project_persistence::publish_new_project_file_with_linker( + &target, + content, + |_stage, target| { + fs::write(target, racer)?; + Err(io::Error::new( + io::ErrorKind::Unsupported, + "hard-link publication became unavailable after a racer won the name", + )) + }, + ) + .expect_err("the reserved-rename fallback must not clobber a concurrent target"); + + assert_eq!( + error, + "Project file already exists. Choose a new file name." + ); + assert_eq!( + fs::read(&target).expect("racer project should remain readable"), + racer + ); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[test] +fn new_project_never_clobbers_a_target_that_appears_concurrently() { + let root = test_dir("no-hard-link-race"); + let target = root.join("setlist.bscope"); + let content = br#"{\"id\":\"candidate\"}"#; + let racer = br#"{\"id\":\"racer\"}"#; + + let error = project_persistence::publish_new_project_file_with_linker( + &target, + content, + |_stage, target| { + fs::write(target, racer)?; + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "racer won the target name", + )) + }, + ) + .expect_err("publication must fail closed when another writer wins the target name"); + + assert_eq!( + error, + "Project file already exists. Choose a new file name." + ); + assert_eq!( + fs::read(&target).expect("racer project should remain readable"), + racer + ); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[test] +fn existing_project_never_clobbers_a_target_swapped_after_authority_snapshot() { + let root = test_dir("existing-target-race"); + let target = root.join("setlist.bscope"); + let parked = root.join("parked-authorized.bscope"); + let stage = root.join("candidate.stage"); + let authorized = br#"{\"id\":\"authorized\"}"#; + let racer = br#"{\"id\":\"racer\"}"#; + let candidate = br#"{\"id\":\"candidate\"}"#; + fs::write(&target, authorized).expect("authorized fixture should be written"); + fs::write(&stage, candidate).expect("candidate stage should be written"); + + let expected = project_persistence::project_file_identity(&target) + .expect("the selected target identity should be capturable"); + fs::rename(&target, &parked).expect("authorized target should be parked by the racer"); + fs::write(&target, racer).expect("racer should replace the selected pathname"); + + let error = project_persistence::replace_existing_project_file(&stage, &target, &expected) + .expect_err("replacement must fail closed when target identity changed after validation"); + + assert_eq!(error, "Could not publish the project safely."); + assert_eq!( + fs::read(&target).expect("racer target should remain readable"), + racer, + "the save must not clobber a different file that won the pathname" + ); + assert_eq!( + fs::read(&parked).expect("authorized project should remain readable"), + authorized + ); + assert!( + !stage.exists(), + "the rejected candidate stage should be cleaned after a successful rollback" + ); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[cfg(windows)] +#[test] +fn failed_windows_replace_removes_the_candidate_stage() { + let root = test_dir("windows-replace-failure-cleanup"); + let target = root.join("setlist.bscope"); + let stage = root.join("candidate.stage"); + let known_good = br#"{\"id\":\"known-good\"}"#; + let candidate = br#"{\"id\":\"candidate\"}"#; + fs::write(&target, known_good).expect("known-good fixture should be written"); + fs::write(&stage, candidate).expect("candidate stage should be written"); + + let expected = project_persistence::project_file_identity(&target) + .expect("the selected target identity should be capturable"); + let mut permissions = fs::metadata(&target) + .expect("known-good metadata should be readable") + .permissions(); + permissions.set_readonly(true); + fs::set_permissions(&target, permissions) + .expect("the fixture should make ReplaceFileW reject the target"); + + let error = project_persistence::replace_existing_project_file(&stage, &target, &expected) + .expect_err("a failed native replacement must fail closed"); + + assert_eq!(error, "Could not publish the project safely."); + assert!( + !stage.exists(), + "a failed ReplaceFileW attempt must remove the owned candidate stage" + ); + assert_eq!( + fs::read(&target).expect("known-good target should remain readable"), + known_good + ); + + let mut permissions = fs::metadata(&target) + .expect("known-good metadata should remain readable") + .permissions(); + permissions.set_readonly(false); + fs::set_permissions(&target, permissions) + .expect("the fixture should restore write permission before cleanup"); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[test] +fn oversized_project_error_names_the_binary_limit_as_mib() { + let root = test_dir("oversize-unit-copy"); + let target = root.join("setlist.bscope"); + let oversized = vec![b'x'; 5 * 1024 * 1024 + 1]; + + let save_error = project_persistence::publish_new_project_file(&target, &oversized) + .expect_err("a project above the binary 5 MiB ceiling must be rejected"); + assert_eq!( + save_error, + "Project file is too large (exceeds 5 MiB limit)", + "the buyer-visible error must name the 5 * 1024 * 1024 byte ceiling as MiB, not decimal MB" + ); + + let existing = fs::File::create(&target).expect("oversize load fixture should be created"); + existing + .set_len((5 * 1024 * 1024 + 1) as u64) + .expect("oversize load fixture should be sized"); + drop(existing); + let load_error = project_persistence::read_project_file(&target) + .expect_err("the bounded reader must reject the same binary ceiling"); + assert_eq!(load_error, "Project file is too large (exceeds 5 MiB limit)"); + + fs::remove_dir_all(root).expect("test directory should be removable"); +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs b/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs new file mode 100644 index 000000000..e4a897450 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs @@ -0,0 +1,77 @@ +#[path = "../src/project_persistence.rs"] +mod project_persistence; + +#[cfg(unix)] +fn fixture_root(label: &str) -> std::path::PathBuf { + use std::time::{SystemTime, UNIX_EPOCH}; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "bandscope-project-persistence-{label}-{}-{nonce}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("fixture root should be created"); + root +} + +#[cfg(unix)] +#[test] +fn refuses_to_publish_through_symlinked_parent_directory() { + use std::{fs, os::unix::fs::symlink}; + + let root = fixture_root("parent-symlink"); + let external = root.join("external"); + let linked_parent = root.join("selected-parent"); + fs::create_dir_all(&external).expect("external fixture directory should be created"); + symlink(&external, &linked_parent).expect("fixture parent symlink should be created"); + + let target = linked_parent.join("setlist.bscope"); + let error = + project_persistence::publish_new_project_file(&target, br#"{\"id\":\"must-not-escape\"}"#) + .expect_err("a symlinked save parent must not redirect project publication"); + + assert_eq!(error, "Could not stage the project safely."); + assert!(!external.join("setlist.bscope").exists()); + assert_eq!( + fs::read_dir(&external) + .expect("external fixture directory should remain readable") + .count(), + 0, + "no staging or published artifact may escape through the symlinked parent" + ); + + fs::remove_dir_all(root).expect("test fixture should be removable"); +} + +#[cfg(unix)] +#[test] +fn refuses_to_publish_through_symlinked_ancestor_directory() { + use std::{fs, os::unix::fs::symlink}; + + let root = fixture_root("ancestor-symlink"); + let external = root.join("external"); + let external_parent = external.join("nested-parent"); + let linked_ancestor = root.join("selected-root"); + fs::create_dir_all(&external_parent).expect("external nested directory should be created"); + symlink(&external, &linked_ancestor).expect("fixture ancestor symlink should be created"); + + let target = linked_ancestor.join("nested-parent").join("setlist.bscope"); + let error = + project_persistence::publish_new_project_file(&target, br#"{\"id\":\"must-not-escape\"}"#) + .expect_err("a linked ancestor must not redirect project publication"); + + assert_eq!(error, "Could not stage the project safely."); + assert!(!external_parent.join("setlist.bscope").exists()); + assert_eq!( + fs::read_dir(&external_parent) + .expect("external nested directory should remain readable") + .count(), + 0, + "no staging or published artifact may escape through a linked ancestor" + ); + + fs::remove_dir_all(root).expect("test fixture should be removable"); +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_permissions.rs b/apps/desktop/src-tauri/tests/project_persistence_permissions.rs new file mode 100644 index 000000000..0bd2105f2 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_permissions.rs @@ -0,0 +1,75 @@ +#[path = "../src/project_persistence.rs"] +mod project_persistence; + +#[cfg(unix)] +#[test] +fn existing_project_overwrite_preserves_restrictive_mode() { + use std::{ + fs, + os::unix::fs::PermissionsExt, + time::{SystemTime, UNIX_EPOCH}, + }; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "bandscope-project-permissions-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&root).expect("test directory should be created"); + let target = root.join("private.bscope"); + fs::write(&target, br#"{"id":"private-old"}"#).expect("fixture should be written"); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)) + .expect("fixture should be restricted to its owner"); + + project_persistence::publish_new_project_file(&target, br#"{"id":"private-new"}"#) + .expect("existing private project should be replaced safely"); + + let mode = fs::metadata(&target) + .expect("replacement should be readable") + .permissions() + .mode() + & 0o777; + assert_eq!( + mode, 0o600, + "staged replacement must not widen an existing project's Unix permissions" + ); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[cfg(unix)] +#[test] +fn existing_project_overwrite_strips_executable_bits() { + use std::{ + fs, + os::unix::fs::PermissionsExt, + time::{SystemTime, UNIX_EPOCH}, + }; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "bandscope-project-permissions-executable-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&root).expect("test directory should be created"); + let target = root.join("project.bscope"); + fs::write(&target, br#"{"id":"executable-old"}"#).expect("fixture should be written"); + fs::set_permissions(&target, fs::Permissions::from_mode(0o755)) + .expect("fixture should be executable"); + + project_persistence::publish_new_project_file(&target, br#"{"id":"data-new"}"#) + .expect("existing executable project should be replaced safely"); + + let mode = fs::metadata(&target) + .expect("replacement should be readable") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o644, "project data must not retain executable bits"); + fs::remove_dir_all(root).expect("test directory should be removable"); +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_recovery_cleanup.rs b/apps/desktop/src-tauri/tests/project_persistence_recovery_cleanup.rs new file mode 100644 index 000000000..9191f6a3b --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_recovery_cleanup.rs @@ -0,0 +1,203 @@ +#[path = "../src/project_persistence.rs"] +mod project_persistence; + +#[cfg(unix)] +mod unix_recovery_cleanup { + use super::project_persistence; + use std::{ + fs, + os::unix::ffi::OsStrExt, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn test_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "bandscope-project-recovery-cleanup-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).expect("test directory should be created"); + path + } + + fn prepared_journal_path(target: &Path) -> PathBuf { + target.parent().expect("fixture target should have a parent").join(format!( + ".bandscope-recovery-{}.prepared.journal", + project_persistence::journal_target_key(target) + .expect("fixture target key should be derivable") + )) + } + + #[test] + fn stale_prepared_journal_without_recovery_artifacts_does_not_lock_a_changed_target() { + let root = test_dir("stale-journal"); + let target = root.join("setlist.bscope"); + let stage = root.join(format!(".bandscope-stage-{}.stage", uuid::Uuid::new_v4())); + let original = br#"{"id":"original"}"#; + let candidate = br#"{"id":"candidate"}"#; + let replacement = br#"{"id":"external-replacement"}"#; + fs::write(&target, original).expect("original fixture should be written"); + fs::write(&stage, candidate).expect("candidate fixture should be written"); + + let expected = project_persistence::project_file_identity(&target) + .expect("original identity should be capturable"); + let candidate_identity = project_persistence::project_file_identity(&stage) + .expect("candidate identity should be capturable"); + let target_name = target.file_name().unwrap().as_bytes().to_vec(); + let stage_name = stage.file_name().unwrap().as_bytes().to_vec(); + let journal = prepared_journal_path(&target); + let record = serde_json::json!({ + "version": 1, + "target_name": target_name, + "candidate_name": stage_name, + "displaced_name": stage.file_name().unwrap().as_bytes().to_vec(), + "expected": expected, + "candidate": candidate_identity, + }); + fs::write(&journal, serde_json::to_vec(&record).expect("journal should serialize")) + .expect("prepared journal should be written"); + + fs::remove_file(&stage).expect("orphan candidate should be removed"); + fs::remove_file(&target).expect("original target should be replaced externally"); + fs::write(&target, replacement).expect("external replacement should be written"); + + project_persistence::recover_project_publication(&target) + .expect("a journal with no rollback artifacts must not permanently lock the target"); + + assert_eq!(fs::read(&target).expect("target should remain readable"), replacement); + assert!(!journal.exists(), "stale recovery journal should be removed"); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[test] + fn failed_journal_creation_removes_the_owned_candidate_stage() { + let root = test_dir("journal-collision"); + let target = root.join("setlist.bscope"); + let known_good = br#"{"id":"known-good"}"#; + fs::write(&target, known_good).expect("known-good fixture should be written"); + let journal = prepared_journal_path(&target); + fs::write(&journal, b"occupied").expect("fixture should reserve the journal name"); + + project_persistence::publish_new_project_file(&target, br#"{"id":"candidate"}"#) + .expect_err("an occupied prepared journal should fail closed"); + + let leaked_stage = fs::read_dir(&root) + .expect("fixture directory should be readable") + .filter_map(Result::ok) + .map(|entry| entry.file_name()) + .any(|name| { + let name = name.to_string_lossy(); + name.starts_with(".bandscope-stage-") && name.ends_with(".stage") + }); + assert!(!leaked_stage, "failed journal preparation must clean the owned stage"); + assert_eq!(fs::read(&target).expect("known-good target should remain readable"), known_good); + fs::remove_dir_all(root).expect("test directory should be removable"); + } +} + +#[cfg(windows)] +mod windows_recovery_cleanup { + use super::project_persistence; + use std::{ + fs, + os::windows::ffi::OsStrExt, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn test_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "bandscope-project-recovery-cleanup-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).expect("test directory should be created"); + path + } + + fn prepared_journal_path(target: &Path) -> PathBuf { + target.parent().expect("fixture target should have a parent").join(format!( + ".bandscope-recovery-{}.prepared.journal", + project_persistence::journal_target_key(target) + .expect("fixture target key should be derivable") + )) + } + + fn journal_name(path: &Path) -> Vec { + path.file_name() + .expect("fixture path should have a file name") + .encode_wide() + .collect() + } + + #[test] + fn stale_prepared_journal_without_recovery_artifacts_does_not_lock_a_changed_target() { + let root = test_dir("windows-stale-journal"); + let target = root.join("setlist.bscope"); + let stage = root.join(format!(".bandscope-stage-{}.stage", uuid::Uuid::new_v4())); + let original = br#"{"id":"original"}"#; + let candidate = br#"{"id":"candidate"}"#; + let replacement = br#"{"id":"external-replacement"}"#; + fs::write(&target, original).expect("original fixture should be written"); + fs::write(&stage, candidate).expect("candidate fixture should be written"); + + let expected = project_persistence::project_file_identity(&target) + .expect("original identity should be capturable"); + let candidate_identity = project_persistence::project_file_identity(&stage) + .expect("candidate identity should be capturable"); + let journal = prepared_journal_path(&target); + let record = serde_json::json!({ + "version": 1, + "target_name": journal_name(&target), + "candidate_name": journal_name(&stage), + "displaced_name": journal_name(&stage), + "expected": expected, + "candidate": candidate_identity, + }); + fs::write(&journal, serde_json::to_vec(&record).expect("journal should serialize")) + .expect("prepared journal should be written"); + + fs::remove_file(&stage).expect("orphan candidate should be removed"); + fs::remove_file(&target).expect("original target should be replaced externally"); + fs::write(&target, replacement).expect("external replacement should be written"); + + project_persistence::recover_project_publication(&target) + .expect("a journal with no rollback artifacts must not permanently lock the target"); + + assert_eq!(fs::read(&target).expect("target should remain readable"), replacement); + assert!(!journal.exists(), "stale recovery journal should be removed"); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[test] + fn failed_journal_creation_removes_the_owned_candidate_stage() { + let root = test_dir("windows-journal-collision"); + let target = root.join("setlist.bscope"); + let known_good = br#"{"id":"known-good"}"#; + fs::write(&target, known_good).expect("known-good fixture should be written"); + let journal = prepared_journal_path(&target); + fs::write(&journal, b"occupied").expect("fixture should reserve the journal name"); + + project_persistence::publish_new_project_file(&target, br#"{"id":"candidate"}"#) + .expect_err("an occupied prepared journal should fail closed"); + + let leaked_stage = fs::read_dir(&root) + .expect("fixture directory should be readable") + .filter_map(Result::ok) + .map(|entry| entry.file_name()) + .any(|name| { + let name = name.to_string_lossy(); + name.starts_with(".bandscope-stage-") && name.ends_with(".stage") + }); + assert!(!leaked_stage, "failed journal preparation must clean the owned stage"); + assert_eq!(fs::read(&target).expect("known-good target should remain readable"), known_good); + fs::remove_dir_all(root).expect("test directory should be removable"); + } +} diff --git a/apps/desktop/src-tauri/tests/project_persistence_windows_identity.rs b/apps/desktop/src-tauri/tests/project_persistence_windows_identity.rs new file mode 100644 index 000000000..cb1a8406c --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_windows_identity.rs @@ -0,0 +1,69 @@ +#![cfg(windows)] + +#[path = "../src/project_persistence.rs"] +mod project_persistence; + +use std::{ + fs::{self, File}, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +fn test_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "bandscope-project-windows-identity-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).expect("test directory should be created"); + path +} + +#[test] +fn distinct_windows_files_have_distinct_native_identity() { + let root = test_dir("distinct"); + let left_path = root.join("left.bscope"); + let right_path = root.join("right.bscope"); + let bytes = br#"{\"id\":\"same-size\"}"#; + fs::write(&left_path, bytes).expect("left fixture should be written"); + fs::write(&right_path, bytes).expect("right fixture should be written"); + + let left = File::open(&left_path).expect("left fixture should open"); + let right = File::open(&right_path).expect("right fixture should open"); + + assert_ne!( + project_persistence::windows_file_identity(&left) + .expect("left native identity should be readable"), + project_persistence::windows_file_identity(&right) + .expect("right native identity should be readable"), + "distinct files with the same bytes must not collapse to one Windows identity" + ); + + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[test] +fn windows_hard_link_aliases_share_native_identity() { + let root = test_dir("hard-link"); + let original_path = root.join("original.bscope"); + let alias_path = root.join("alias.bscope"); + fs::write(&original_path, br#"{\"id\":\"shared\"}"#) + .expect("original fixture should be written"); + fs::hard_link(&original_path, &alias_path).expect("hard-link fixture should be created"); + + let original = File::open(&original_path).expect("original fixture should open"); + let alias = File::open(&alias_path).expect("alias fixture should open"); + + assert_eq!( + project_persistence::windows_file_identity(&original) + .expect("original native identity should be readable"), + project_persistence::windows_file_identity(&alias) + .expect("alias native identity should be readable"), + "two handles to one file must report one Windows identity" + ); + + fs::remove_dir_all(root).expect("test directory should be removable"); +} diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index bb750b34b..fa0ed829f 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -15,6 +15,14 @@ import { type RehearsalSong } from "@bandscope/shared-types"; import { listen } from "@tauri-apps/api/event"; +import { + createProjectDocument, + parseProjectDocument, + type ProjectDocument, + type SelectedPlaybackSource +} from "./projectDocument"; + +export type { ProjectDocument, SelectedPlaybackSource } from "./projectDocument"; type TauriInvoke = (command: string, args?: Record) => Promise; @@ -342,14 +350,27 @@ export async function importYoutubeUrl(url: string): Promise { - const parsedSong = parseRehearsalSong(song); - await invokeAnalysis("save_project", { payload: parsedSong }); +/** Persist one current v2 project document through the native Project Persistence owner. */ +export async function saveProjectDocument(projectDocument: ProjectDocument): Promise { + const parsedDocument = parseProjectDocument(projectDocument); + await invokeAnalysis("save_project", { payload: parsedDocument }); } -/** Documented. */ -export async function loadProject(): Promise { +/** Reopen one current v2 project document, including stable Active Player preferences. */ +export async function loadProjectDocument(): Promise { const response = await invokeAnalysis("load_project"); - return parseRehearsalSong(response); + return parseProjectDocument(response); +} + +/** Compatibility save for callers that do not yet own a playback-source preference. */ +export async function saveProject( + song: RehearsalSong, + selectedPlaybackSource: SelectedPlaybackSource = "full_mix" +): Promise { + await saveProjectDocument(createProjectDocument(song, selectedPlaybackSource)); +} + +/** Compatibility load for existing song-only consumers while #1160 adopts the v2 document. */ +export async function loadProject(): Promise { + return (await loadProjectDocument()).song; } diff --git a/apps/desktop/src/lib/projectDocument.plainRecord.test.ts b/apps/desktop/src/lib/projectDocument.plainRecord.test.ts new file mode 100644 index 000000000..2e921d072 --- /dev/null +++ b/apps/desktop/src/lib/projectDocument.plainRecord.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { parseProjectDocument } from "./projectDocument"; + +class ProjectDocumentWithPrototype { + song = createDemoRehearsalSong(); + preferences = { selectedPlaybackSource: "vocals" }; +} + +class ProjectPreferencesWithPrototype { + selectedPlaybackSource = "vocals"; +} + +describe("project document plain-record admission", () => { + it("rejects a project document with a custom prototype before persistence IPC", () => { + expect(() => parseProjectDocument(new ProjectDocumentWithPrototype())).toThrow( + "Invalid project document" + ); + }); + + it("rejects custom-prototype preferences even when the outer document is plain", () => { + expect(() => + parseProjectDocument({ + song: createDemoRehearsalSong(), + preferences: new ProjectPreferencesWithPrototype() + }) + ).toThrow("Invalid project document"); + }); + + it("fails closed when prototype inspection itself throws", () => { + const trappedDocument = new Proxy( + { + song: createDemoRehearsalSong(), + preferences: { selectedPlaybackSource: "vocals" } + }, + { + getPrototypeOf() { + throw new Error("prototype trap"); + } + } + ); + + expect(() => parseProjectDocument(trappedDocument)).toThrow("Invalid project document"); + }); + + it("admits null-prototype JSON records without widening the durable field set", () => { + const song = createDemoRehearsalSong(); + const preferences = Object.assign(Object.create(null) as Record, { + selectedPlaybackSource: "bass" + }); + const document = Object.assign(Object.create(null) as Record, { + song, + preferences + }); + + expect(parseProjectDocument(document)).toEqual({ + song, + preferences: { selectedPlaybackSource: "bass" } + }); + }); + + it("continues to admit ordinary JSON-shaped project documents", () => { + const song = createDemoRehearsalSong(); + expect( + parseProjectDocument({ + song, + preferences: { selectedPlaybackSource: "vocals" } + }) + ).toEqual({ + song, + preferences: { selectedPlaybackSource: "vocals" } + }); + }); +}); diff --git a/apps/desktop/src/lib/projectDocument.ts b/apps/desktop/src/lib/projectDocument.ts new file mode 100644 index 000000000..c96840c26 --- /dev/null +++ b/apps/desktop/src/lib/projectDocument.ts @@ -0,0 +1,82 @@ +import { parseRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; + +/** Stable project preference persisted across sessions; never a runtime playback authority. */ +export type SelectedPlaybackSource = "full_mix" | "vocals" | "bass" | "drums" | "other"; + +/** Durable Project Persistence preferences owned by the versioned `.bscope` document. */ +export type ProjectPreferences = { + selectedPlaybackSource: SelectedPlaybackSource; +}; + +/** Current renderer-facing project document admitted by the native persistence owner. */ +export type ProjectDocument = { + song: RehearsalSong; + preferences: ProjectPreferences; +}; + +const SELECTED_PLAYBACK_SOURCES = new Set([ + "full_mix", + "vocals", + "bass", + "drums", + "other" +]); + +function isPlainRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + + try { + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; + } catch { + return false; + } +} + +function hasOnlyKeys(value: Record, allowedKeys: readonly string[]): boolean { + const allowed = new Set(allowedKeys); + return Object.keys(value).every((key) => allowed.has(key)) && Object.keys(value).length === allowedKeys.length; +} + +/** + * Validate the renderer-visible project document without accepting filesystem paths, + * runtime capability URLs, generation tokens, prototype-bearing records, or unknown preference fields. + */ +export function parseProjectDocument(value: unknown): ProjectDocument { + if (!isPlainRecord(value) || !hasOnlyKeys(value, ["song", "preferences"])) { + throw new Error("Invalid project document"); + } + + const preferences = value.preferences; + if (!isPlainRecord(preferences) || !hasOnlyKeys(preferences, ["selectedPlaybackSource"])) { + throw new Error("Invalid project document"); + } + + const selectedPlaybackSource = preferences.selectedPlaybackSource; + if ( + typeof selectedPlaybackSource !== "string" || + !SELECTED_PLAYBACK_SOURCES.has(selectedPlaybackSource as SelectedPlaybackSource) + ) { + throw new Error("Invalid project document"); + } + + return { + song: parseRehearsalSong(value.song), + preferences: { + selectedPlaybackSource: selectedPlaybackSource as SelectedPlaybackSource + } + }; +} + +/** Build the exact current renderer document before crossing the native persistence boundary. */ +export function createProjectDocument( + song: RehearsalSong, + selectedPlaybackSource: SelectedPlaybackSource = "full_mix" +): ProjectDocument { + return parseProjectDocument({ + song: parseRehearsalSong(song), + preferences: { selectedPlaybackSource } + }); +} diff --git a/apps/desktop/src/lib/projectDocumentBridge.test.ts b/apps/desktop/src/lib/projectDocumentBridge.test.ts new file mode 100644 index 000000000..1dbb6961e --- /dev/null +++ b/apps/desktop/src/lib/projectDocumentBridge.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { + loadProjectDocument, + saveProjectDocument, + type SelectedPlaybackSource +} from "./analysis"; + +type TauriWindow = Window & { + __TAURI_INTERNALS__?: unknown; + __TAURI_INVOKE__?: unknown; +}; + +const tauriWindow = window as TauriWindow; +const SOURCE_SEMANTICS: SelectedPlaybackSource[] = [ + "full_mix", + "vocals", + "bass", + "drums", + "other" +]; + +describe("project document bridge", () => { + beforeEach(() => { + delete tauriWindow.__TAURI_INTERNALS__; + delete tauriWindow.__TAURI_INVOKE__; + }); + + it.each(SOURCE_SEMANTICS)( + "persists the stable %s source semantic without serializing runtime authority", + async (selectedPlaybackSource) => { + const invoke = vi.fn().mockResolvedValue(undefined); + tauriWindow.__TAURI_INVOKE__ = invoke; + const song = createDemoRehearsalSong(); + + await saveProjectDocument({ + song, + preferences: { selectedPlaybackSource } + }); + + expect(invoke).toHaveBeenCalledWith("save_project", { + payload: { + song, + preferences: { selectedPlaybackSource } + } + }); + } + ); + + it("returns the persisted source semantic with the reopened song", async () => { + const song = createDemoRehearsalSong(); + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue({ + song, + preferences: { selectedPlaybackSource: "vocals" } + }); + + await expect(loadProjectDocument()).resolves.toEqual({ + song, + preferences: { selectedPlaybackSource: "vocals" } + }); + }); + + it("rejects a revocable playback authority returned across the project boundary", async () => { + const song = createDemoRehearsalSong(); + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue({ + song, + preferences: { + selectedPlaybackSource: "bandscope-playback://project-400-4/vocals?generation=7" + } + }); + + await expect(loadProjectDocument()).rejects.toThrow("Invalid project document"); + }); + + it("rejects unknown preference fields instead of creating a second writable project contract", async () => { + const song = createDemoRehearsalSong(); + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue({ + song, + preferences: { + selectedPlaybackSource: "bass", + runtimeAuthority: "bandscope-playback://project-400-4/bass?generation=7" + } + }); + + await expect(loadProjectDocument()).rejects.toThrow("Invalid project document"); + }); +}); diff --git a/docs/engineering/local-project-format.md b/docs/engineering/local-project-format.md index 4c4368f2c..99101c561 100644 --- a/docs/engineering/local-project-format.md +++ b/docs/engineering/local-project-format.md @@ -1,33 +1,78 @@ # Local Project Format -This document specifies the format and lifecycle of a BandScope `.bscope` project file, focusing on data persistence, manual overrides, and recovery. +This document specifies the format and lifecycle of a BandScope `.bscope` project file, focusing on data persistence, manual overrides, durable rehearsal preferences, and recovery. ## Overview -BandScope projects are saved as `.bscope` files. These files are standard JSON containing the serialized `RehearsalSong` data structure. They allow users to persist the results of audio analysis and their manual corrections (overrides) across sessions. +BandScope projects are saved as `.bscope` files. Current writes use a strict JSON envelope with `projectFormatVersion: 2`. The nested `song` remains the compatibility view used by the desktop rehearsal contract, while `preferences` is the first typed project-level section outside that song view. + +Version 1 files and older raw `RehearsalSong` JSON remain supported inputs. They are parsed by the historical strict song/v1 boundary and migrated in memory to the current document with `preferences.selectedPlaybackSource = "full_mix"`. A migration does not infer that a stem was selected previously because v1 carried no such durable evidence. ## Schema -The primary data structure for a `.bscope` file is the `RehearsalSong` type from `@bandscope/shared-types`. +The rehearsal content inside `song` is the `RehearsalSong` contract from `@bandscope/shared-types`. -### Top-Level Structure +### Top-Level Structure (version 2) ```json { - "id": "string", - "title": "string", - "sections": [ ... ], - "exportSummary": { - "format": "cue-sheet", - "headline": "string", - "focusSections": ["string"] + "projectFormatVersion": 2, + "song": { + "id": "string", + "title": "string", + "tempo": 120, + "sections": [ ... ], + "exportSummary": { + "format": "cue-sheet", + "headline": "string", + "focusSections": ["string"] + }, + "collaboration": { + "syncMode": "local_only", + "syncNote": "string", + "assignments": [ ... ], + "comments": [ ... ], + "approvals": [ ... ] + } + }, + "preferences": { + "selectedPlaybackSource": "full_mix" } } ``` +`selectedPlaybackSource` is a closed durable semantic with exactly these values: `full_mix`, `vocals`, `bass`, `drums`, or `other`. It is not a media URL, local path, generation receipt, or native playback authority. An opaque `bandscope-playback` authority is runtime-only and must never appear in a `.bscope` file. + +The native `save_project`/`load_project` commands now admit and return the complete typed current document, and the TypeScript Project Persistence adapter exposes `saveProjectDocument`/`loadProjectDocument` with the same closed preference domain. Existing song-only `saveProject`/`loadProject` callers remain compatibility adapters and use the deterministic `full_mix` default when they do not own an explicit source preference. The mounted #1160 Active Player still has to supply its selected semantic to this bridge on save and consume the reopened semantic through fresh native source availability; that UI composition step is not claimed complete by the bridge itself. + +`tempo` and `collaboration` are optional song fields. The native persistence boundary preserves the current shared collaboration contract and its assignment/comment/approval state domains. Role records also preserve optional `harmonicExplanation`, `transpositionPlan`, `transcription`, and integer `practiceProgress` from 0 through 100. These fields are typed project data; unknown fields still fail closed rather than being retained in an untyped JSON bag. + +The project format version is independent of the application package version. Version 2 rejects unknown envelope fields and invalid preference tokens. A well-formed unsupported future version returns an explicit unsupported-version error before its body is interpreted as current truth. + +Checked-in compatibility evidence: + +- `apps/desktop/core/testdata/project-v1.json` — supported version-1 input. +- `apps/desktop/core/testdata/project-v2.json` — current version-2 document with an explicit `vocals` preference. +- `apps/desktop/core/tests/project_format_v2_playback_preference.rs` — v1 and legacy migration, closed preference-domain, and no-runtime-authority contracts. +- `apps/desktop/core/tests/project_format_v2_fixture.rs` — current golden-fixture round trip. +- `apps/desktop/src/lib/projectDocumentBridge.test.ts` — renderer/native bridge contract for all five stable semantics plus runtime-authority and unknown-preference rejection. + +### Version 1 compatibility + +Version 1 had the shape below and did not contain project-level preferences: + +```json +{ + "projectFormatVersion": 1, + "song": { ... } +} +``` + +The ordered v1 → v2 migration keeps the validated song unchanged and creates only one new value: `preferences.selectedPlaybackSource = "full_mix"`. This is idempotent at the current reader/writer boundary: once a document is serialized as v2, reopening and serializing it again preserves the same typed preference instead of re-running a heuristic inference. + ### Sections and Roles -Sections describe structural segments of the song (e.g., Intro, Verse, Chorus). Each section contains a list of roles (instruments or vocals). +Sections describe structural segments of the song (for example Intro, Verse, or Chorus). Each section contains a list of roles. ```json { @@ -45,7 +90,7 @@ Sections describe structural segments of the song (e.g., Intro, Verse, Chorus). ### Manual Overrides -To ensure provenance preservation, BandScope records when a user manually changes an analyzed property. This is stored in the `manualOverrides` array on the `RehearsalRole` object. +BandScope records user corrections in the `manualOverrides` array on a `RehearsalRole` so an analyzed value is not confused with user-owned rehearsal truth. ```json { @@ -66,20 +111,27 @@ To ensure provenance preservation, BandScope records when a user manually change }, "source": "user" } - ], - ... + ] } ``` -By retaining `manualOverrides`, BandScope can distinguish between original model outputs and user corrections, meeting the provenance requirements for the product. - ## Security Constraints -When loading `.bscope` files from disk, BandScope applies the following constraints: -1. **Size Limits**: The project file must not exceed an upper bound (currently enforced at 5MB in Tauri backend) to prevent memory exhaustion. -2. **Schema Validation**: The loaded JSON is structurally validated against the `RehearsalSong` contract. -3. **Bounded Processing**: The JSON parsing is standard and safe, avoiding arbitrary code execution or payload expansion attacks. +When loading `.bscope` files from disk, BandScope applies these constraints: + +1. **Size limit** — a project file may not exceed 5 MiB (`5 * 1024 * 1024` bytes) at the current Tauri persistence boundary. +2. **Strict schema validation** — current/v1 envelopes and the rehearsal song contract reject unknown fields according to their published compatibility rule. Playback preference, collaboration state, provenance, cue, role, export, and progress domains are closed values rather than arbitrary strings. +3. **Bounded processing** — project JSON is parsed as data only. The format contains no executable code or runtime playback URL. +4. **Runtime-authority separation** — a selected source is stored only as a stable semantic. Reopening must request a fresh native authority from current resource availability rather than trusting persisted media capability data. + +## Current boundary and next migration slices + +Version 2 now establishes the first typed project preference, executable legacy/v1 → v2 migration, and a symmetric native/TypeScript current-document Save/Reopen bridge. It does not complete #962. Source references, derived analysis artifacts, user decisions beyond the existing song contract, portable handoff data, broader UI preferences, autosave/recovery state, and volatile player state are not fabricated or written into untyped bags. + +The mounted Active Player must still pass its selected semantic into the Project Persistence bridge and, on reopen, resolve the returned semantic against current native source availability. If the requested stem is no longer admitted, the player must fail closed to Full mix. A WebView `localStorage`/session store or serialized `bandscope-playback` URL would create a second authority and is not an acceptable substitute. + +The remaining Project Persistence work includes bounded autosave, known-good backup rotation, startup recovery discovery, accessible Restore / Compare / Discard UX, descriptor-bound parent authority, deterministic migration receipts/hashes, downgrade/rollback behavior, and exhaustive interruption/disk-full/power-loss fault injection. ## Extensibility -Future updates to the `.bscope` format should be backward-compatible where possible, adding new fields to the `RehearsalSong` contract rather than breaking existing fields. If structural changes are required, a format version field may be introduced. +Each future `.bscope` version must have an ordered deterministic migration from every supported predecessor, validate a copy before publication, retain the prior known-good artifact until the migrated document opens successfully, and add a machine-verifiable golden fixture. Unknown fields must either be explicitly preserved by a typed schema or rejected; they must never be silently discarded. diff --git a/docs/traceability/project-format-v2-playback-preference.md b/docs/traceability/project-format-v2-playback-preference.md new file mode 100644 index 000000000..838ef9fd9 --- /dev/null +++ b/docs/traceability/project-format-v2-playback-preference.md @@ -0,0 +1,87 @@ +# Project Format v2 Playback Preference Traceability + +## Problem + +The Active Player has a stable source semantic (`full_mix | vocals | bass | drums | other`) but Project Persistence version 1 stored only the rehearsal `song`. Reopening a project therefore had no durable place to record which admitted rehearsal source the user had selected. Persisting the mounted `bandscope-playback` URL instead would be incorrect because that URL is a revocable runtime authority tied to current native resource admission rather than durable project truth. + +Version 2 established the durable preference, but its first compatibility surface still admitted only a `RehearsalSong` at the Tauri save boundary. A renderer therefore had no typed Project Persistence admission function that could accept an explicit stable source choice without either dropping it back to `full_mix` or bypassing the native format authority. + +## Constraints + +- #970/#962 remains the single Project Persistence owner. #1160 remains the Active Player/UI consumer and must not create a second localStorage, session, or file writer. +- Preserve strict historical v1 and legacy raw-song parsing. A v1 file contains no evidence that a stem was selected, so migration must not infer one. +- The persisted value is a closed rehearsal semantic only. Native playback URLs, absolute paths, generation tokens, and capability receipts stay runtime-only. +- Renderer IPC values are untrusted; an explicit current document must be admitted through typed Rust DTOs before any filesystem mutation. +- Unsupported future versions must fail explicitly before their body is interpreted as the current schema. +- Keep the existing historical core source in place rather than creating a large review-only move for a narrow format change. +- Version 2 is Draft code. Downgrade/rollback behavior and packaged cross-platform evidence remain release gates. + +## RED → fix evidence + +- RED `86207ea0459f1a6e27e80f571ad5d6462a0d6fab` adds `apps/desktop/core/tests/project_format_v2_playback_preference.rs`. The predecessor cannot compile because the current-document API and typed preference did not exist. The test requires deterministic v1/legacy migration to `full_mix`, round-trip preservation of all five stable semantics, rejection of unknown and `bandscope-playback` values, and a typed document constructor that needs no runtime authority. +- Causal implementation `be4ce61f9a865229aad9b46ad27adb79b1028258` introduces `project_format` as the current version/migration boundary and delegates historical v1/legacy validation to the existing strict parser. +- Review-surface repair `e95b1db4495df5d9c721271f9b8edc54840eb004` removes the temporary large source move. `apps/desktop/core/src/lib.rs` is restored byte-for-byte at its historical path; `src/crate_root.rs` includes it as the `core` module and re-exports the current v2 Project Persistence API. `Cargo.toml` changes only the library entry path. The net semantic delta from the predecessor is therefore the small crate-root adapter plus `project_format`, fixtures, tests, and documentation—not a copied 1,600-line implementation. +- Golden fixture `4aa18fa8cbe5e59cf3f1e195f9a20e51c36e4da7` adds `project-v2.json` with an explicit `vocals` preference. Fixture contract `73dc9a7314c0e20938fc767c207e4102e1bbf106` verifies that current-format round trips preserve it. +- Documentation alignment `9518d84eb621b03211a4ad5a164969268ae68cdd` updates `docs/engineering/local-project-format.md` to the version-2 envelope, ordered v1 migration, golden fixtures, runtime-authority separation, and remaining consumer/recovery gaps. +- Evidence-trigger RED `770942f006c80724a5cac970d17acae6da4a9d5b` proves the Windows Project Persistence lane would not run for `crate_root.rs`, `project_format.rs`, or the new `project_format*.rs` integration contracts. Causal workflow fix `72434d1026fe0a409bf291d91ead64d8b13f7959` adds those exact paths to both pull-request and protected-branch triggers without removing any prior input or reducing the Rust test command. +- IPC-admission RED `ed5dd9a05a4ceead5a48119d854d5fc06a7e0a1c` extends the external format contract with a renderer-shaped `{ song, preferences }` document. The predecessor cannot compile because `project_document_from_value` does not exist. The RED requires all five stable tokens to survive durable v2 serialization and rejects an unknown token, a realistic revocable `bandscope-playback://...` value, and an extra root `runtimeAuthority` field. +- Causal IPC implementation `7711b4f938d6dd95dbd58a31595a3a7760834bdb` makes `ProjectDocumentPayload` a strict deserializable DTO and adds `project_document_from_value`. Fresh review of the crate root then found that the new function was not re-exported, so the external integration contract would still fail to compile even though the implementation existed. +- Public-surface repair `4f076ce7c2a03b455409a318d045f526492497f6` adds `project_document_from_value` to the canonical `crate_root.rs` re-export list. The external test now addresses the same public Project Persistence API that Tauri and later consumers must use rather than reaching into a private module. + +## Decision + +Version 2 adds one typed top-level section: + +```json +{ + "projectFormatVersion": 2, + "song": { "...": "validated RehearsalSong" }, + "preferences": { + "selectedPlaybackSource": "full_mix" + } +} +``` + +`selectedPlaybackSource` accepts exactly `full_mix`, `vocals`, `bass`, `drums`, or `other`. V1 and legacy raw-song inputs migrate to `full_mix` because that is the only selection consistent with the absence of historical stem-selection evidence. Existing song-only save callers advance to v2 with the same deterministic default. The native core now publicly admits a strict renderer-shaped current document so the upcoming Tauri bridge can pass an explicit stable preference without accepting arbitrary JSON or runtime playback authority. + +On reopen, the stored semantic is not sufficient authority to play audio. The consumer must ask the native Active Player/resource-admission boundary for current source availability, resolve a fresh opaque authority, and fall back to Full mix when the stored stem is unavailable. + +## Alternatives rejected + +- **Persist the current `bandscope-playback` URL** — rejected because a generation-bound capability is revocable runtime state, not portable project truth. +- **Keep the selected source inside the `song` DTO** — rejected because it is a project/UI preference, not MIR/rehearsal-song analysis truth, and would blur bounded-context ownership. +- **Use an arbitrary string preference or raw `serde_json::Value` as the storage DTO** — rejected because malformed, future, injected, or runtime-only values would survive as if they were current domain truth. +- **Deserialize only `preferences` and trust the separately parsed song** — rejected because it would create split admission semantics for one durable document and make unknown root fields invisible. +- **Expose the new admission function only inside the private format module** — rejected because the actual Tauri/consumer bridge must depend on one canonical public Project Persistence API; a private-only function gives false unit-level confidence while the external integration contract remains RED. +- **Infer the most recently generated stem during v1 migration** — rejected because the v1 artifact has no durable evidence for that claim. Deterministic `full_mix` is the only non-fabricated migration. +- **Create a WebView persistence store until the project format catches up** — rejected because it would establish a second writer and could disagree with the crash-safe project artifact after Save As, reopen, or recovery. +- **Keep the temporary `lib.rs` → `core.rs` file move** — rejected after reviewing the resulting diff. Although byte-equivalent, it expanded the review surface by roughly the whole historical core source without adding product behavior. The ordinary descendant repair keeps the source at its original path and uses a small crate-root adapter instead. +- **Rely on general cross-platform build checks while omitting the focused Windows persistence trigger** — rejected because #962 already owns a focused Windows evidence lane and format-contract changes must not silently skip it due to stale path filters. + +## Effect + +The canonical Project Persistence branch now has a typed current document with a versioned preference boundary, executable v1/legacy migration, and a strict public native admission function for renderer-supplied current documents. This closes the schema/authority prerequisite for passing an explicit stable source through Tauri without persisting runtime media capability data. + +This does not yet mean that a user-selected stem survives reopen. The current `save_project` command still accepts only `RehearsalSong` and therefore writes the compatibility `full_mix` default; `load_project` still returns only the song compatibility view. The next consumer slice must wire these commands and #1160 to the current document API, then resolve the restored semantic against fresh native availability. + +## Security Notes + +### Attack surface and trust boundary + +`.bscope` bytes and renderer IPC values are untrusted local input. The preference is admitted only through the native Project Persistence format boundary. Runtime playback authorities originate from native resource admission and remain outside the durable document. The renderer does not gain permission to mint or persist a playback URL merely because it can choose a stable semantic. + +### Validation and fail-closed behavior + +The v2 disk envelope uses `deny_unknown_fields`; the renderer-facing `ProjectDocumentPayload` and nested `ProjectPreferencesPayload` also use `deny_unknown_fields`; `selectedPlaybackSource` is a serde enum with five accepted tokens; and the rehearsal song remains governed by the strict typed DTO. Unknown root fields, unknown preference fields, unknown source values, and a literal `bandscope-playback://...` value fail parsing before filesystem publication. V1/legacy inputs reuse the already hardened strict song parser rather than a permissive migration. Future versions return `Unsupported project format version: ` before their future body is interpreted as v2. + +### Logging and privacy + +Migration and IPC-admission errors are bounded format/validation errors. They do not need to echo project paths, song content, collaboration text, media URLs, credentials, or audio metadata. The v2 preference itself contains no path or resource locator. + +### Test points + +The RED/fix suite covers v1 migration, legacy migration, every valid source token, unknown tokens, a realistic revocable playback URL, typed construction without runtime authority, checked-in v2 golden fixture, renderer-shaped current-document admission, rejection of extra runtime authority at the IPC document root, and public-crate visibility of that admission function. Existing Project Persistence tests continue to own bounded I/O, symlink/reparse checks, native identity, atomic publication/recovery, permission normalization, and the 5 MiB ceiling. The focused Windows workflow policy test also pins every Rust format source, format integration test, golden fixture, Tauri persistence source, and manifest/lock input that must wake the platform-specific persistence lane. + +### Remaining risk + +The current Tauri `save_project` and `load_project` commands still expose the song-only compatibility view, so an explicit Active Player preference is not yet carried through Save/Reopen. Reopen resolution/fallback has not yet been proven end to end. Version 2 also does not complete autosave, backup rotation, startup recovery discovery, migration receipts/hashes, downgrade behavior, descriptor-bound parent authority, or exhaustive power-loss injection. The PR must remain Draft until exact-head cross-platform checks and independent review cover the unchanged source. diff --git a/docs/traceability/project-persistence-shared-song-contract.md b/docs/traceability/project-persistence-shared-song-contract.md new file mode 100644 index 000000000..045b6767b --- /dev/null +++ b/docs/traceability/project-persistence-shared-song-contract.md @@ -0,0 +1,76 @@ +# Project Persistence Shared-Song Contract Traceability + +## Problem + +The desktop shared contract already permits collaboration data and role-level rehearsal fields, but the native Project Persistence DTO on #970 did not preserve them. Because the native DTO uses `deny_unknown_fields`, a renderer-valid `RehearsalSong` containing collaboration, `harmonicExplanation`, `transpositionPlan`, `transcription`, or `practiceProgress` could be rejected at save/load. Follow-up review found inverse drift modes as well: unrestricted Rust strings / unconstrained progress could accept invalid domain values, Rust `Option` would silently accept explicit JSON `null` where the TypeScript validator accepts only omission or a value of the declared type, and several older native fields still admitted arbitrary strings where the shared contract defines closed domains. + +## Constraints + +- #970/#962 remains the canonical Project Persistence owner; #1160 is evidence/consumer work, not a second durable storage authority. +- Preserve `projectFormatVersion: 1`, finite-positive tempo validation, strict unknown-field rejection, the existing golden fixture, and current atomic publication/recovery behavior. +- Do not serialize volatile `bandscope-playback` authorities into `.bscope` files. +- Do not replace the current file wholesale with the older #1160 snapshot because it predates #970's v1 envelope and later persistence hardening. +- Closed-domain validation must mirror the current shared renderer contract rather than inventing new persistence-only values. + +## RED → fix evidence + +- Structural RED: `93e9e80fa13d93692fdbd8d7d9acd10714ee8e8d` adds an integration contract requiring parse/serialize preservation of current collaboration and role fields. +- Structural fix: `819d8af80e425dc5627d86659a5fc97ec90c2767` adds typed native DTOs for those fields while retaining the existing v1/tempo/unknown-field invariants. +- Collaboration/progress RED: `6bcdf160a7e95cc540d96e49e25868c19a438106` proves invalid collaboration sync/status tokens and `practiceProgress = 101` must fail closed. +- Collaboration/progress fix: `a1cf37ea98db2f8024ca710d563d879c04204961` replaces unrestricted collaboration state strings with serde enums and bounds `practiceProgress` to an integer from 0 through 100. +- Optional-null RED: `ed61d1c5f10e2baa4290fb40d692b82fb7dde500` proves explicit `null` is not equivalent to an omitted optional field for collaboration, collaboration `roleId`, or role explanation/transposition/transcription fields. +- Optional-null fix: `8b4ae848ec360a5af42b50076af15b643ae5275e` uses one generic present-value deserializer so missing properties retain `None` compatibility while explicit `null` must deserialize as the declared value type and therefore fails closed. `ed9abedf0e5069fa93780fa3440ca91500cbdd93` extends the same regression coverage to optional `scoreAttachments`. +- Closed-domain RED: `2b0a47e6305b7b7a3e87857335d0f36dfabc9712` adds a current-song fixture with a valid user-owned harmony override and proves that invalid section labels, confidence levels/provenance, role types, harmony provenance, cue kinds, rehearsal priorities, export formats, and manual-override field/authority tokens must fail closed. +- Closed-domain fix: `96d66ed6f5fad918b0ddef8a1e6494b76f8bafd0` replaces those unrestricted native strings with serde enums that serialize to the exact shared values. Manual overrides use a dedicated user-only harmony payload so an outer `source: "user"` cannot mask a nested model-owned override value. +- Positive-domain coverage: `f8c30150375b39d54e1775d941f6515d2686410c` exercises every currently valid section-form, confidence, provenance, role-type, cue-kind, rehearsal-priority, and export-format token. This guards the serde rename rules, including `pre-chorus`, `cue-sheet`, and `chart-summary`, against a repair that rejects legitimate existing projects. +- Security-note contract RED: `d7886876b285f16ceda83ff5e0dd848e31cf7f97` extends the repository Security Notes verifier from plans to traceability records. The previous version of this document has no `Security Notes` section, so the governed check fails until the boundary below is explicit. +- Size-unit RED: `a7c86be8e20895e3baebee44d33ef765e0837b5f` adds an executable save/load regression requiring the buyer-visible error to name the `5 * 1024 * 1024` byte ceiling as 5 MiB. The predecessor implementation returned `5MB`, so the assertion is deterministically red without changing the byte limit. +- Size-unit fix: `04e19ef6d19aced87e22015e4ec165cbce89f1d0` changes the shared native size-limit error and its source-level regressions to `5 MiB`; `73d6a80183c19166b75be05f9286bee3769069e0` aligns the project-format documentation with the same binary unit. No admission threshold or memory bound changed. + +The shared renderer authority is `packages/shared-types/src/index.ts` on protected `develop`. Its relevant domains are: section form label `intro | verse | pre-chorus | chorus | bridge | outro | tag | pickup | stop | handoff`; confidence `low | medium | high`; provenance `model | user`; cue kind `lyric | count | transition`; role type `instrument | vocal | hand`; rehearsal priority `low | medium | high`; export format `cue-sheet | chart-summary`; manual override field `harmony` with both outer and value provenance fixed to `user`; collaboration sync `local_only | planned_cloud`; assignment status `todo | in_progress | ready | blocked`; comment status `open | resolved`; approval status `pending | approved | changes_requested`; and `practiceProgress`, when present, an integer from 0 through 100. Optional fields test `!== undefined` before validating the concrete declared type, so explicit `null` is invalid rather than another spelling of absence. + +## Alternatives rejected + +- **Copy the #1160 `lib.rs` snapshot:** rejected because it would overwrite later #970 persistence invariants and violate owner/consolidation boundaries. +- **Store new fields as `serde_json::Value`:** rejected because it weakens the fail-closed schema boundary and silently turns project compatibility into an untyped bag. +- **Keep shared closed domains as `String`:** rejected because malformed or future tokens could be persisted as if they were current domain values, creating renderer/native disagreement on reopen. +- **Use general provenance for manual overrides:** rejected because the shared `ManualOverride` contract requires both the override and its harmony value to be explicitly user-owned; allowing `model` there would change the authority meaning of persisted edits. +- **Clamp out-of-range practice progress:** rejected because changing user/project data on load hides corruption or contract drift; malformed input must fail closed. +- **Treat explicit `null` as omission:** rejected because the renderer parser does not do so, and normalizing malformed project input during load would conceal schema drift. +- **Keep `5MB` as shorthand for a binary ceiling:** rejected because the implementation uses 5 × 1024 × 1024 bytes. The error is buyer-visible diagnostic truth and must distinguish MiB from decimal MB rather than relying on ambiguous colloquial usage. + +## Effects and remaining risks + +A current shared rehearsal song can now cross the native Project Persistence boundary without dropping the newly covered fields. Collaboration/progress states, omission-versus-null semantics, and the renderer's closed section/role/confidence/provenance/cue/export/manual-override domains are represented by native typed values rather than arbitrary strings. The project byte ceiling remains exactly 5,242,880 bytes; only its buyer-visible unit and documentation were corrected from ambiguous `MB` to `MiB`. This does not complete #962. Transcription-number semantics and other legacy invariants still need evidence-driven cross-language comparison; the shared validator currently type-checks `onset`, `offset`, and `velocity` as JavaScript numbers rather than defining rehearsal-specific numeric bounds, so persistence must not invent such bounds without a product/scientific contract. Autosave, backup rotation, global startup recovery, deterministic migrations beyond v1, fault injection, and selected-playback-source persistence/reload remain open. + +Selected playback source persistence must use a stable semantic (`full_mix | vocals | bass | drums | other`) and resolve a fresh native playback authority on reopen; a missing source must fail closed to Full mix. + +## Security Notes + +### Attack surface + +`.bscope` content is untrusted local file input, and save targets, recovery journals, staged files, backup/displaced files, file metadata, collaboration payloads, role-level rehearsal data, and renderer-provided project JSON all cross trust boundaries. Project files can therefore exercise parser, filesystem, recovery, and local-privacy failure modes even though BandScope remains local-first and this slice adds no network authority. + +### Trust boundary + +The renderer may submit only the shared `RehearsalSong` contract. Native Project Persistence is the storage authority: it admits the versioned envelope, applies `deny_unknown_fields`, validates finite-positive tempo and closed-domain enums, rejects explicit `null` where omission is the only absent form, and keeps volatile `bandscope-playback` authorities out of durable project state. Filesystem authority remains confined to the user-selected target plus BandScope-owned same-parent staging/recovery names after parent-chain, final-component, regular-file, native-identity, size, and platform checks. + +### Mitigations + +Validation uses explicit allowlists for the project envelope and current shared domains instead of `serde_json::Value` bags or permissive strings. Reads are bounded to the 5 MiB project limit and use no-follow/native-identity checks. Saves stage and sync complete bytes before publication, preserve data-file permissions without executable/special bits, and use target-scoped prepared recovery journals plus parent-directory synchronization around replacement and cleanup. Recovery acts only on the exact target and BandScope-owned candidate/displaced identities; mismatched or ambiguous state fails closed rather than deleting or following arbitrary paths. + +### Safe failure and logging/privacy + +Malformed envelopes, unsupported versions, invalid shared-domain tokens, explicit-null drift, unsafe paths, identity mismatches, oversized files, and unrecoverable journal states return bounded product errors without echoing project contents, local paths, collaboration text, credentials, or secret-shaped values into logs. Failure must retain known-good project data or retryable recovery state whenever mutation has begun; it must not silently coerce corrupt values, fabricate a source selection, or fall back to direct non-atomic overwrite. + +### Test points + +Executable coverage includes shared-song parse/serialize parity, closed-domain positive and negative cases, omission-versus-null behavior, progress bounds, v1 fixture compatibility, bounded read/write size, exact MiB diagnostic wording for the binary project limit, symlink/reparse and ancestor checks, native file identity, first-save/no-clobber behavior, existing-target replacement, stage cleanup, permission normalization, Windows replacement/recovery, macOS/Windows case-alias recovery, completed rollback, and stale-journal cleanup. `scripts/checks/verify_security_notes.py` also treats traceability records as governed Security Notes documents so later edits cannot silently drop this boundary. + +### Realistic threats + +The realistic threats are malformed or future project payloads being accepted as current truth; a local directory participant racing or pre-creating recovery names; link/reparse redirection; file replacement between preflight and publication; interruption during replacement/rollback; permissive file modes exposing rehearsal data to another local account; and stale runtime playback authorities being mistaken for durable project truth. The controls are scoped to local project persistence and do not claim protection against a fully compromised operating system or an attacker with equivalent account authority. + +### Remaining risk + +Parent authority is still path-based after lexical validation, so concurrent ancestor replacement is not yet descriptor-bound. Recovery is target-scoped and normally runs when that project path is selected rather than through a global startup scan. Autosave, backup rotation, deterministic migrations beyond v1, exhaustive power-loss/fault injection, and selected-playback-source persistence/reload remain #962 work. Those gaps must stay explicit and must not be described as crash-safe or shipped until current-head cross-platform evidence proves the corresponding implementation. diff --git a/docs/traceability/project-v2-ipc-bridge.md b/docs/traceability/project-v2-ipc-bridge.md new file mode 100644 index 000000000..0856b7d8a --- /dev/null +++ b/docs/traceability/project-v2-ipc-bridge.md @@ -0,0 +1,52 @@ +# Project v2 IPC Bridge Traceability + +## Problem + +Project Persistence already owned a strict `projectFormatVersion: 2` document and a durable `preferences.selectedPlaybackSource` semantic, but the production desktop bridge still admitted and returned only the `RehearsalSong` compatibility view. A mounted Active Player therefore had no typed save/reopen path for `full_mix | vocals | bass | drums | other` without inventing a second WebView store or persisting a revocable `bandscope-playback` authority. + +A later review of the renderer admission boundary found that its helper was named `isPlainRecord` but accepted any non-array object with the expected enumerable keys, including class instances with custom prototypes. Native Rust admission still failed closed on JSON shape, so this was not a demonstrated filesystem escape; it was nevertheless an avoidable mismatch between the documented exact JSON-record trust boundary and the renderer implementation. + +## Constraints + +- Project Persistence remains the only durable `.bscope` authority. +- Playback source persistence stores only the stable semantic; paths, native capability URLs, generation tokens and source-discovery receipts stay runtime-only. +- Legacy song-only desktop callers must continue to save and load without a breaking call-site migration; their deterministic preference remains `full_mix`. +- Unknown root/preference fields, prototype-bearing renderer records and runtime authority strings fail closed on renderer admission; native admission repeats the typed JSON boundary before persistence. +- This bridge does not claim that the mounted #1160 selector is already wired to Save/Reopen or that a reopened stem authority is reusable. Reopen must resolve the stored semantic against fresh native availability and mint a new authority. + +## RED + +Commit `ecc2904f55516806b51baa4bbafeef9d700b058c` adds a renderer bridge contract covering all five stable source semantics, round-trip load, rejection of a realistic `bandscope-playback://project-400-4/vocals?generation=7` authority and rejection of unknown preference fields. The predecessor `analysis.ts` exported neither `saveProjectDocument` nor `loadProjectDocument`, so this contract could not compile or pass. + +Commit `3db1096baa52de34baa7fea4c1638185914d22b7` adds a focused renderer admission regression for custom-prototype outer documents and preference objects while retaining a positive ordinary JSON-shaped document case. The predecessor `isPlainRecord` accepted both prototype-bearing objects. + +## Implementation + +- `30bfa590df61a2b031076af81010f3e5f31372ea` adds the Project Persistence TypeScript anti-corruption boundary. It validates exact `{ song, preferences }` shape, parses the shared `RehearsalSong`, closes `selectedPlaybackSource` to the five durable semantics and rejects runtime-only/unknown state. +- `64613fbb604c4ddc6d156c84bc520dd8d40cef19` makes `saveProjectDocument` and `loadProjectDocument` cross the existing Tauri command boundary. Existing `saveProject(song)`/`loadProject()` remain compatibility adapters; song-only saves default to `full_mix` rather than fabricating a historical stem choice. +- `7f9d118b08038fd5473b71f0a1243136b39e04bc` changes native `save_project` to `project_document_from_value` + `project_content_for_document` and `load_project` to return `ProjectDocumentPayload` through `project_document_from_content`. +- Review of that native edit found one unrelated line accidentally changed in `remove_score_pdf`; `327c83f86c1ed213a1f6a58d382715e744ab9831` immediately restores the original project-scoped score root. That transient defect is not treated as valid product delta. +- `7cc4869560155039ff1e2e10d171505885dc39e3` makes renderer record admission match its stated JSON-record contract: only `Object.prototype` or null-prototype records are accepted, and prototype inspection failure itself fails closed. The durable field/domain contract is unchanged. +- `3f4ce38c2be533a7b8bc90cd67b702d624cd3d1a` closes the new branch/edge evidence around that fix: a throwing `getPrototypeOf` proxy fails closed, the null-prototype path remains intentionally accepted, and ordinary objects remain accepted. + +## Alternatives rejected + +Persisting the opaque playback URL was rejected because its generation/session authority is intentionally revocable. Storing the preference in `localStorage` was rejected because it creates a second writable project truth. Adding stem preference fields to `RehearsalSong` was rejected because playback choice is project/UI preference, not MIR song evidence. Replacing the existing song-only APIs outright was rejected because unrelated current callers do not yet own Active Player source state. Treating arbitrary class instances as equivalent to JSON objects was rejected because custom prototypes have no durable `.bscope` semantics and expand the renderer-side trust surface without buyer value. + +## Security Notes + +**Attack surface.** Renderer IPC and reopened `.bscope` JSON are untrusted inputs; playback capability strings are also untrusted and must not become durable authority. Renderer values may originate from application code before serialization, so the renderer adapter must not silently admit prototype-bearing object shapes as if they were plain project records. + +**Trust boundary.** The TypeScript adapter validates exact current-document shape before invoke/after load, while the Rust Project Persistence owner repeats strict typed admission before filesystem mutation and after bounded file read. Runtime playback authority is resolved later by the Active Player/native availability boundary. + +**Mitigations.** Exact-key checks, plain-record prototype checks, the closed five-value source domain, `parseRehearsalSong`, Rust `deny_unknown_fields`, the v2 closed enum, bounded project reads and atomic publication prevent unknown/runtime state from being silently persisted. Prototype inspection exceptions fail closed. Song-only compatibility writes use the deterministic `full_mix` default. + +**Test points.** The bridge contract exercises all five durable semantics, load round trip, runtime-authority rejection and unknown-field rejection. `projectDocument.plainRecord.test.ts` exercises custom-prototype rejection for both the outer document and nested preferences, fail-closed prototype inspection, the intentional null-prototype path and an ordinary JSON-shaped positive case. Existing Rust v2 fixtures/migration contracts continue to cover disk representation and legacy/v1 migration. + +**Realistic threats.** A renderer bug or compromised WebView could attempt to persist an absolute path, stale playback capability URL, extra writable state or prototype-bearing object in place of the declared JSON record; a crafted project could return an unsupported source semantic. Both sides fail closed rather than treating those values as project truth. + +**Remaining risk.** #1160 has not yet consumed `loadProjectDocument()` to restore selector intent, nor resolved that intent against fresh native source availability after reopen. More fundamentally, the current App clears `jobResultBootstrap` on project load, so a reopened project does not yet restore the source/bootstrap authority needed for audible playback after process restart. Selected-source composition must not be presented as complete until the Project Persistence source-reference boundary is defined and tested. Packaged Windows/macOS Save/Reopen acceptance and crash/power-loss evidence remain required. + +## Effect + +The persistence format and desktop IPC speak the same current v2 document without copying native path authority into the renderer contract, and renderer-side admission now matches the documented plain JSON-record boundary with explicit edge coverage. This completes the v2 document bridge prerequisite itself; it does not complete source/bootstrap restoration, Active Player reopen interaction or #962 recovery/autosave scope. diff --git a/scripts/checks/verify_security_notes.py b/scripts/checks/verify_security_notes.py index 821a5e940..7edc1597f 100644 --- a/scripts/checks/verify_security_notes.py +++ b/scripts/checks/verify_security_notes.py @@ -1,9 +1,9 @@ -"""Verify that design-plan documents include a complete Security Notes section.""" +"""Verify that security-sensitive design and traceability documents include Security Notes.""" from pathlib import Path SECURITY_NOTES_TEXT = "Security Notes" -PLAN_DIR = Path("docs/plans") +SECURITY_NOTE_DIRS = (Path("docs/plans"), Path("docs/traceability")) REQUIRED_SUBSECTIONS = [ "attack surface", "trust boundary", @@ -15,7 +15,7 @@ def security_notes_section(content: str) -> str: - """Extract the lowercased Security Notes section from a plan document.""" + """Extract the lowercased Security Notes section from a governed document.""" lowered = content.lower() marker = SECURITY_NOTES_TEXT.lower() start = lowered.find(marker) @@ -34,10 +34,19 @@ def security_notes_section(content: str) -> str: return lowered[start : min(end_candidates)] +def governed_documents() -> list[Path]: + """Return plan and traceability documents governed by the Security Notes contract.""" + return [ + path + for directory in SECURITY_NOTE_DIRS + for path in sorted(directory.glob("*.md")) + ] + + def main() -> int: """Return a failing exit code when Security Notes or required subsections are missing.""" missing: list[str] = [] - for path in sorted(PLAN_DIR.glob("*.md")): + for path in governed_documents(): content = path.read_text(encoding="utf-8") if SECURITY_NOTES_TEXT not in content: missing.append(str(path)) diff --git a/services/analysis-engine/tests/test_project_persistence_workflow_policy.py b/services/analysis-engine/tests/test_project_persistence_workflow_policy.py new file mode 100644 index 000000000..1fd94a013 --- /dev/null +++ b/services/analysis-engine/tests/test_project_persistence_workflow_policy.py @@ -0,0 +1,29 @@ +"""Regression coverage for the Windows Project Persistence evidence lane.""" + +from pathlib import Path + + +def test_windows_project_persistence_gate_tracks_contract_inputs() -> None: + """Run the Windows regression whenever a persistence contract input changes.""" + repo_root = Path(__file__).resolve().parents[3] + workflow = (repo_root / ".github" / "workflows" / "project-persistence-windows.yml").read_text( + encoding="utf-8" + ) + + required_paths = ( + '"apps/desktop/core/Cargo.toml"', + '"apps/desktop/core/src/lib.rs"', + '"apps/desktop/core/src/crate_root.rs"', + '"apps/desktop/core/src/project_format.rs"', + '"apps/desktop/core/tests/project_persistence*.rs"', + '"apps/desktop/core/tests/project_format*.rs"', + '"apps/desktop/core/testdata/project-*.json"', + '"apps/desktop/src-tauri/Cargo.toml"', + '"apps/desktop/src-tauri/Cargo.lock"', + '"apps/desktop/src-tauri/src/main.rs"', + '"apps/desktop/src-tauri/src/project_persistence.rs"', + '"apps/desktop/src-tauri/tests/project_persistence*.rs"', + ) + + for required_path in required_paths: + assert required_path in workflow, f"Windows persistence workflow misses {required_path}"